Compare commits

...
Author SHA1 Message Date
Jacob Overgaard 49bf135a7e Merge remote-tracking branch 'origin/main' into v18/feature/typesafe-localization-keys 2026-05-21 09:06:03 +02:00
Jacob Overgaard c8e839dc8e chore: generates new keys 2026-05-21 09:05:53 +02:00
Jacob OvergaardandGitHub 0fdea48665 Merge branch 'main' into v18/feature/typesafe-localization-keys 2026-05-20 15:51:32 +02:00
Jacob OvergaardandGitHub 74a1f5fb55 Merge branch 'main' into v18/feature/typesafe-localization-keys 2026-05-20 15:37:41 +02:00
Jacob OvergaardandGitHub 2e4a479d79 Merge branch 'main' into v18/feature/typesafe-localization-keys 2026-05-19 21:58:12 +02:00
Jacob Overgaard 5a0e9b212f docs: Walk through plugin localization end-to-end
Expands the "Adding plugin-specific keys" subsection so it covers both
halves of the story instead of only the type declaration: runtime
registration via the localization extension (inline meta.localizations
for small packs, separate js file for larger ones) plus the global
interface merging that enables autocomplete and arg-type inference on
the plugin's own keys.

Each step is independently usable — plugins that just want their
strings translated can stop after step 1; the type declaration is
opt-in for the developer-experience win.
2026-05-19 20:44:01 +02:00
Jacob Overgaard 133d45ee73 docs(CLAUDE.md): Flag the en.ts regeneration step in Required Reading
Adds a row to the "Required Reading Before Acting" table so contributors
who add, rename, or remove a key in en.ts know to regenerate
known-keys.generated.ts up front instead of finding out via a failing
CI staleness test or the ESLint rule on a second push.
2026-05-19 20:42:03 +02:00
Jacob Overgaard 25452fc62c Localization: Declare typed key map globally so plugins can extend it
Moves `UmbKnownLocalizationSet` and `UmbKnownLocalizationKey` from module-scoped
exports of `localization-api` to a global declaration in
`known-keys.generated.ts` — same pattern as `UmbExtensionManifestMap` /
`UmbExtensionManifest`. Plugin developers can now publish their own typed
keys via plain interface merging without the `declare module
'@umbraco-cms/backoffice/localization-api'` boilerplate:

    declare global {
      interface UmbKnownLocalizationSet {
        mypkg_anything: string;
        mypkg_greeting: (name: string) => string;
      }
    }

The augmented keys participate in autocomplete and argument-type inference
on `localize.term()` / `<umb-localize key="…">` exactly like the built-in
ones. Verified with a synthetic plugin file: TS accepts the augmented
call sites, infers args correctly on function-valued additions, still
rejects nothing (the `(string & {})` escape hatch stays for dynamic
keys).

Downstream consumers get the global automatically when they import
anything from `@umbraco-cms/backoffice/localization-api` — the barrel
re-exports `UMB_KNOWN_LOCALIZATION_KEYS` from `known-keys.generated.js`,
which causes TS to load the matching `.d.ts` containing the global
declaration.

`localization.controller.ts` and `localize.element.ts` switch from
named-type imports to side-effect imports (`import './known-keys.generated.js'`
and `import '@umbraco-cms/backoffice/localization-api'` respectively).
The build tsconfig's `**/*.ts` include already loads the generated file
project-wide; the explicit side-effect imports document the dependency
at the use-site.

Docs updated in `package-development.md` → Type-safe localization keys
with the new plugin-augmentation example.

Verified locally: tsc 0 errors, lint:errors clean, all 85 localization
tests pass, export-consts passes, no new circular deps, Prettier clean,
synthetic plugin augmentation type-checks correctly.
2026-05-19 17:11:49 +02:00
Jacob Overgaard 0dcb3b2177 Localization: ESLint rule for unknown keys, plus the 11 call sites it caught
Adds `local-rules/no-unknown-localization-key` — a small custom eslint rule that
flags string-literal arguments to `<x>.localize.term()` and `<x>.localize.termOrDefault()`
that aren't in the codegen-generated `UMB_KNOWN_LOCALIZATION_KEYS` list.

Why a lint rule and not just type-checking: the `(string & {})` escape hatch on
`term()`'s key parameter (necessary for dynamic keys like
`` `login_greeting${day}` `` and for third-party runtime-registered keys to type-
check in the published types) means tsc accepts any string literal. The lint
rule closes the gap inside our own codebase — without changing the shipped
types — by checking literals against the generated key list at lint time.

Skips template literals, identifiers, and other non-literal arguments (those
are the documented escape hatch). Disable with
`// eslint-disable-next-line local-rules/no-unknown-localization-key` on the
rare call site that legitimately references a runtime-registered key.

The rule fired on 11 call sites the first time it ran — all real bugs, none
of which produced a runtime error loud enough to be noticed:

- collection-filter-field.element.ts: `general_filter` doesn't exist;
  correct key is `placeholders_filter` (which the placeholder attribute on the
  same element was already using).
- document-schedule-modal.element.ts (x2) and element-schedule-modal.element.ts
  (x2): `general_publishDate` doesn't exist. The visible label above each
  field already used the right keys — `content_releaseDate` for the publish
  date and `content_unpublishDate` for the unpublish date — but the input's
  own label attribute was referencing a non-existent key.
- document-publishing.workspace-context.ts and element-publishing.workspace-context.ts:
  `content_saveAndPublishShortcut` doesn't exist; the matching key is
  `buttons_saveAndPublish`.
- member-profile-data-workspace-info-app.element.ts (x2): `general_none`
  didn't exist either, so the rule pushed the obvious fix — add it. The
  call sites render nullish/empty values, and "None" is the natural label.
  Added `general.none: 'None'` to en.ts.
- url-picker-monaco-markdown-editor-action.ts: `general_insertLink` doesn't
  exist; the existing key is `defaultdialogs_insertlink` (lowercase second
  word, which is also why the typo'd version was likely a misremembered
  guess at the capitalization).
- umbraco-news-dashboard.element.ts: the "visit forum" button label was
  `term('hi')`. Replaced with `welcomeDashboard_umbracoForumButton`
  (= "Visit the Umbraco community forum"), which is what the button
  actually links to.

Verified locally: `npm run lint:errors` clean, `tsc --noEmit` clean against
the build tsconfig, 84/84 localization tests pass, `export-consts.test.ts`
still passes.

Includes a small Prettier-driven reformat in `localization.controller.ts`
(combined the multi-line import statement onto one line, restructured the
class declaration with `implements` on a separate line) — semantically a
no-op, just brings the file in line with the project's `printWidth` config.
2026-05-19 16:54:44 +02:00
Jacob Overgaard eb2efe4de6 Localization: Address review feedback on the typesafe keys PR
- Re-export `UMB_KNOWN_LOCALIZATION_KEYS` from `@umbraco-cms/backoffice/localization-api`.
  The existing `export-consts.test.ts` scans every package for `export const UMB_*`
  and fails if any aren't reachable through the package's public index. The new
  runtime array missed the public re-export.

- Move the class-level JSDoc back to immediately precede `UmbLocalizationController`.
  My earlier insertion of `LocalizationKeyOf` / `LocalizationArgsOf` between the
  block and the class detached the `@see UmbLocalizeElement` reference and the
  `@example` snippet — TS tooling attaches a JSDoc to the next declaration, so
  the class lost its consumer-facing docs.

- Update `term()` / `termOrDefault()` JSDoc to match the new conditional argument
  typing (`LocalizationArgsOf<...>`). The old `unknown[]` description was stale.

- Add JSDoc to the manager's `connectedControllers` field flagging that the
  element type changed from `UmbLocalizationController<UmbLocalizationSetBase>`
  to `UmbLocalizationConsumer`, and that the field shouldn't be iterated
  externally — `appendConsumer` / `removeConsumer` are the supported entry points.

- Add JSDoc to the codegen-emitted `UmbKnownLocalizationSet`,
  `UmbKnownLocalizationKey`, and the file header reference so IDE hover surfaces
  intent for third-party authors consuming the types via declaration merging.

- Document the staleness test's name-only scope. It catches the common case
  (add/rename/remove a key) but not signature drift (string ↔ function).

- Refactor `collectEntries` further to satisfy CodeScene's Bumpy Road check —
  split into `flattenEntries` (flatMap walk) and `deduplicate` (uniqueness pass).
  Extract `isWrapperExpression` from `unwrapAs` to flatten the Complex Conditional.
2026-05-19 16:14:01 +02:00
Jacob Overgaard d1d50db6b1 Localization: Simplify collectEntries to satisfy CodeScene
Split the nested-loop entry collection into two small helpers
(`getGroupObjectLiteral`, `getKeyEntry`) so each function has a single
responsibility instead of compounding guard clauses. Same output —
generator's `No changes (2674 keys)` confirms idempotency.

CodeScene's "Bumpy Road Ahead" + Complex Method/Conditional warnings
fired on the original two-level loop with several early-continue
guards at each level. The refactor flattens the nesting without
introducing a new abstraction layer.
2026-05-19 14:37:15 +02:00
Jacob Overgaard 019370e7f4 Localization: Test the generated key list against en.ts at runtime
Adds `known-keys.test.ts` which re-derives the flat `group_key` set from
`assets/lang/en.ts` and compares it to a new runtime export
(`UMB_KNOWN_LOCALIZATION_KEYS`) emitted by the codegen alongside the
interface. Fails loudly with the regen command in the error message when
the two diverge.

Catches the "edited en.ts but forgot to regenerate" failure mode that
the committed-generated-file strategy is otherwise vulnerable to —
without an assertion like this, a stale file compiles fine and only
gets caught the next time the `prebuild` hook runs.

The new runtime array tree-shakes out of production bundles since
nothing outside the test imports it.

Verified the test actually catches drift by removing an entry from the
generated runtime list and confirming the assertion fires with a
pointer to `npm run generate:localization-keys`.
2026-05-19 14:35:17 +02:00
Jacob Overgaard 3f507c08aa Localization: Drop variance escape, type the consumer contract explicitly
Replaces the `Set<UmbLocalizationController<any>>` workaround on the
manager with a small `UmbLocalizationConsumer` interface that captures
the two methods the manager actually calls on each stored controller
(`keysChanged`, `documentUpdate`). The manager's Set, `appendConsumer`,
and `removeConsumer` now use the new interface; `UmbLocalizationController`
declares `implements UmbController, UmbLocalizationConsumer`.

Net effect: the manager no longer cares about the controller's generic
parameter at all, the variance check that broke after `term()` picked
up conditional types disappears, and the contract is now documented in
one place — future maintainers can't accidentally start depending on
the generic without amending `UmbLocalizationConsumer` first.

Includes the formatting/docs polish from the earlier squashed commit.
2026-05-19 14:27:56 +02:00
Jacob Overgaard ef715ee769 chore: marks generated file as linguist-generated 2026-05-19 12:58:05 +02:00
Jacob Overgaard 6edb8f6771 Localization: Type-safe keys for term() and <umb-localize>
Generates `UmbKnownLocalizationSet` from `src/assets/lang/en.ts` at
build time so `localize.term()`, `localize.termOrDefault()`, and
`<umb-localize key="…">` get autocomplete and compile-time validation
against the canonical 2,674 keys. The signature keeps a `(string & {})`
escape hatch alongside `keyof UmbKnownLocalizationSet`, so dynamic
keys (e.g. `` `login_greeting${day}` ``) still type-check without a cast.

Catches the class of bug that bit us in 18.0.0-beta: call sites
referencing keys that don't exist. Renames like `auth_*` → `login_*`
used to silently fall back to the literal key as the displayed string;
they now surface as compile errors.

Implementation:

- New codegen `devops/localization/generate-known-keys.js` parses
  `en.ts` via the TypeScript compiler API, flattens
  `group: { key: value }` into `group_key`, preserves function-entry
  parameter types verbatim (so e.g. `user_languageNotFound(culture, base)`
  is checked against its source signature), and emits
  `src/libs/localization-api/known-keys.generated.ts`. Idempotent —
  re-running prints "No changes" if the dictionary is unchanged.

- Wired as the `prebuild` hook so production builds always see fresh
  keys without committing churn. Also exposed as
  `npm run generate:localization-keys` for manual use. The generated
  file is committed so fresh checkouts compile without running it.

- `UmbLocalizationController` now defaults `LocalizationSetType` to
  `UmbKnownLocalizationSet`. The `term()` / `termOrDefault()` key
  parameter accepts `LocalizationKeyOf<T>`, which is
  `Exclude<keyof T, keyof UmbLocalizationSetBase> | (string & {})` —
  literal-key autocomplete + dynamic-key escape hatch in one.

- Argument inference: function-valued entries forward their parameter
  list verbatim; string-valued entries stay as `unknown[]` (rather
  than `[]`) because `#processTerm` supports `%0%` / `{0}` placeholder
  substitution on string values at runtime — locking down to `[]`
  would surface hundreds of legacy call sites that depend on that.

- Manager's controller registry was constrained as
  `Set<UmbLocalizationController<UmbLocalizationSetBase>>`. The variance
  check on the new conditional-typed `term()` made that incompatible
  with `this` from instances with a concrete generic, so the storage
  type is `Set<UmbLocalizationController<any>>` — safe because the
  manager only calls `keysChanged` and `documentUpdate`, neither of
  which use the generic.

Bugs the new typing surfaced (fixed alongside):

- `split-panel.element.ts`: was passing `[formatted]` (an array
  wrapping the value) to `general_dividerPosition(value: string | number)`.
  The function signature expects a scalar.
- `property-action-menu.element.ts`: was calling
  `term('actions_viewActionsFor')` with zero args, but the entry is a
  function `(name) => name ? … : 'View actions'` so it required one.
  Now passes `''` to take the fallback branch.
- `create-user-modal.element.ts`: was passing
  `this.data?.user.kind` (`string | undefined`) where the entry expects
  `string`. Defaults to `''` to take the non-"Api" branch.
- `ui-culture-input.element.ts`: same `string | undefined` issue against
  `user_languageNotFound` / `user_languageNotFoundFallback`. The
  validators only fire when `this.#invalidCulture` is truthy at runtime,
  but TS can't see that across the closure boundary; defaults the args
  to `''` for both.

Docs: short section in `docs/package-development.md` under Localization
documenting the codegen, the escape hatch, and how third-party packages
can declaration-merge into `UmbKnownLocalizationSet` to publish their
own typed keys.
2026-05-19 12:38:42 +02:00
25 changed files with 5982 additions and 39 deletions
+1
View File
@@ -61,3 +61,4 @@ src/Umbraco.Web.UI.Client/src/packages/core/backend-api/** linguist-generated
src/Umbraco.Web.UI.Login/src/api/** linguist-generated
templates/UmbracoExtension/Client/src/api/** linguist-generated
src/Umbraco.Cms.Api.Management/OpenApi.json linguist-generated
src/Umbraco.Web.UI.Client/src/libs/localization-api/known-keys.generated.ts linguist-generated
+1
View File
@@ -52,6 +52,7 @@ TypeScript/Lit web components library for the Umbraco CMS backoffice. Published
| Work with auth or security | [docs/security.md](./docs/security.md) + [docs/edge-cases.md](./docs/edge-cases.md) |
| Scaffold a new package or module | [docs/package-development.md](./docs/package-development.md) |
| Write or change observers / `Umb*State` usage | [docs/state-system.md](./docs/state-system.md) — states already deduplicate; do not add "is this a re-emit?" guards |
| Add, rename, or remove a key in `src/assets/lang/en.ts` | [docs/package-development.md](./docs/package-development.md#type-safe-localization-keys) — also run `npm run generate:localization-keys` (or `npm run build`, which triggers the `prebuild` hook) and commit the updated `known-keys.generated.ts`. `known-keys.test.ts` + the `local-rules/no-unknown-localization-key` ESLint rule catch drift, but regenerating up front saves a CI cycle |
This is not optional. Skipping these leads to convention violations that are caught in review.
@@ -0,0 +1,112 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
/**
* Flags string-literal arguments to `<x>.localize.term(...)` / `<x>.localize.termOrDefault(...)` that
* aren't in the generated `UMB_KNOWN_LOCALIZATION_KEYS` list (sourced from `assets/lang/en.ts`).
*
* Why a lint rule and not just type-checking: the TypeScript signature on `term()` keeps a
* `(string & {})` escape hatch in the key parameter so dynamic keys (e.g. `` `login_greeting${day}` ``)
* and third-party-registered runtime keys still type-check without casts. That's necessary for the
* published `@umbraco-cms/backoffice` types but it means typos in literal keys also slip through tsc.
* This rule closes the gap inside our own codebase without changing the shipped types.
*
* Skips template literals, identifiers, and other non-literal arguments — the escape hatch is
* intentional for those. To bypass the rule on a specific known-runtime-registered key, use
* `// eslint-disable-next-line local-rules/no-unknown-localization-key`.
*/
const GENERATED_KEYS_FILE = path.join(__dirname, '../../../src/libs/localization-api/known-keys.generated.ts');
let cachedKeySet = null;
function loadKnownKeys() {
if (cachedKeySet) return cachedKeySet;
let source;
try {
source = fs.readFileSync(GENERATED_KEYS_FILE, 'utf-8');
} catch {
// Generated file missing — fail open so a fresh checkout that hasn't run `npm install`
// (which triggers `prebuild`/`generate:localization-keys`) doesn't get drowned in errors.
// CI's `npm run lint:errors` runs after the generator, so this branch only hits locally.
return new Set();
}
const arrayMatch = source.match(/UMB_KNOWN_LOCALIZATION_KEYS[^=]*=\s*\[([\s\S]*?)\];/);
if (!arrayMatch) {
throw new Error(
`Could not locate UMB_KNOWN_LOCALIZATION_KEYS in ${GENERATED_KEYS_FILE}. Regenerate via \`npm run generate:localization-keys\`.`,
);
}
const keys = new Set();
for (const match of arrayMatch[1].matchAll(/(['"])((?:(?!\1).)+)\1/g)) {
keys.add(match[2]);
}
cachedKeySet = keys;
return keys;
}
const TARGET_METHODS = new Set(['term', 'termOrDefault']);
/**
* Returns true when `node` is a `<something>.localize` member access.
* Matches `this.localize`, `this.#localize`, `host._localize`, etc. — same heuristic as no-unsafe-localize.
*/
function isLocalizeReceiver(node) {
if (!node || node.type !== 'MemberExpression') return false;
const prop = node.property;
if (!prop) return false;
if (prop.type === 'Identifier' && /localize$/i.test(prop.name)) return true;
if (prop.type === 'PrivateIdentifier' && /localize$/i.test(prop.name)) return true;
return false;
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description:
'Disallow passing string literals to `localize.term()` / `localize.termOrDefault()` that are not in the generated `UMB_KNOWN_LOCALIZATION_KEYS` list.',
category: 'Possible Errors',
recommended: true,
},
schema: [],
messages: {
unknownKey:
"Unknown localization key '{{key}}'. Add it to `src/assets/lang/en.ts` and regenerate (`npm run generate:localization-keys`), or use a dynamic-key form (template literal / variable) for runtime-registered keys.",
},
},
create(context) {
const knownKeys = loadKnownKeys();
if (knownKeys.size === 0) return {};
return {
CallExpression(node) {
const callee = node.callee;
if (!callee || callee.type !== 'MemberExpression') return;
const methodName = callee.property?.name;
if (!methodName || !TARGET_METHODS.has(methodName)) return;
if (!isLocalizeReceiver(callee.object)) return;
const firstArg = node.arguments[0];
if (!firstArg) return;
// Only check static literal keys. Template literals, identifiers, member accesses,
// and other dynamic forms are the documented escape hatch — leave them alone.
if (firstArg.type !== 'Literal') return;
if (typeof firstArg.value !== 'string') return;
if (knownKeys.has(firstArg.value)) return;
context.report({
node: firstArg,
messageId: 'unknownKey',
data: { key: firstArg.value },
});
},
};
},
};
@@ -0,0 +1,216 @@
/**
* Generates a typed dictionary interface from the canonical `en.ts` localization source.
*
* Reads `src/assets/lang/en.ts`, walks the default-exported object literal, flattens
* each `group: { key: value }` pair to a single `group_key` property, and emits
* `src/libs/localization-api/known-keys.generated.ts` containing the
* `UmbKnownLocalizationSet` interface. The interface preserves whether each entry is a
* plain string or a function — so `localize.term()` keeps its argument-type inference
* via `LocalizationArgsOf<LocalizationSetType, K>` in `localization.controller.ts`.
*
* Run via `npm run generate:localization-keys`. Also runs automatically before the
* production build (`prebuild` hook).
*
* Copyright (c) 2026 by Umbraco HQ
*/
import ts from 'typescript';
import fs from 'node:fs';
import path from 'node:path';
import { format, resolveConfig } from 'prettier';
const __dirname = import.meta.dirname;
const projectRoot = path.resolve(__dirname, '../..');
const sourcePath = path.join(projectRoot, 'src/assets/lang/en.ts');
const outputPath = path.join(projectRoot, 'src/libs/localization-api/known-keys.generated.ts');
const header = `// AUTO-GENERATED — DO NOT EDIT
//
// Generated by \`devops/localization/generate-known-keys.js\` from
// \`src/assets/lang/en.ts\`. Run \`npm run generate:localization-keys\` to regenerate.
//
// \`UmbKnownLocalizationSet\` is declared globally (mirrors the \`UmbExtensionManifestMap\`
// pattern) so third-party packages can publish their own typed keys via plain
// interface merging — no module-path boilerplate required:
//
// declare global {
// interface UmbKnownLocalizationSet {
// mypkg_anything: string;
// mypkg_greeting: (name: string) => string;
// }
// }
/* eslint-disable @typescript-eslint/naming-convention */
`;
function getPropertyName(node) {
if (ts.isIdentifier(node) || ts.isPrivateIdentifier(node)) return node.text;
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
if (ts.isNumericLiteral(node)) return node.text;
return null;
}
function inferEntryType(initializer, source) {
if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer)) {
const params = initializer.parameters
.map((p) => {
const name = p.name.getText(source);
const optional = p.questionToken ? '?' : '';
const type = p.type ? p.type.getText(source) : 'unknown';
const rest = p.dotDotDotToken ? '...' : '';
return `${rest}${name}${optional}: ${type}`;
})
.join(', ');
return `(${params}) => string`;
}
return 'string';
}
const isWrapperExpression = (node) =>
ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isParenthesizedExpression(node);
function unwrapAs(expr) {
let current = expr;
while (isWrapperExpression(current)) {
current = current.expression;
}
return current;
}
function findDefaultExportObjectLiteral(source) {
for (const stmt of source.statements) {
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
const expr = unwrapAs(stmt.expression);
if (ts.isObjectLiteralExpression(expr)) return expr;
}
}
throw new Error(`Could not find a default export object literal in ${sourcePath}`);
}
function getGroupObjectLiteral(groupProp) {
if (!ts.isPropertyAssignment(groupProp)) return null;
const name = getPropertyName(groupProp.name);
if (!name) return null;
const value = unwrapAs(groupProp.initializer);
if (!ts.isObjectLiteralExpression(value)) return null;
return { name, value };
}
function getKeyEntry(keyProp, groupName, source) {
if (!ts.isPropertyAssignment(keyProp)) return null;
const name = getPropertyName(keyProp.name);
if (!name) return null;
return {
key: `${groupName}_${name}`,
type: inferEntryType(unwrapAs(keyProp.initializer), source),
};
}
function flattenEntries(objectLiteral, source) {
return objectLiteral.properties.flatMap((groupProp) => {
const group = getGroupObjectLiteral(groupProp);
if (!group) return [];
return group.value.properties
.map((keyProp) => getKeyEntry(keyProp, group.name, source))
.filter((entry) => entry !== null);
});
}
function deduplicate(rawEntries) {
const entries = [];
const duplicates = [];
const seen = new Set();
for (const entry of rawEntries) {
if (seen.has(entry.key)) {
duplicates.push(entry.key);
} else {
seen.add(entry.key);
entries.push(entry);
}
}
return { entries, duplicates };
}
function collectEntries(objectLiteral, source) {
return deduplicate(flattenEntries(objectLiteral, source));
}
function keyLiteral(key) {
// Match TypeScript identifier rules — otherwise quote.
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
}
async function main() {
const sourceText = fs.readFileSync(sourcePath, 'utf-8');
const source = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.ES2022, true);
const objectLiteral = findDefaultExportObjectLiteral(source);
const { entries, duplicates } = collectEntries(objectLiteral, source);
if (duplicates.length > 0) {
console.warn(`[generate-known-keys] Duplicate flat keys found (later occurrences skipped):`);
for (const key of duplicates) console.warn(` - ${key}`);
}
entries.sort((a, b) => a.key.localeCompare(b.key));
const body = [
header,
"import type { UmbLocalizationSetBase } from './localization.manager.js';",
'',
'declare global {',
'\t/**',
"\t * Typed dictionary of every known Umbraco localization key derived from `assets/lang/en.ts`.",
'\t *',
"\t * Each property reflects the entry shape: string-valued keys are typed as `string`, function-",
"\t * valued keys forward their full parameter list so call sites are checked against the source",
'\t * signature.',
'\t *',
'\t * Declared globally so third-party packages can publish their own typed keys via plain',
'\t * interface merging (mirrors the `UmbExtensionManifestMap` pattern):',
'\t *',
'\t * ```ts',
'\t * declare global {',
'\t * interface UmbKnownLocalizationSet {',
"\t * mypkg_anything: string;",
"\t * mypkg_greeting: (name: string) => string;",
'\t * }',
'\t * }',
'\t * ```',
'\t */',
'\tinterface UmbKnownLocalizationSet extends UmbLocalizationSetBase {',
...entries.map((e) => `\t\t${keyLiteral(e.key)}: ${e.type};`),
'\t}',
'',
'\t/**',
"\t * Union of every known localization key from `UmbKnownLocalizationSet`, excluding the metadata",
"\t * fields inherited from `UmbLocalizationSetBase` (`$code`, `$dir`). Picks up keys declared by",
'\t * third-party packages via global interface merging automatically.',
'\t */',
'\ttype UmbKnownLocalizationKey = Exclude<keyof UmbKnownLocalizationSet, keyof UmbLocalizationSetBase>;',
'}',
'',
'/**',
' * Runtime list of every known key shipped by the backoffice. Exists for the staleness test in',
" * `known-keys.test.ts` — production code should reference the `UmbKnownLocalizationKey` global",
' * type instead. Tree-shaken from the production bundle when nothing imports it at runtime.',
' */',
'export const UMB_KNOWN_LOCALIZATION_KEYS: readonly UmbKnownLocalizationKey[] = [',
...entries.map((e) => `\t${JSON.stringify(e.key)},`),
'];',
'',
].join('\n');
const prettierConfig = (await resolveConfig(outputPath)) ?? {};
const formatted = await format(body, { ...prettierConfig, parser: 'typescript' });
const previous = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf-8') : null;
if (previous === formatted) {
console.log(`[generate-known-keys] No changes (${entries.length} keys).`);
return;
}
fs.writeFileSync(outputPath, formatted, 'utf-8');
console.log(`[generate-known-keys] Wrote ${entries.length} keys to ${path.relative(projectRoot, outputPath)}.`);
}
await main();
@@ -103,6 +103,76 @@ No hardcoded UI-facing strings. All user-visible text must go through the locali
For step-by-step instructions on adding localization keys and using them in elements or controllers, use the `general-add-localization` skill.
### Type-safe localization keys
`this.localize.term()`, `termOrDefault()`, and the `<umb-localize key="…">` element are typed against the canonical `en.ts` dictionary. After adding or renaming a key in `en.ts`, run:
```bash
npm run generate:localization-keys
```
This walks `en.ts`, flattens `group: { key: value }` into `group_key`, and rewrites `src/libs/localization-api/known-keys.generated.ts` with an `UmbKnownLocalizationSet` interface — string entries stay typed as `string`, function entries forward their parameter types (so `term('user_languageNotFound', culture, baseCulture)` is checked against the underlying signature). The script also runs automatically as a `prebuild` hook, so production builds always see fresh keys.
The TypeScript signature on `term()` keeps a `(string & {})` escape hatch alongside `keyof UmbKnownLocalizationSet`. That means autocomplete shows the canonical keys and dynamic keys like `` localize.term(`login_greeting${day}`) `` still work without a cast. Typos in static string literals are caught by a local ESLint rule (`local-rules/no-unknown-localization-key`) that checks the literal against the generated runtime list — that runs in this repo only, so third-party plugins are unaffected.
#### Adding plugin-specific keys
A plugin that wants its own typed localization keys needs two pieces — runtime registration and (optionally) type declaration. They're decoupled: register-only works fine, the type declaration just enables autocomplete and arg-type inference on the plugin's own keys.
**1. Register the strings at runtime** via a `localization` extension. The runtime registers them under the active culture, flattens `group.key` into `group_key`, and merges into the dictionary the controller looks up.
Inline form (best for small overrides):
```json
// umbraco-package.json
{
"alias": "mypkg.extensions",
"name": "MyPkg",
"extensions": [
{
"type": "localization",
"alias": "Mypkg.Localize.EnUS",
"name": "English",
"meta": {
"culture": "en-US",
"localizations": {
"mypkg": {
"anything": "Some string",
"greeting": "Hello, %0%!"
}
}
}
}
]
}
```
For larger packs, point `js` at a separate JavaScript file (e.g. `"js": "/App_Plugins/MyPkg/en-us.js"`) that default-exports the same `{ group: { key: value } }` shape. The JS file form supports function entries that take typed arguments — string entries use the runtime `%0%` / `{0}` placeholder pattern.
**2. Declare the matching types globally** (optional, recommended) — same pattern as `UmbExtensionManifestMap`, plain interface merging, no module-path boilerplate:
```ts
// types.d.ts (or anywhere in the plugin's source tree)
declare global {
interface UmbKnownLocalizationSet {
mypkg_anything: string;
mypkg_greeting: (name: string) => string;
}
}
```
Plugin authors typically drop this in a single `types.d.ts` at the package root and ship it alongside the npm package — consumers of the plugin get autocomplete on the augmented keys automatically, with no extra config.
**3. Use them like built-in keys:**
```ts
this.localize.term('mypkg_anything'); // autocompletes alongside the built-ins
this.localize.term('mypkg_greeting', 'Alice'); // 'Alice: string' inferred from the declaration
html`<umb-localize key="mypkg_anything"></umb-localize>`;
```
Plugins that skip step 2 still work — their keys hit the `(string & {})` escape hatch in `term()`'s signature, so `localize.term('mypkg_anything')` compiles. They just lose autocomplete and arg-type inference on those keys.
### Active language
The active language is driven by the shell elements `<umb-app>` and `<umb-auth>`, not by `<html lang>`:
@@ -8,6 +8,7 @@ const preferImportAliasesRule = require('./devops/eslint/rules/prefer-import-ali
const preferStaticStylesLastRule = require('./devops/eslint/rules/prefer-static-styles-last.cjs');
const noRelativeImportToImportMapModule = require('./devops/eslint/rules/no-relative-import-to-import-map-module.cjs');
const noUnsafeLocalize = require('./devops/eslint/rules/no-unsafe-localize.cjs');
const noUnknownLocalizationKey = require('./devops/eslint/rules/no-unknown-localization-key.cjs');
const enforceManifestAliasRule = require('./devops/eslint/rules/enforce-manifest-alias.cjs');
module.exports = {
@@ -19,5 +20,6 @@ module.exports = {
'prefer-static-styles-last': preferStaticStylesLastRule,
'no-relative-import-to-import-map-module': noRelativeImportToImportMapModule,
'no-unsafe-localize': noUnsafeLocalize,
'no-unknown-localization-key': noUnknownLocalizationKey,
'enforce-manifest-alias': enforceManifestAliasRule,
};
@@ -56,6 +56,7 @@ export default [
'local-rules/enforce-manifest-alias': 'warn',
'local-rules/prefer-static-styles-last': 'warn',
'local-rules/no-unsafe-localize': 'error',
'local-rules/no-unknown-localization-key': 'error',
'local-rules/enforce-umbraco-external-imports': [
'error',
{
+2
View File
@@ -182,6 +182,7 @@
"build:vite": "tsc && vite build --mode staging",
"build:workspaces": "npm run build -ws --if-present",
"build": "tsc --project ./src/tsconfig.build.json",
"prebuild": "npm run generate:localization-keys",
"postbuild": "node ./devops/build/global-types.js",
"check": "npm run lint:errors && npm run compile && npm run build-storybook && npm run generate:jsonschema:dist",
"check:paths": "node ./devops/build/check-path-length.js dist-cms 120",
@@ -203,6 +204,7 @@
"generate:jsonschema:dist": "npm run generate:jsonschema -- --out ./umbraco-package-schema.json tsconfig.json UmbracoPackage",
"generate:jsonschema": "typescript-json-schema --skipLibCheck --ignoreErrors --excludePrivate --required --include \"./src/json-schema/umbraco-package-schema.ts\"",
"generate:check-const-test": "node ./devops/generate-check-const-test/index.js",
"generate:localization-keys": "node ./devops/localization/generate-known-keys.js",
"lint:errors": "npm run lint -- --quiet",
"lint:fix": "npm run lint -- --fix",
"lint": "eslint src",
@@ -956,6 +956,7 @@ export default {
next: 'Next',
no: 'No',
nodeName: 'Node Name',
none: 'None',
notFound: 'Not found',
of: 'of',
off: 'Off',
@@ -1,3 +1,7 @@
export * from './localization.controller.js';
export type * from './types/localization.js';
export * from './localization.manager.js';
// Side-effect re-export: registers the global `UmbKnownLocalizationSet` / `UmbKnownLocalizationKey`
// declarations so consumers (and plugin authors via interface merging) pick them up without an
// explicit type import. The runtime keys list is exposed for the staleness test in known-keys.test.ts.
export { UMB_KNOWN_LOCALIZATION_KEYS } from './known-keys.generated.js';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import { expect } from '@open-wc/testing';
import en from '../../assets/lang/en.js';
import { UMB_KNOWN_LOCALIZATION_KEYS } from './known-keys.generated.js';
/**
* Catches the "edited `en.ts` but forgot to regenerate" failure mode. The generated file is
* committed (see `docs/package-development.md` → Type-safe localization keys), so without an
* assertion like this a stale file would happily compile and only get caught the next time the
* `prebuild` hook ran. By that point the PR may already be merged.
*
* **Scope:** this only validates the set of flat key NAMES, not the argument signatures the
* generator emits per entry. A key flipping between a plain string and a function (or a function
* gaining/losing parameters) in `en.ts` is invisible to this test — `tsc` catches those at the
* call sites that pass args, but if no call site uses the affected key the drift survives until
* the next regen. Worth a follow-up if it ever bites in practice.
*/
describe('UmbKnownLocalizationSet generation', () => {
it('stays in sync with `assets/lang/en.ts`', () => {
const expected = new Set<string>();
for (const [group, entries] of Object.entries(en)) {
if (typeof entries !== 'object' || entries === null) continue;
for (const key of Object.keys(entries)) {
expected.add(`${group}_${key}`);
}
}
const actual = new Set<string>(UMB_KNOWN_LOCALIZATION_KEYS);
const missing = [...expected].filter((k) => !actual.has(k));
const extra = [...actual].filter((k) => !expected.has(k));
const regenHint = "Run 'npm run generate:localization-keys' to regenerate `known-keys.generated.ts`.";
expect(
missing,
`${missing.length} key(s) in en.ts are missing from the generated file. ${regenHint}\nMissing: ${missing.join(', ')}`,
).to.have.length(0);
expect(
extra,
`${extra.length} key(s) in the generated file no longer exist in en.ts. ${regenHint}\nStale: ${extra.join(', ')}`,
).to.have.length(0);
});
});
@@ -11,19 +11,49 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type {
UmbLocalizationSet,
FunctionParams,
UmbLocalizationSetBase,
UmbLocalizationSetKey,
} from './localization.manager.js';
import type { UmbLocalizationConsumer, UmbLocalizationSetBase, UmbLocalizationSetKey } from './localization.manager.js';
import { umbLocalizationManager } from './localization.manager.js';
// Side-effect import: registers the global `UmbKnownLocalizationSet` / `UmbKnownLocalizationKey`
// declarations so plugins can extend the interface via plain `declare global { … }` blocks.
import './known-keys.generated.js';
import { unsafeHTML } from '@umbraco-cms/backoffice/external/lit';
import { escapeHTML } from '@umbraco-cms/backoffice/utils';
import type { LitElement } from '@umbraco-cms/backoffice/external/lit';
import type { UmbController, UmbControllerHost } from '@umbraco-cms/backoffice/controller-api';
const LocalizationControllerAlias = Symbol();
/**
* Resolves the union of valid keys for a localization set, excluding the metadata fields
* declared on `UmbLocalizationSetBase` (`$code`, `$dir`). The `(string & {})` intersection
* preserves literal-key autocomplete while still accepting dynamic keys (e.g.,
* `` `login_greeting${day}` ``).
*
* If `LocalizationSetType` has an index signature (the legacy `UmbLocalizationSet` shape),
* `keyof` returns the index signature's key type and autocomplete falls back to free-form
* strings — no narrowing, no breakage. The default generic now points at
* `UmbKnownLocalizationSet`, which is the codegen output with literal keys, so callers get
* autocomplete out of the box.
*/
type LocalizationKeyOf<T> = (Exclude<keyof T, keyof UmbLocalizationSetBase> & string) | (string & {});
/**
* Resolves the argument tuple for a localization key.
*
* - **Function entries** (e.g., `(name: string) => string`) forward their parameter list so the
* call site is checked against the source signature.
* - **String entries** stay as `unknown[]` rather than `[]`. The runtime supports `%0%` and
* `{0}` placeholder substitution on string values (see `#processTerm`), so passing args to a
* string key is intentional, not an error.
* - **Dynamic-string escape hatch** (`(string & {})`) falls back to `unknown[]` so consumers
* building a key on the fly (e.g., `` `login_greeting${day}` ``) aren't forced to cast.
*/
type LocalizationArgsOf<T, K> = K extends keyof T
? T[K] extends (...args: infer U) => string
? U
: unknown[]
: unknown[];
/**
* The UmbLocalizationController enables localization for your element.
* @see UmbLocalizeElement
@@ -41,8 +71,8 @@ const LocalizationControllerAlias = Symbol();
* }
* ```
*/
export class UmbLocalizationController<LocalizationSetType extends UmbLocalizationSetBase = UmbLocalizationSet>
implements UmbController
export class UmbLocalizationController<LocalizationSetType extends UmbLocalizationSetBase = UmbKnownLocalizationSet>
implements UmbController, UmbLocalizationConsumer
{
#host;
#hostEl?: HTMLElement & Partial<Pick<LitElement, 'requestUpdate'>>;
@@ -165,8 +195,13 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
/**
* Outputs a translated term.
* @param {string} key - the localization key, the indicator of what localization entry you want to retrieve.
* @param {unknown[]} args - the arguments to parse for this localization entry.
* @param key The localization key to retrieve. Typed as `LocalizationKeyOf<LocalizationSetType>` —
* literal-key autocomplete from `UmbKnownLocalizationSet` plus a `(string & {})` escape
* hatch for dynamic keys.
* @param args The arguments to parse for this localization entry. Resolved by
* `LocalizationArgsOf<LocalizationSetType, K>`: function-valued entries forward their
* parameter list (so `(name: string) => string` requires a `string` here); string-valued
* entries accept `unknown[]` for the runtime `%0%` / `{0}` substitution path.
* @returns {string} - the translated term as a string.
* @example
* Retrieving a term without any arguments:
@@ -175,11 +210,14 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
* ```
* Retrieving a term with arguments:
* ```ts
* this.localize.term('general_greeting', ['John']);
* this.localize.term('general_greeting', 'John');
* ```
*/
term<K extends keyof LocalizationSetType>(key: K, ...args: FunctionParams<LocalizationSetType[K]>): string {
const term = this.#lookupTerm(key);
term<K extends LocalizationKeyOf<LocalizationSetType>>(
key: K,
...args: LocalizationArgsOf<LocalizationSetType, K>
): string {
const term = this.#lookupTerm(key as keyof LocalizationSetType);
if (term === null) {
return String(key);
@@ -192,9 +230,14 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
* Returns the localized term for the given key, or the default value if not found.
* This method follows the same resolution order as term() (primary → secondary → fallback),
* but returns the provided defaultValue instead of the key when no translation is found.
* @param {string} key - the localization key, the indicator of what localization entry you want to retrieve.
* @param {string | null} defaultValue - the value to return if the key is not found in any localization set.
* @param {unknown[]} args - the arguments to parse for this localization entry.
* @param key The localization key to retrieve. Typed as `LocalizationKeyOf<LocalizationSetType>` —
* literal-key autocomplete from `UmbKnownLocalizationSet` plus a `(string & {})` escape
* hatch for dynamic keys.
* @param defaultValue The value to return if the key is not found in any localization set.
* @param args The arguments to parse for this localization entry. Resolved by
* `LocalizationArgsOf<LocalizationSetType, K>`: function-valued entries forward their
* parameter list; string-valued entries accept `unknown[]` for the runtime `%0%` / `{0}`
* substitution path.
* @returns {string | null} - the translated term or the default value.
* @example
* Retrieving a term with fallback:
@@ -210,12 +253,12 @@ export class UmbLocalizationController<LocalizationSetType extends UmbLocalizati
* this.localize.termOrDefault('general_close', null);
* ```
*/
termOrDefault<K extends keyof LocalizationSetType, D extends string | null>(
termOrDefault<K extends LocalizationKeyOf<LocalizationSetType>, D extends string | null>(
key: K,
defaultValue: D,
...args: FunctionParams<LocalizationSetType[K]>
...args: LocalizationArgsOf<LocalizationSetType, K>
): string | D {
const term = this.#lookupTerm(key);
const term = this.#lookupTerm(key as keyof LocalizationSetType);
if (term === null) {
return defaultValue;
@@ -12,7 +12,6 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { UmbLocalizationController } from './localization.controller.js';
import type { UmbLocalizationEntry } from './types/localization.js';
export type FunctionParams<T> = T extends (...args: infer U) => string ? U : [];
@@ -28,10 +27,29 @@ export interface UmbLocalizationSet extends UmbLocalizationSetBase {
[key: UmbLocalizationSetKey]: UmbLocalizationEntry;
}
/**
* The non-generic contract the manager calls on each registered controller. Decoupled from
* `UmbLocalizationController<T>`'s generic so the manager's `Set` and consumer-registration
* methods don't pick up variance from `term()`'s conditional types — and so the manager can't
* accidentally start depending on the generic in future without amending the contract here.
*/
export interface UmbLocalizationConsumer {
keysChanged(changedKeys: Set<UmbLocalizationSetKey>): void;
documentUpdate(): void;
}
export const UMB_DEFAULT_LOCALIZATION_CULTURE = 'en';
export class UmbLocalizationManager {
connectedControllers = new Set<UmbLocalizationController<UmbLocalizationSetBase>>();
/**
* Internal registry of controllers the manager dispatches `keysChanged` / `documentUpdate` to.
* Use `appendConsumer` / `removeConsumer` to mutate this set — external code should not iterate
* it directly or assume the stored values are full `UmbLocalizationController` instances. The
* element type was relaxed from `UmbLocalizationController<UmbLocalizationSetBase>` to the
* smaller `UmbLocalizationConsumer` contract in v18.1 so that the manager doesn't pick up
* variance from the controller's generic `term()` signature.
*/
connectedControllers = new Set<UmbLocalizationConsumer>();
#changedKeys: Set<UmbLocalizationSetKey> = new Set();
#requestUpdateChangedKeysId?: number = undefined;
@@ -51,17 +69,17 @@ export class UmbLocalizationManager {
return this.localizations.get(UMB_DEFAULT_LOCALIZATION_CULTURE) as UmbLocalizationSet;
}
appendConsumer(consumer: UmbLocalizationController<UmbLocalizationSetBase>) {
appendConsumer(consumer: UmbLocalizationConsumer) {
if (this.connectedControllers.has(consumer)) return;
this.connectedControllers.add(consumer);
}
removeConsumer(consumer: UmbLocalizationController<UmbLocalizationSetBase>) {
removeConsumer(consumer: UmbLocalizationConsumer) {
this.connectedControllers.delete(consumer);
}
/**
* Registers one or more translations
* @param t
* @param {UmbLocalizationSetBase} t The localization set
*/
registerLocalization(t: UmbLocalizationSetBase) {
const code = t.$code.toLowerCase();
@@ -25,7 +25,7 @@ export class UmbCollectionFilterFieldElement extends UmbLitElement {
override render() {
return html`
<uui-input
label=${this.localize.term('general_filter')}
label=${this.localize.term('placeholders_filter')}
placeholder=${this.localize.term('placeholders_filter')}
data-mark="input:filter"
@input=${this.#onInput}></uui-input>
@@ -98,7 +98,7 @@ export class UmbSplitPanelElement extends UmbLitElement {
// Update ARIA value for divider
const formatted = percentagePos.toFixed(0);
const ariaText = this.localize?.term('general_dividerPosition', [formatted]) ?? `Divider at ${formatted}%`;
const ariaText = this.localize?.term('general_dividerPosition', formatted) ?? `Divider at ${formatted}%`;
this.dividerTouchAreaElement.setAttribute('aria-valuetext', ariaText);
}
@@ -62,13 +62,18 @@ export class UmbUiCultureInputElement extends UmbFormControlMixin<string, typeof
this.addValidator(
'customError',
() => this.localize.term('user_languageNotFound', this.#invalidCulture, this.value),
() => this.localize.term('user_languageNotFound', this.#invalidCulture ?? '', this.value ?? ''),
() => !!this.#invalidCulture && !this.#invalidBaseCulture,
);
this.addValidator(
'customError',
() => this.localize.term('user_languageNotFoundFallback', this.#invalidCulture, this.#invalidBaseCulture),
() =>
this.localize.term(
'user_languageNotFoundFallback',
this.#invalidCulture ?? '',
this.#invalidBaseCulture ?? '',
),
() => !!this.#invalidCulture && !!this.#invalidBaseCulture,
);
}
@@ -1,5 +1,8 @@
import { css, customElement, html, property, state, unsafeHTML, when } from '@umbraco-cms/backoffice/external/lit';
import { escapeHTML } from '@umbraco-cms/backoffice/utils';
// Side-effect import: ensures the global `UmbKnownLocalizationKey` declaration is loaded so the
// `key` property below picks up plugin-augmented entries from `declare global` blocks.
import '@umbraco-cms/backoffice/localization-api';
import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
/**
@@ -11,11 +14,15 @@ import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element';
export class UmbLocalizeElement extends UmbLitElement {
/**
* The key to localize. The key is case sensitive.
*
* Typed as `UmbKnownLocalizationKey | (string & {})` so that property bindings get
* autocomplete for the canonical `en.ts` dictionary while still accepting dynamic
* keys (e.g., `` `login_greeting${day}` ``) and third-party-augmented entries.
* @attr
* @example key="general_ok"
*/
@property()
key!: string;
key!: UmbKnownLocalizationKey | (string & {});
/**
* The values to forward to the localization function (must be JSON compatible).
@@ -48,7 +48,7 @@ export class UmbPropertyActionMenuElement extends UmbLitElement {
id="popover-trigger"
popovertarget="property-action-popover"
data-mark="open-property-actions"
label=${this.localize.term('actions_viewActionsFor')}
label=${this.localize.term('actions_viewActionsFor', '')}
compact>
<uui-symbol-more id="more-symbol"></uui-symbol-more>
</uui-button>
@@ -285,7 +285,7 @@ export class UmbDocumentScheduleModalElement extends UmbModalBaseElement<
type="datetime-local"
.value=${this.#formatDate(fromDate)}
@change=${(e: Event) => this.#onFromDateChange(e, option.unique)}
label=${this.localize.term('general_publishDate')}>
label=${this.localize.term('content_releaseDate')}>
<div slot="append">
${when(
fromDate,
@@ -313,7 +313,7 @@ export class UmbDocumentScheduleModalElement extends UmbModalBaseElement<
type="datetime-local"
.value=${this.#formatDate(toDate)}
@change=${(e: Event) => this.#onToDateChange(e, option.unique)}
label=${this.localize.term('general_publishDate')}>
label=${this.localize.term('content_unpublishDate')}>
<div slot="append">
${when(
toDate,
@@ -61,7 +61,7 @@ export class UmbDocumentPublishingWorkspaceContext extends UmbContextBase implem
this.#documentWorkspaceContext = context;
this.#documentWorkspaceContext?.view.shortcuts.addOne({
unique: UMB_DOCUMENT_PUBLISHING_SHORTCUT_UNIQUE,
label: this.#localize.term('content_saveAndPublishShortcut'),
label: this.#localize.term('buttons_saveAndPublish'),
key: 'p',
modifier: true,
action: () => this.saveAndPublish(),
@@ -285,7 +285,7 @@ export class UmbElementScheduleModalElement extends UmbModalBaseElement<
type="datetime-local"
.value=${this.#formatDate(fromDate)}
@change=${(e: Event) => this.#onFromDateChange(e, option.unique)}
label=${this.localize.term('general_publishDate')}>
label=${this.localize.term('content_releaseDate')}>
<div slot="append">
${when(
fromDate,
@@ -313,7 +313,7 @@ export class UmbElementScheduleModalElement extends UmbModalBaseElement<
type="datetime-local"
.value=${this.#formatDate(toDate)}
@change=${(e: Event) => this.#onToDateChange(e, option.unique)}
label=${this.localize.term('general_publishDate')}>
label=${this.localize.term('content_unpublishDate')}>
<div slot="append">
${when(
toDate,
@@ -54,7 +54,7 @@ export class UmbElementPublishingWorkspaceContext extends UmbContextBase impleme
this.#elementWorkspaceContext = context;
this.#elementWorkspaceContext?.view.shortcuts.addOne({
unique: UMB_ELEMENT_PUBLISHING_SHORTCUT_UNIQUE,
label: this.#localize.term('content_saveAndPublishShortcut'),
label: this.#localize.term('buttons_saveAndPublish'),
key: 'p',
modifier: true,
action: () => this.saveAndPublish(),
@@ -18,7 +18,7 @@ export class UmbUrlPickerMonacoMarkdownEditorAction extends UmbControllerBase {
}
getLabel() {
return this.#localize.term('general_insertLink');
return this.#localize.term('defaultdialogs_insertlink');
}
getKeybindings() {
@@ -74,8 +74,8 @@ export class UmbCreateUserModalElement extends UmbModalBaseElement<UmbCreateUser
}
override render() {
return html`<uui-dialog-layout headline=${this.localize.term('user_createUserHeadline', this.data?.user.kind)}>
<p>${this.localize.term('user_createUserDescription', this.data?.user.kind)}</p>
return html`<uui-dialog-layout headline=${this.localize.term('user_createUserHeadline', this.data?.user.kind ?? '')}>
<p>${this.localize.term('user_createUserDescription', this.data?.user.kind ?? '')}</p>
${this.#renderForm()}
<uui-button @click=${this._rejectModal} slot="actions" label="Cancel" look="secondary"></uui-button>