Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49bf135a7e | ||
|
|
c8e839dc8e | ||
|
|
0fdea48665 | ||
|
|
74a1f5fb55 | ||
|
|
2e4a479d79 | ||
|
|
5a0e9b212f | ||
|
|
133d45ee73 | ||
|
|
25452fc62c | ||
|
|
0dcb3b2177 | ||
|
|
eb2efe4de6 | ||
|
|
d1d50db6b1 | ||
|
|
019370e7f4 | ||
|
|
3f507c08aa | ||
|
|
ef715ee769 | ||
|
|
6edb8f6771 |
@@ -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
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
+5416
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();
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+7
-2
@@ -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).
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export class UmbUrlPickerMonacoMarkdownEditorAction extends UmbControllerBase {
|
||||
}
|
||||
|
||||
getLabel() {
|
||||
return this.#localize.term('general_insertLink');
|
||||
return this.#localize.term('defaultdialogs_insertlink');
|
||||
}
|
||||
|
||||
getKeybindings() {
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user