Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples #44
@@ -109,15 +109,17 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
|
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
|
||||||
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
|
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
|
||||||
locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
|
locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
|
||||||
- **The core building blocks carry the locale; a plugin doesn't have to.** `pagination`,
|
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
|
||||||
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `menu` and the nav wrap every href they
|
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
|
||||||
render in `localeHref`, and the two GET forms carry it as a hidden `locale` input (a GET submit
|
href they render in `localeHref`; the nav is wrapped upstream in `chrome.ts`; and the two GET forms
|
||||||
replaces the whole query string, so no href wrapper can reach it). Putting the obligation on each
|
(filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
|
||||||
call site was tried first and missed five of eight sites inside one commit — including the admin
|
whole query string and no href wrapper can reach it. Putting the obligation on each call site was
|
||||||
screens. `ctx.localeHref` remains for hrefs a plugin's own markup emits. Decided 2026-08-03 after
|
tried first and missed five of eight sites inside one commit — including the admin screens.
|
||||||
an architecture review.
|
`ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
|
||||||
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set, so it never
|
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
|
||||||
shows up as a plugin filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
|
so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
|
||||||
|
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
|
||||||
|
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
|
||||||
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
|
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
|
||||||
so a collision loses the key instead of breaking the shell.
|
so a collision loses the key instead of breaking the shell.
|
||||||
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** A catalog there
|
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** A catalog there
|
||||||
@@ -223,6 +225,11 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
|||||||
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
||||||
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
||||||
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||||
|
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
|
||||||
|
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
|
||||||
|
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
|
||||||
|
remains is orchestration. Planned split along those seams; don't grow it further without taking one
|
||||||
|
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
|
||||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
||||||
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
||||||
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
||||||
|
|||||||
@@ -998,10 +998,12 @@ Three rules worth knowing:
|
|||||||
that deliberately carries markup is rendered with `<%- %>` — and must never interpolate
|
that deliberately carries markup is rendered with `<%- %>` — and must never interpolate
|
||||||
untrusted data, since nothing escapes it there.
|
untrusted data, since nothing escapes it there.
|
||||||
- **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`.
|
- **Dates and numbers are `Intl`'s job**, not the catalog's: `new Intl.DateTimeFormat(ctx.locale)`.
|
||||||
- **The core building blocks carry the locale for you.** `pagination`, `filter-bar`, `data-table`,
|
- **The core building blocks carry the locale for you.** The shell's breadcrumbs, `pagination`,
|
||||||
`auth-card` and the nav wrap every href they render, and the two GET forms carry it as a hidden
|
`filter-bar`, `data-table`, `auth-card`, `field` and the nav wrap every href they render, and the
|
||||||
`locale` input (a GET submit replaces the whole query string). `ctx.localeHref` is only for hrefs
|
two GET forms (filter bar, rows-per-page) carry it as a hidden `locale` input — a GET submit
|
||||||
your own markup emits — and `localeParam` (a view local: the tag, or null) for your own GET forms.
|
replaces the whole query string, so no href wrapper can reach it. `ctx.localeHref` is only for
|
||||||
|
hrefs your own markup emits, and `localeParam` (a view local: the tag, or null) for your own GET
|
||||||
|
forms. `locale` is reserved: `parseListQuery` never returns it as a filter.
|
||||||
- **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/
|
- **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/
|
||||||
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
||||||
to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times.
|
to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times.
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
|
await page.getByRole("button", { name: "Visa" }).click(); // the rows-per-page GET form
|
||||||
await expect(page).toHaveURL(/locale=sv-SE/);
|
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||||
|
|
||||||
|
// The breadcrumb is the chrome's way back up — it is rendered by the shell, not by the screen.
|
||||||
|
await page.getByRole("navigation", { name: "Sidsökväg" }).getByRole("link").first().click();
|
||||||
|
await expect(page).toHaveURL(/locale=sv-SE/);
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ const messages = {
|
|||||||
"admin.notFound.message": "That item doesn't exist.",
|
"admin.notFound.message": "That item doesn't exist.",
|
||||||
"admin.notFound.title": "Not found",
|
"admin.notFound.title": "Not found",
|
||||||
|
|
||||||
|
"admin.permissions.actions": "Permission actions",
|
||||||
|
"admin.permissions.allAssigned": "All users and groups already have this permission.",
|
||||||
|
"admin.permissions.assign": "Assign the permission",
|
||||||
|
"admin.permissions.assignAction": "Assign",
|
||||||
"admin.permissions.assignTo": "Assign to",
|
"admin.permissions.assignTo": "Assign to",
|
||||||
"admin.permissions.assignedTo": "Assigned to",
|
"admin.permissions.assignedTo": "Assigned to",
|
||||||
"admin.permissions.column.members": "Members",
|
"admin.permissions.column.members": "Members",
|
||||||
@@ -90,10 +94,13 @@ const messages = {
|
|||||||
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
|
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
|
||||||
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
|
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
|
||||||
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
|
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
|
||||||
|
"admin.permissions.effective": "Effective access",
|
||||||
|
"admin.permissions.effectiveHint": "Everyone who holds this permission — directly or through a group (resolved by Keto).",
|
||||||
"admin.permissions.field.name": "Permission name",
|
"admin.permissions.field.name": "Permission name",
|
||||||
"admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
|
"admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
|
||||||
"admin.permissions.filter": "Filter permissions",
|
"admin.permissions.filter": "Filter permissions",
|
||||||
"admin.permissions.new": "New permission",
|
"admin.permissions.new": "New permission",
|
||||||
|
"admin.permissions.noEffective": "No users hold this permission yet.",
|
||||||
"admin.permissions.noMembers": "Not assigned to anyone yet.",
|
"admin.permissions.noMembers": "Not assigned to anyone yet.",
|
||||||
"admin.permissions.pagination": "Permissions pagination",
|
"admin.permissions.pagination": "Permissions pagination",
|
||||||
"admin.permissions.revoke": "Revoke",
|
"admin.permissions.revoke": "Revoke",
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ const messages: AdminMessages = {
|
|||||||
"admin.notFound.message": "Objektet finns inte.",
|
"admin.notFound.message": "Objektet finns inte.",
|
||||||
"admin.notFound.title": "Hittades inte",
|
"admin.notFound.title": "Hittades inte",
|
||||||
|
|
||||||
|
"admin.permissions.actions": "Behörighetsåtgärder",
|
||||||
|
"admin.permissions.allAssigned": "Alla användare och grupper har redan den här behörigheten.",
|
||||||
|
"admin.permissions.assign": "Tilldela behörigheten",
|
||||||
|
"admin.permissions.assignAction": "Tilldela",
|
||||||
"admin.permissions.assignTo": "Tilldela till",
|
"admin.permissions.assignTo": "Tilldela till",
|
||||||
"admin.permissions.assignedTo": "Tilldelad till",
|
"admin.permissions.assignedTo": "Tilldelad till",
|
||||||
"admin.permissions.column.members": "Medlemmar",
|
"admin.permissions.column.members": "Medlemmar",
|
||||||
@@ -90,10 +94,13 @@ const messages: AdminMessages = {
|
|||||||
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
|
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
|
||||||
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
|
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
|
||||||
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
|
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
|
||||||
|
"admin.permissions.effective": "Faktisk åtkomst",
|
||||||
|
"admin.permissions.effectiveHint": "Alla som har behörigheten — direkt eller via en grupp (uppslaget av Keto).",
|
||||||
"admin.permissions.field.name": "Behörighetens namn",
|
"admin.permissions.field.name": "Behörighetens namn",
|
||||||
"admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
|
"admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
|
||||||
"admin.permissions.filter": "Filtrera behörigheter",
|
"admin.permissions.filter": "Filtrera behörigheter",
|
||||||
"admin.permissions.new": "Ny behörighet",
|
"admin.permissions.new": "Ny behörighet",
|
||||||
|
"admin.permissions.noEffective": "Ingen användare har den här behörigheten ännu.",
|
||||||
"admin.permissions.noMembers": "Inte tilldelad till någon ännu.",
|
"admin.permissions.noMembers": "Inte tilldelad till någon ännu.",
|
||||||
"admin.permissions.pagination": "Sidnavigering för behörigheter",
|
"admin.permissions.pagination": "Sidnavigering för behörigheter",
|
||||||
"admin.permissions.revoke": "Återkalla",
|
"admin.permissions.revoke": "Återkalla",
|
||||||
|
|||||||
@@ -33,6 +33,6 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
|
||||||
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
|
<p class="field-hint"><%= t("admin.clients.rereg") %></p>
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
|
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,6 @@
|
|||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
|
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,8 +31,8 @@
|
|||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card" aria-labelledby="effective-h">
|
<section class="form-card" aria-labelledby="effective-h">
|
||||||
<h2 class="card-title" id="effective-h">Effective access</h2>
|
<h2 class="card-title" id="effective-h"><%= t("admin.permissions.effective") %></h2>
|
||||||
<p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p>
|
<p class="field-hint"><%= t("admin.permissions.effectiveHint") %></p>
|
||||||
<% if (effective.length) { -%>
|
<% if (effective.length) { -%>
|
||||||
<ul class="plain-list">
|
<ul class="plain-list">
|
||||||
<% effective.forEach((u) => { -%>
|
<% effective.forEach((u) => { -%>
|
||||||
@@ -40,18 +40,18 @@
|
|||||||
<% }) -%>
|
<% }) -%>
|
||||||
</ul>
|
</ul>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<p class="cell-muted">No users hold this permission yet.</p>
|
<p class="cell-muted"><%= t("admin.permissions.noEffective") %></p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card" aria-labelledby="add-h">
|
<section class="form-card" aria-labelledby="add-h">
|
||||||
<h2 class="card-title" id="add-h">Assign the permission</h2>
|
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
|
||||||
<% if (add.options.length) { -%>
|
<% if (add.options.length) { -%>
|
||||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
|
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<p class="cell-muted">All users and groups already have this permission.</p>
|
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="Permission actions">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.permissions.actions") %>">
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
|
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.permissions.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
||||||
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
|
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
|
||||||
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||||
<a class="btn btn-danger" href="<%= edit.deleteAction %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
|
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
|
||||||
</section>
|
</section>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,3 +51,8 @@ test("parseListQuery honours custom reserved names and page-size bounds", () =>
|
|||||||
assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50);
|
assert.equal(parseListQuery("?n=999", { maxPageSize: 50, pageSizeParam: "n" }).pageSize, 50);
|
||||||
assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] });
|
assert.deepEqual(parseListQuery("?q=hi", { qParam: "search" }).filters, { q: ["hi"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("`locale` is the host's, not a filter — every localized list link carries it", () => {
|
||||||
|
const query = parseListQuery("/admin/users?locale=sv-SE&status=active");
|
||||||
|
assert.deepEqual(Object.keys(query.filters), ["status"]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export function parseListQuery(url: URL | URLSearchParams | string, options: Lis
|
|||||||
const sortParam = options.sortParam ?? "sort";
|
const sortParam = options.sortParam ?? "sort";
|
||||||
const pageParam = options.pageParam ?? "page";
|
const pageParam = options.pageParam ?? "page";
|
||||||
const pageSizeParam = options.pageSizeParam ?? "pageSize";
|
const pageSizeParam = options.pageSizeParam ?? "pageSize";
|
||||||
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam]);
|
// `locale` is host-owned (README → Languages): every list link and both GET forms carry it, so
|
||||||
|
// without this it would arrive as a phantom filter on every localized list page.
|
||||||
|
const reserved = new Set([pageParam, pageSizeParam, qParam, sortParam, "locale"]);
|
||||||
|
|
||||||
const filters: Record<string, string[]> = {};
|
const filters: Record<string, string[]> = {};
|
||||||
for (const key of new Set(params.keys())) {
|
for (const key of new Set(params.keys())) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
-%>
|
-%>
|
||||||
<div class="field<% if (error) { %> has-error<% } %>">
|
<div class="field<% if (error) { %> has-error<% } %>">
|
||||||
<% if (link || optional) { -%>
|
<% if (link || optional) { -%>
|
||||||
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= link.href %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div>
|
<div class="field-top"><label for="<%= id %>"><%= locals.label %></label><% if (link) { %><a class="field-link" href="<%= localeHref(link.href) %>"><%= link.label %></a><% } else { %><span class="optional"><%= t("field.optional") %></span><% } %></div>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<label for="<%= id %>"><%= locals.label %></label>
|
<label for="<%= id %>"><%= locals.label %></label>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
<% if (title) { %><h1 class="page-title"><%= title %></h1><% } %>
|
<% if (title) { %><h1 class="page-title"><%= title %></h1><% } %>
|
||||||
<% if (breadcrumbs.length) { %>
|
<% if (breadcrumbs.length) { %>
|
||||||
<nav class="crumbs" aria-label="<%= t("shell.breadcrumb") %>">
|
<nav class="crumbs" aria-label="<%= t("shell.breadcrumb") %>">
|
||||||
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= c.href %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>
|
<% breadcrumbs.forEach((c, i) => { %><% if (i) { %><span class="sep">/</span><% } %><% if (c.href) { %><a href="<%= localeHref(c.href) %>"><%= c.label %></a><% } else { %><span><%= c.label %></span><% } %><% }) %>
|
||||||
</nav>
|
</nav>
|
||||||
<% } %>
|
<% } %>
|
||||||
<div class="topbar-spacer"></div>
|
<div class="topbar-spacer"></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user