Developers · Configuration

Almost nothing here is module configuration in the config:/modules/h1-seo sense — that path holds exactly one node, the bootstrapped CSV import command. The module is configured by content properties (fields on page and item nodes) and by YAML in light modules. This page is the reference for both, plus the REST surface, the virtual URIs and the MCP tools.

Metadata

Fields

All fields come from h1-seo-metadata/includes/fields.yaml and land as flat properties on the node, because every composite in the chain uses currentItemProvider. The h1seo prefix exists because a page node's properties are a shared namespace: other modules decorate the same dialog.

Property Field type i18n Notes
h1seoTitle textField maxLength: 60. Empty ⇒ the service falls back to title, then to name
h1seoDescription textField, 3 rows maxLength: 160. Empty ⇒ falls back to h1seoSummary
h1seoSummary textField, 4 rows No length limit. Also the description source for llms.txt
h1seoNoindex checkBoxField Boolean. Also excludes the page from llms.txt. Does not affect the sitemap
h1seoNofollow checkBoxField Boolean
h1seoOgImage damLinkField Persists as jcr:<uuid>, not a bare uuid. Returned raw by REST; resolved to a URL by the FTL include
h1seoTwitterCard comboBoxField summary_large_image (default) · summary
h1seoSchemaType comboBoxField WebPage (default) · Article · Product · Organization · Person. Also drives ogType
h1seoTopics multiValueField of textField Multi-value String property. A single-value property is also read
h1seoFaq jcrMultiField of compositeField (jcrChildNodeProvider) A child node h1seoFaq whose children carry question + answer, both i18n
h1seoJsonLd codeField (language: json) Raw override. Replaces the generated JSON-LD entirely; the FAQPage block is emitted separately and survives

i18n values follow the Magnolia convention: the default language in the bare property, other languages in <property>_<locale>.

The five composites that group them — h1seoContentGroup, h1seoIndexingGroup, h1seoSocialGroup, h1seoStructuredGroup, h1seoAdvancedGroup — are visual only. They use currentItemProvider, so they add no nesting to the stored data.

Two channels, one service

MetadataService is parameterised by workspace and serves two different kinds of node:

Channel Method Endpoint Node type check
Pages getPage(path, marca) GET /v1/seo/metadata[/{path}] requires mgnl:page, in website
Content items getByUuid(workspace, uuid, marca) GET /v1/seo/metadata/content/{workspace}/{uuid} none — any node type, any workspace

Content items are addressed by uuid, not path, because an item's path is neither stable nor known to the consumer, while the uuid is returned by the content type's own endpoint. A workspace that is not registered is a 404, not a 500.

The marca query parameter

marca wins over the path. It prefixes the path idempotently and selects the site whose normalised rootWebsite equals /{marca}:

Request Resolved page path
?marca=M3 + /case-studies /M3/case-studies
?marca=M3 + /M3/case-studies /M3/case-studies (not duplicated)
?marca=M3 + / (or empty) /M3 — the brand's home
no marca + /M3/home /M3/home — the consumer sends the full path

Site resolution differs per channel:

  • Pages, with marca — exact match on rootWebsite. A brand with no matching site is not an error: the metadata is returned without the globals.
  • Pages, without marca — prefix match, longest rootWebsite that prefixes the page path wins.
  • Content itemsmarca is the only source. Without it no site is resolved and the response has no globals at all; the path of an item in another workspace cannot be compared against rootWebsite without producing false positives.

Site globals (h1-sites, optional)

Read by workspace name, with no compile-time dependency on h1-sites. Sites whose node name starts with jcr: or rep:, or that have no rootWebsite, are skipped.

Site property Feeds
rootWebsite Site resolution / brand matching
name (i18n) ogSiteName and publisher.name
logo publisher.logo (raw DAM reference)
organizationType publisher.type (default Organization)
defaultOgImage ogImage fallback when the node has none
socialMedia (child nodes, property value) publisher.sameAs[]

If the workspace is absent or unreadable the whole block degrades silently to per-node values.

Response

{
  "title": "…",
  "description": "…",
  "summary": "…",
  "noindex": false,
  "nofollow": false,
  "ogType": "website",
  "ogImage": "jcr:2f9c…",
  "ogImageAlt": "…",
  "ogSiteName": "…",
  "twitterCard": "summary_large_image",
  "schemaType": "WebPage",
  "topics": ["…"],
  "faq": [{ "question": "…", "answer": "…" }],
  "datePublished": "2026-03-04",
  "dateModified": "2026-09-01",
  "jsonLd": null,
  "publisher": { "type": "Organization", "name": "…", "logo": "…", "sameAs": ["…"] }
}
  • Always present: noindex, nofollow (primitive booleans, false when unset), ogType, twitterCard, schemaType, topics and faq (possibly []).
  • Omitted when empty, not returned as null: title, description, summary, ogImage, ogImageAlt, ogSiteName, datePublished, dateModified, jsonLd, publisher.
  • ogType is derived from schemaType: Articlearticle, Productproduct, anything else ⇒ website.
  • ogImageAlt reuses the resolved title.
  • Dates come from mgnl:created and mgnl:lastModified, formatted yyyy-MM-dd.
  • i18n values are resolved to the request's language (Accept-Language, matched against the site's configured locales, falling back to the default).
  • Asset references (ogImage, publisher.logo) are raw DAM references — the consumer resolves the absolute URL.
  • There is no canonical. The front end generates it with its own routing; a JCR path is not a URL, least of all for a content item in another workspace. The Freemarker include does emit <link rel="canonical">, derived from the page via cmsfn.link.

The seo-metadata.ftl include

h1-seo-metadata/includes/seo-metadata.ftl is the server-rendered counterpart. It emits <title>, the meta description, Open Graph, Twitter card, the canonical link, <meta name="robots"> when h1seoNoindex/h1seoNofollow are set, one JSON-LD block (the h1seoJsonLd override, or a generated one with url/name/headline/description/image/datePublished/dateModified/about/publisher), and a second FAQPage block when h1seoFaq has children.

It takes one optional variable, seoNode, assigned before the include: the node the h1seo* properties are read from. Unset, it falls back to cmsfn.page(content). The canonical URL and the site globals are always derived from the page, never from seoNode. Every optional block is wrapped in [#attempt], so a missing h1-sites, a missing DAM asset or a missing ACL degrades the output instead of breaking the render.

Sitemap

Fields

Contributed by h1-seo-sitemap/includes/fields.yaml, flat on the page node.

Property Field type Notes
h1sitemapExcludeFromSitemap checkBoxField Excludes the page and its whole subtree — the traversal does not descend
h1sitemapPriority textField Validated in the form against ^(0(\.\d+)?\|1(\.0+)?)$. Empty, or a stored value that fails the same check in the service, ⇒ 1.0 - depth × 0.1, floored at 0.1, formatted with one decimal
h1sitemapChangeFreq comboBoxField always, hourly, daily, weekly (default), monthly, yearly, never. A stored value outside the set falls back to weekly
h1sitemapSourceWorkspace textField Turns the page into a dynamic one — see below. Empty for a normal page

Root resolution

build(root) reads website from root (default /) and returns loc paths relative to the root, because the domain points at the root.

Root node Behaviour
A page Included, then descended
rep:root or a folder Its child pages are collected
Non-existent Empty list — not an error

Only mgnl:page nodes are traversed. lastmod is mgnl:lastModified, falling back to jcr:created, falling back to now, formatted yyyy-MM-dd.

Warning — the only node names skipped unconditionally are sitemap and robots.txt. A page named robots or llms is traversed and published; tick Exclude from sitemap on them if you do not want them listed. (LlmsService skips a wider set — see below.)

Dynamic pages: one entry per item

Set h1sitemapSourceWorkspace on a page template that renders one URL per item via a selector. The page's own URL is then replaced by one entry per item:

  • loc = the page's parent path (relative to the root) + the item's slug. /case-studies/detail yields /case-studies/{slug}.
  • The slug comes from the item's slug property, falling back to the node name.
  • Items are mgnl:content nodes, collected recursively in JCR child order under the same root as the sitemap/h1 as the sitemap root means the items are read from /h1 in the source workspace. If that path does not exist there, no items are emitted.
  • lastmod comes from the item; priority and changefreq come from the dynamic page, since all items share the template.
  • A missing or unreadable workspace yields no items, silently.

Multilingual output

Driven by RequestLocale.orderedLocaleCodes() — the current site's configured locales, default first, falling back to the global i18n configuration when no site is resolved.

Languages Output
0 or 1 Bare paths, alternates: []
More than 1 One entry per language, all sharing the same complete alternates list: one per locale plus x-default pointing at the default language's variant, not at the bare path

One exception, the home page. The default language's home is emitted bare (/, and its x-default too), because the front end 301s /{defaultLang}/. Other languages' homes are emitted as just their prefix (/en).

Warning — in a renderer, a loc that is only a language prefix (/en) is that language's home: a directory URL, not /en.html. The shipped sitemap.ftl handles this by comparing the single segment against model.locales.

The rendered channel

h1-seo-sitemap:pages/sitemap uses SitemapModel, which exposes entries (from the service, with the root taken from the page's parent path) and locales. Both SitemapModel and LlmsModel are best-effort: on an exception they log and return an empty result rather than break the render.

Robots

Property Where Notes
h1robotsContent the robots page Free text, textField with 18 rows. Ships with a default body

RobotsService.getByRoot(root) resolves {root}/robots in website and returns {"content": …}. A page that exists with no property answers ""; a page that does not exist raises RobotsNotFoundException404. The service exists for the headless channel only — the Freemarker template reads the property directly, because it is already rendering that page.

robots.ftl sets text/plain;charset=UTF-8, prints the property and appends:

Sitemap: {scheme}://{serverName}{contextPath}/sitemap.xml

The Sitemap: line is not part of the stored content and not part of the REST response: each channel adds it with its own domain.

llms.txt

No fields of its own. It is the sitemap's twin for AI, and reuses:

Property From Effect
h1seoNoindex h1-seo-metadata Drops the page's own entry — but the traversal keeps descending, so its children still appear. On a collection item, drops that item
h1sitemapSourceWorkspace h1-seo-sitemap Expands one link per item, and stops descending (a dynamic page has no real child pages to list)

Structure

# {site title}

> {site description}

## {first-level page}

- [Page title](https://host/path.html): description
  - [Nested page](https://host/path/child.html): description
  • title = the site's name (i18n) → the root page's title → the last segment of the root (Website for /).
  • description = the site's description (i18n) only. Omitted when there is no site.
  • One ## section per first-level page under the root, in JCR child order. A section with no entries is dropped.
  • depth is relative to the section's top page and drives the Markdown indentation.

Description precedence, first non-blank wins:

Node Precedence
Page h1seoSummaryh1seoDescriptiontitle
Collection item h1seoSummarydescriptionsummarysubtitle

Titles: a page uses title (i18n) → node name; an item uses titlename → node name.

Node names sitemap, sitemap.xml, robots, robots.txt, llms and llms.txt are all skipped.

Links carry the default language's prefix only — no duplication per language, unlike the sitemap — and the home stays bare. So a link that is only a prefix never occurs here, which is why llms.ftl has no directory-URL case.

Note — site resolution for llms.txt also matches a site whose rootWebsite starts with the root, so at root / every site matches and the longest rootWebsite wins. On a multi-brand instance, build llms.txt per brand (/brand-a/llms) rather than at the root, or the title and description will be a brand's.

Redirects

Content type

Workspace h1-seo-redirects, node type mgnl:content, created on demand (autoCreate: true). Folders /{brand} are organisational.

Property Declared in the content type Form field Values
sourcePattern String, required: true textField + regexpValidator Must match ^[/^].* — starts with / or ^
targetUrl String, required: true textField Path or absolute URL
redirectType String, required: true comboBoxField 301 (default) · 302. Any stored value other than 301 resolves to 302
matchType String, required: true comboBoxField exact (default) · prefix · regex, lower-cased on load
active Boolean checkBoxField A rule is loaded only if the property exists and is true
description not declared richTextField Internal notes. Present in the form and in the model, but not in the content type's property list

Note — the validator pattern is written with single quotes in YAML on purpose. In double quotes, a regex containing a backslash escape YAML does not recognise (\^) breaks parsing.

Matching and precedence

Match type Match rule Target
exact path.equals(from) to, verbatim
prefix path == p or path.startsWith(p + "/"), where p is from minus a trailing /* to + the remaining tail — /blog/*/news/ turns /blog/x into /news/x
regex path.matches(from); an invalid regex is logged and skipped path.replaceAll(from, to), so $1, $2… substitute. An exception falls back to to

Precedence is a computed weight — there is no manual priority field:

exact  → 30000 + pattern length
prefix → 20000 + pattern length
regex  → 10000 + pattern length

Higher wins. The rules are returned sorted by weight descending, and the public REST response carries weight, so a front end sorting by it resolves exactly as Magnolia does. The virtual URI mapping also propagates the weight to VirtualUriMapping.Result, so the VirtualUriFilter picks by specificity across all mappings.

The original query string is appended to the target when the target does not already contain a ?.

Language handling

On a multilingual site:

  • Match — the path is tried as it came and, if nothing matches, again with the leading /{lang} stripped. So a rule written root-relative (/products/old) catches both /es/products/old and /en/products/old, and a rule written with a language applies to that language only.
  • Target — the language is written exactly once, by precedence: the target's own language → the request's language → the site's default. So the result always carries a language, with one exception: the default language's home is emitted bare (/).
  • Absolute targets (http(s)://…) pass through untouched. So does everything on a monolingual site.
  • The query string is split off before the language is inspected, so a target like /es?x=1 is recognised as already prefixed.

Cache and invalidation

Source of truth The h1-seo-redirects workspace — one loader for both channels
Read as System context, so no anonymous ACL is involved and it works outside a request
Loading A tree walk, not a SQL2 query, so a rule created a moment ago is visible immediately (a query depends on the Jackrabbit index, which can lag). session.refresh(false) discards the stale session state
Cache In memory, TTL 60 s
Invalidation A JCR observation listener on / (NODE_ADDED, NODE_REMOVED, NODE_MOVED, PROPERTY_ADDED/CHANGED/REMOVED), registered lazily on first load. RedirectCrudService also calls invalidate() after every write, and the MCP test action calls it before resolving

The live site self-invalidates through observation; the TTL is the backstop for the case where the listener could not be registered (which logs a warning once).

Channels

Channel How
Magnolia (server-rendered) RedirectVirtualUriMapping, registered by virtualUriMappings/redirects.yaml. It receives the URI without the context path plus the query string, and returns permanent: (301) or redirect: (302) for RequestDispatchUtil
Headless GET /.rest/v1/seo/redirects[/{root}] returns the active rules for the front end to apply

Warning — the virtual URI channel evaluates all active rules, with no brand scoping. Brand folders only filter the REST channel, through root, using the node path (which is not serialised). If a rule must apply to one brand only, encode the brand in sourcePattern.

CSV

Headers, in order — marca always first:

Column Meaning
marca The /{brand} folder the rule lives in. Empty means the workspace root. The value in the file wins over the folder open in AdminCentral
name The JCR node name — the identity of the row. Keeping it makes a re-import update instead of duplicate. Empty ⇒ derived from sourcePattern via StringUtils.toNodeName, then from targetUrl, then a random UUID
sourcePattern, targetUrl, redirectType, matchType, description As above; blank cells become null
active Parsed with Boolean.parseBoolean, so only true (any casing) is true. A blank cell is null, and the mapper skips a null: on an update the stored value survives, on a create the property is simply absent

The engine itself lives in h1-helpers (CsvImportRunner, CsvWriter, CsvResponses, CsvExportScope). This module contributes only the business logic (RedirectCsvImportHandler: identity by /{marca}/{nodeName}, creation via createInMarca) and the YAML that configures the generic command:

# mgnl-bootstrap/h1-seo/config.modules.h1-seo.commands.h1-seo.importRedirectsFromCsv.yaml
importRedirectsFromCsv:
  class: com.habito1.helpers.csv.ui.CsvImportCommand
  enabled: true
  handlerClass: com.habito1.redirects.importer.RedirectCsvImportHandler
  messageSubjectKey: h1-seo-redirect.csv.import.subject
  messageBodyKey: h1-seo-redirect.csv.import.result

The dialog's Import button is a CsvImportCommandActionDefinition bound to catalog h1-seo, command importRedirectsFromCsv. The two download buttons and the list-view export use OpenRelativeUrlActionDefinition; folderParam: folder is what sends the selected folder's path so export and re-import stay symmetrical.

REST reference

Base path /.rest. The restEndpoints YAML files do not declare a path, so the @Path annotation decides. All endpoints are GET; everything is application/json unless noted.

Endpoint Parameters Returns
/v1/seo/sitemap All entries from /
/v1/seo/sitemap/{root} root — multi-segment path Entries under root, loc relative to it
/v1/seo/robots {"content": …} for the root robots page. 404 if the page does not exist
/v1/seo/robots/{root} root That brand's robots content
/v1/seo/llms LlmsDoc for /
/v1/seo/llms/{root} root LlmsDoc under root
/v1/seo/metadata marca Metadata of the root page (/, or /{marca})
/v1/seo/metadata/{path} path — multi-segment, marca Metadata of a page in website. 404 if it does not exist or is not mgnl:page
/v1/seo/metadata/content/{workspace}/{uuid} marca Metadata of a content item. 404 on unknown workspace or uuid
/v1/seo/redirects All active rules, weight descending
/v1/seo/redirects/{root} root — the brand folder Active rules whose node lives under that folder
/v1/seo/redirects-admin/csv-template text/csv — header row only
/v1/seo/redirects-admin/csv-export folder — JCR path text/csv — current rules, all of them, including inactive ones

/v1/seo/metadata/content/{workspace}/{uuid} wins over /v1/seo/metadata/{path} by JAX-RS precedence: it has more literal characters.

Warningredirects-admin is a separate path from redirects on purpose. The public one returns active rules only; the admin one dumps everything, inactive rules and internal descriptions included. Do not expose redirects-admin to anonymous traffic.

Errors are ApiError bodies from h1-helpers: 404 for the not-found cases above, 500 otherwise, always application/json.

Virtual URIs

Public URL Regex Forwards to
/sitemap.xml, /{brand}/sitemap.xml ^(/[^/.]+)?/sitemap\.(?:xml\|xml\.html)$ forward:$1/sitemap
/robots.txt, /{brand}/robots.txt ^(/[^/.]+)?/robots\.(?:txt\|txt\.html)$ forward:$1/robots
/llms.txt, /{brand}/llms.txt ^(/[^/.]+)?/llms\.(?:txt\|txt\.html)$ forward:$1/llms
any RedirectVirtualUriMapping (class-based, decided per request)

Group 1 is the brand: a single segment containing neither / nor .. The .html variant is matched so the rendered page URL works too. The regexes are single-quoted in YAML — with double quotes the \. breaks parsing.

MCP tools

Present only when h1-mcp-api is installed. Each is a YAML file under h1McpTools/ declaring its class; McpToolRegistry watches that folder in every module, the same way virtual URI mappings are discovered. The tools resolve their services with Components.getComponent — they are instantiated by node2bean, not by Guice.

Tool Class Scope
seo_metadata SeoMetadataTool get, set (PATCH), clear, preview on the h1seo* fields of one node — pages by path, content items by workspace + uuid
seo_sitemap SeoSitemapTool create, get, delete, preview for the sitemap page, plus get_page_options / set_page_options on any page
seo_robots SeoRobotsTool create, get, delete, preview for the robots page, plus set_content
seo_llms SeoLlmsTool create, get, delete, preview for the llms page. No editable content
redirect RedirectTool The standard content-type actions — list, get, create, update, delete, publish, unpublish, create_folder, move, rename, rename_folder, delete_folder, reorder, remove_property — plus test

seo_metadata

  • Declares ownedFields() = the eleven h1seo* fields. That is what makes another content type's tool reject them and name this tool, instead of discarding them silently.
  • Validates before writing: title ≤ 60, description ≤ 160, h1seoTwitterCard and h1seoSchemaType against their enums, h1seoJsonLd parsed as JSON. An unknown key is rejected with the list of valid ones.
  • set is a PATCH: only the keys present are touched, and an explicit null removes the property. h1seoFaq and h1seoTopics are full replacements. Other languages go in translations, never in invented h1seoTitle_en keys.
  • clear removes the property, its _<locale> variants and, for h1seoFaq, the child node.
  • preview returns the effective result through MetadataService — the same code path the endpoint serves — and, on the content-item channel without marca, adds a note saying the globals are missing for that reason.
  • Guardrail: on a workspace other than website the content type must actually expose h1seo* in its form (checked with ContentTypeIntrospector.flatFormFieldNames). Otherwise the call is refused, naming the missing decoration — writing there would leave properties nothing edits and nothing serves.

The three page tools

AbstractSeoPageTool centralises the lifecycle rules for sitemap, robots and llms:

  • Forced path: always /{marca}/<type>, or /<type> at the root. The caller chooses the brand, never an arbitrary path.
  • No extension: the node is sitemap, never sitemap.xml. marca is validated as a single segment with no / and no ., and the brand page must exist and be an mgnl:page.
  • One per brand: create fails if the page exists, or if a differently named sibling already uses the same template.
  • A real page: create writes an mgnl:page node with mgnl:template, even on a headless instance, so the page is visible in the tree.
  • delete is a two-step confirmation and unpublishes first; preview calls the feature's service.

Decoration inventory

File Target Adds
h1-seo/decorations/admincentral/config.yaml admincentral The h1seo launcher group with h1-seo-redirects-app
h1-seo/decorations/frontend-md3/dialogs/pages/<Tpl>/<Tpl>.yamlBase, FrontPage, CaseStudyDetail frontend-md3 page dialogs The empty h1-seo composite and the tab
h1-seo-metadata/decorations/frontend-md3/dialogs/pages/<Tpl>/<Tpl>.yamlBase, FrontPage idem The h1-seo-metadata panel
h1-seo-metadata/decorations/h1-ecommerce-light-module/apps/h1-ecommerce-products-app.yaml products app Tab + metadata panel
h1-seo-metadata/decorations/h1-case-studies-light-module/apps/h1-case-studies-app.yaml case studies app Tab + metadata panel
h1-seo-sitemap/decorations/frontend-md3/dialogs/pages/<Tpl>/<Tpl>.yamlBase, FrontPage, CaseStudyDetail frontend-md3 page dialogs The h1-seo-sitemap panel
h1-seo-{sitemap,robots,llms}/decorations/frontend-md3/sites/fallback.templates.availability.templates.yaml the fallback site Each feature's page template id
h1-seo/decorations/frontend-md3/sites/fallback.templates.availability.templates.yaml the fallback site The sitemap template id again — the same value the h1-seo-sitemap file contributes, so it is redundant but harmless

i18n bundles

Six bundles, each with a default, _en and _es file: module-h1-seo-messages (the tab label h1-seo.tab.h1-seo.label and the launcher group), plus one per feature light module. Every label, description and error message in the module is a key — none is inline.

Module components

Registered as singletons in META-INF/magnolia/h1-seo.xml, component set main:

SitemapService · RobotsService · MetadataService · LlmsService · RedirectService · RedirectRepository · RedirectMapper · RedirectCrudService · RedirectCsvImportHandler

The module class, com.habito1.H1SeoModule, is a bean with no configuration of its own. H1SeoModuleVersionHandler holds the deltas listed in Developers · Installation.