diff --git a/eslint.config.ts b/eslint.config.ts index a89102f8e0..c44c690f74 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -55,6 +55,7 @@ const polarConfig = defineConfig({ }, ], 'local/import-style': 'error', + 'local/no-literal-ns-in-t': 'error', }, }) diff --git a/eslintRules/index.ts b/eslintRules/index.ts index 427b1eac92..c8f30e127c 100644 --- a/eslintRules/index.ts +++ b/eslintRules/index.ts @@ -1,9 +1,11 @@ import importStyle from './import-style.js' +import noLiteralNsInT from './no-literal-ns-in-t.js' export default { rules: { /* eslint-disable @typescript-eslint/naming-convention */ 'import-style': importStyle, + 'no-literal-ns-in-t': noLiteralNsInT, /* eslint-enable @typescript-eslint/naming-convention */ }, } diff --git a/eslintRules/no-literal-ns-in-t.ts b/eslintRules/no-literal-ns-in-t.ts new file mode 100644 index 0000000000..2ef1706305 --- /dev/null +++ b/eslintRules/no-literal-ns-in-t.ts @@ -0,0 +1,63 @@ +import type { Rule } from 'eslint' + +/** + * Enforces that the `ns` option in `t`/`$t` calls uses a constant identifier + * (e.g. `PluginId` or `CoreId`) instead of a hard-coded string literal. + * + * Bad: `t(($) => $.key, { ns: 'filter' })` + * Good: `t(($) => $.key, { ns: PluginId })` + */ +const noLiteralNsInT: Rule.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: + 'Enforce using `PluginId`/`CoreId` identifier instead of a string literal for the `ns` option in `t`/`$t` calls', + }, + messages: { + useLiteralId: + 'Use the `PluginId` (or `CoreId`) constant instead of the string literal "{{ ns }}" as the `ns` option.', + }, + schema: [], + }, + create(context) { + return { + /* eslint-disable @typescript-eslint/naming-convention */ + CallExpression(node) { + const { callee } = node + if ( + callee.type !== 'Identifier' || + (callee.name !== 't' && callee.name !== '$t') + ) { + return + } + + for (const arg of node.arguments) { + if (arg.type !== 'ObjectExpression') { + continue + } + + for (const prop of arg.properties) { + if ( + prop.type === 'Property' && + !prop.computed && + ((prop.key.type === 'Identifier' && prop.key.name === 'ns') || + (prop.key.type === 'Literal' && prop.key.value === 'ns')) && + prop.value.type === 'Literal' && + typeof prop.value.value === 'string' + ) { + context.report({ + node: prop.value, + messageId: 'useLiteralId', + data: { ns: prop.value.value }, + }) + } + } + } + }, + /* eslint-enable @typescript-eslint/naming-convention */ + } + }, +} + +export default noLiteralNsInT diff --git a/src/core/components/PolarContainer.ce.vue b/src/core/components/PolarContainer.ce.vue index 12604cb575..4edf3d3c0c 100644 --- a/src/core/components/PolarContainer.ce.vue +++ b/src/core/components/PolarContainer.ce.vue @@ -50,6 +50,7 @@ import { useMoveHandleStore } from '../stores/moveHandle' import { loadKern } from '../utils/loadKern' import { teardownMarkers } from '../utils/map/setupMarkers' import { mapZoomOffset } from '../utils/mapZoomOffset' +import { CoreId } from '../vuePlugins/i18next' import MoveHandle from './MoveHandle.ce.vue' import PolarMap from './PolarMap.ce.vue' import PolarMapOverlay from './PolarMapOverlay.ce.vue' @@ -76,10 +77,10 @@ const overlay = const isMacOS = navigator.userAgent.indexOf('Mac') !== -1 const noCommandOnZoom = useT(() => - t(($) => $.overlay.noCommandOnZoom, { ns: 'core' }) + t(($) => $.overlay.noCommandOnZoom, { ns: CoreId }) ) const noControlOnZoom = useT(() => - t(($) => $.overlay.noControlOnZoom, { ns: 'core' }) + t(($) => $.overlay.noControlOnZoom, { ns: CoreId }) ) function wheelEffect(event: WheelEvent) { @@ -95,7 +96,7 @@ function wheelEffect(event: WheelEvent) { } const oneFingerPan = useT(() => - t(($) => $.overlay.oneFingerPan, { ns: 'core' }) + t(($) => $.overlay.oneFingerPan, { ns: CoreId }) ) let hammer: { destroy: () => void } | null = null function updateListeners() { diff --git a/src/core/utils/checkServiceAvailability.ts b/src/core/utils/checkServiceAvailability.ts index be63947c5f..329fa6eb34 100644 --- a/src/core/utils/checkServiceAvailability.ts +++ b/src/core/utils/checkServiceAvailability.ts @@ -9,6 +9,8 @@ import type { ServiceAvailabilityCheck, } from '../types' +import { CoreId } from '../vuePlugins/i18next' + export function checkServiceAvailability( configuration: MapConfiguration, register: MasterportalApiServiceRegister @@ -44,7 +46,7 @@ export function checkServiceAvailability( if (statusCode !== 200) { notifyUser('warning', () => t(($) => $.error.serviceUnavailable, { - ns: 'core', + ns: CoreId, serviceId, serviceName, }) diff --git a/src/plugins/export/store.ts b/src/plugins/export/store.ts index 921b120965..f14f2379c8 100644 --- a/src/plugins/export/store.ts +++ b/src/plugins/export/store.ts @@ -15,7 +15,7 @@ import { notifyUser } from '@/lib/notifyUser' import type { ExportFormat } from './types' -import { EXPORT_FORMATS } from './types' +import { EXPORT_FORMATS, PluginId } from './types' import { convertToPdf } from './utils/convertToPdf' import { CrossOriginMonkey } from './utils/CrossOriginMonkey' import { downloadAsImage } from './utils/downloadAsImage' @@ -103,7 +103,7 @@ export const useExportStore = defineStore('plugins/export', () => { console.error(error) notifyUser('error', () => t(($) => $.error, { - ns: 'export', + ns: PluginId, }) ) throw error diff --git a/src/plugins/geoLocation/store.ts b/src/plugins/geoLocation/store.ts index b8bfe69352..0a4ed4e47f 100644 --- a/src/plugins/geoLocation/store.ts +++ b/src/plugins/geoLocation/store.ts @@ -26,8 +26,11 @@ import { notifyUser } from '@/lib/notifyUser' import { passesBoundaryCheck } from '@/lib/passesBoundaryCheck' import { getTooltip } from '@/lib/tooltip' -import type { PluginState, GeoLocationPluginOptions } from './types' - +import { + type PluginState, + type GeoLocationPluginOptions, + PluginId, +} from './types' import { detectDeniedGeolocationEarly } from './utils/detectDeniedGeolocationEarly' import { getGeoLocationStyle } from './utils/olStyle' import { positionChanged } from './utils/positionChanged' @@ -184,7 +187,7 @@ export const useGeoLocationStore = defineStore('plugins/geoLocation', () => { notifyUser( 'error', t(($) => $.button.locationAccessDenied, { - ns: 'geoLocation', + ns: PluginId, }) ) console.error(error.message) @@ -282,13 +285,13 @@ export const useGeoLocationStore = defineStore('plugins/geoLocation', () => { function printPositioningFailed(boundaryErrorOccurred: boolean) { if (boundaryErrorOccurred) { - const msg = t(($) => $.toast.boundaryError, { ns: 'geoLocation' }) + const msg = t(($) => $.toast.boundaryError, { ns: PluginId }) notifyUser('error', msg) console.error(msg) return } const msg = t(($) => $.toast.notInBoundary, { - ns: 'geoLocation', + ns: PluginId, }) notifyUser('info', msg, { timeout: 10000 }) // eslint-disable-next-line no-console diff --git a/src/plugins/iconMenu/store.ts b/src/plugins/iconMenu/store.ts index c4b7a88aab..16281c0968 100644 --- a/src/plugins/iconMenu/store.ts +++ b/src/plugins/iconMenu/store.ts @@ -11,7 +11,7 @@ import { type Component, computed, markRaw, ref } from 'vue' import { useCoreStore } from '@/core/stores' -import type { Menu } from './types' +import { PluginId, type Menu } from './types' /* eslint-disable tsdoc/syntax */ /** @@ -147,11 +147,11 @@ export const useIconMenuStore = defineStore('plugins/iconMenu', () => { open.value = null }, closeLabel: t(($) => $.mobileCloseButton, { - ns: 'iconMenu', - plugin: t(($) => $.hints[menu.plugin.id], { ns: 'iconMenu' }), + ns: PluginId, + plugin: t(($) => $.hints[menu.plugin.id], { ns: PluginId }), }), component: menu.plugin.component, - plugin: 'iconMenu', + plugin: PluginId, }) } diff --git a/src/plugins/pins/utils/isCoordinateInBoundaryLayer.ts b/src/plugins/pins/utils/isCoordinateInBoundaryLayer.ts index fd116e680d..83f0ef8c18 100644 --- a/src/plugins/pins/utils/isCoordinateInBoundaryLayer.ts +++ b/src/plugins/pins/utils/isCoordinateInBoundaryLayer.ts @@ -8,6 +8,8 @@ import type { BoundaryOptions } from '@/core' import { notifyUser } from '@/lib/notifyUser' import { passesBoundaryCheck } from '@/lib/passesBoundaryCheck' +import { PluginId } from '../types' + /** * Checks if boundary layer conditions are met; returns false if not and * toasts to the user about why the action was blocked, if `toastAction` is @@ -35,10 +37,10 @@ export async function isCoordinateInBoundaryLayer( } if (typeof boundaryCheckResult === 'symbol') { - notifyUser('error', () => t(($) => $.boundaryError, { ns: 'pins' })) + notifyUser('error', () => t(($) => $.boundaryError, { ns: PluginId })) console.error('Checking boundary layer failed.') } else { - notifyUser('info', () => t(($) => $.notInBoundary, { ns: 'pins' })) + notifyUser('info', () => t(($) => $.notInBoundary, { ns: PluginId })) // eslint-disable-next-line no-console console.info('Pin position outside of boundary layer:', coordinate) }