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 onrootWebsite. A brand with no matching site is not an error: the metadata is returned without the globals. - Pages, without
marca— prefix match, longestrootWebsitethat prefixes the page path wins. - Content items —
marcais 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 againstrootWebsitewithout 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,falsewhen unset),ogType,twitterCard,schemaType,topicsandfaq(possibly[]). - Omitted when empty, not returned as
null:title,description,summary,ogImage,ogImageAlt,ogSiteName,datePublished,dateModified,jsonLd,publisher. ogTypeis derived fromschemaType:Article⇒article,Product⇒product, anything else ⇒website.ogImageAltreuses the resolved title.- Dates come from
mgnl:createdandmgnl:lastModified, formattedyyyy-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 viacmsfn.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
sitemapandrobots.txt. A page namedrobotsorllmsis traversed and published; tick Exclude from sitemap on them if you do not want them listed. (LlmsServiceskips 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/detailyields/case-studies/{slug}.- The slug comes from the item's
slugproperty, falling back to the node name. - Items are
mgnl:contentnodes, collected recursively in JCR child order under the same root as the sitemap —/h1as the sitemap root means the items are read from/h1in the source workspace. If that path does not exist there, no items are emitted. lastmodcomes from the item;priorityandchangefreqcome 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
locthat is only a language prefix (/en) is that language's home: a directory URL, not/en.html. The shippedsitemap.ftlhandles this by comparing the single segment againstmodel.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
RobotsNotFoundException ⇒ 404. 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'sname(i18n) → the root page'stitle→ the last segment of the root (Websitefor/).description= the site'sdescription(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. depthis relative to the section's top page and drives the Markdown indentation.
Description precedence, first non-blank wins:
| Node | Precedence |
|---|---|
| Page | h1seoSummary → h1seoDescription → title |
| Collection item | h1seoSummary → description → summary → subtitle |
Titles: a page uses title (i18n) → node name; an item uses title → name → 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.txtalso matches a site whoserootWebsitestarts with the root, so at root/every site matches and the longestrootWebsitewins. On a multi-brand instance, buildllms.txtper 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/oldand/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=1is 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 insourcePattern.
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.
Warning —
redirects-adminis a separate path fromredirectson purpose. The public one returns active rules only; the admin one dumps everything, inactive rules and internal descriptions included. Do not exposeredirects-adminto 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 elevenh1seo*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,
h1seoTwitterCardandh1seoSchemaTypeagainst their enums,h1seoJsonLdparsed as JSON. An unknown key is rejected with the list of valid ones. setis a PATCH: only the keys present are touched, and an explicitnullremoves the property.h1seoFaqandh1seoTopicsare full replacements. Other languages go intranslations, never in inventedh1seoTitle_enkeys.clearremoves the property, its_<locale>variants and, forh1seoFaq, the child node.previewreturns the effective result throughMetadataService— the same code path the endpoint serves — and, on the content-item channel withoutmarca, adds a note saying the globals are missing for that reason.- Guardrail: on a workspace other than
websitethe content type must actually exposeh1seo*in its form (checked withContentTypeIntrospector.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, neversitemap.xml.marcais validated as a single segment with no/and no., and the brand page must exist and be anmgnl:page. - One per brand:
createfails if the page exists, or if a differently named sibling already uses the same template. - A real page:
createwrites anmgnl:pagenode withmgnl:template, even on a headless instance, so the page is visible in the tree. deleteis a two-step confirmation and unpublishes first;previewcalls 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>.yaml — Base, FrontPage, CaseStudyDetail |
frontend-md3 page dialogs |
The empty h1-seo composite and the tab |
h1-seo-metadata/decorations/frontend-md3/dialogs/pages/<Tpl>/<Tpl>.yaml — Base, 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>.yaml — Base, 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.