From 0878fb3d4b70b169e5dc171a312e84b98c7f800f Mon Sep 17 00:00:00 2001 From: Candid Dauth Date: Thu, 4 Apr 2024 02:38:11 +0200 Subject: [PATCH] Add user preferences dialog and persist language setting as cookie --- client/src/client.ts | 6 +- docs/src/developers/embed.md | 1 + docs/src/developers/i18n.md | 4 +- frontend/package.json | 2 + frontend/src/i18n/de.ts | 6 ++ frontend/src/i18n/en.ts | 6 ++ .../src/lib/components/client-provider.vue | 7 +- .../toolbox/toolbox-tools-dropdown.vue | 20 ++++ .../components/user-preferences-dialog.vue | 65 +++++++++++++ frontend/src/lib/utils/cookies.ts | 47 +++++++++ frontend/src/lib/utils/i18n.ts | 11 ++- server/src/i18n.ts | 95 ++++++++++++++----- server/src/socket/socket-v2.ts | 4 + server/src/socket/socket.ts | 9 +- types/src/socket/socket-common.ts | 5 + types/src/socket/socket-versions.ts | 6 +- utils/src/i18n-utils.ts | 31 +++++- yarn.lock | 16 ++++ 18 files changed, 302 insertions(+), 39 deletions(-) create mode 100644 frontend/src/lib/components/user-preferences-dialog.vue create mode 100644 frontend/src/lib/utils/cookies.ts diff --git a/client/src/client.ts b/client/src/client.ts index ab25fb4a..cfae8a00 100644 --- a/client/src/client.ts +++ b/client/src/client.ts @@ -1,5 +1,5 @@ import { io, type ManagerOptions, type Socket as SocketIO, type SocketOptions } from "socket.io-client"; -import { type Bbox, type BboxWithZoom, type CRU, type EventHandler, type EventName, type FindOnMapQuery, type FindPadsQuery, type FindPadsResult, type FindQuery, type GetPadQuery, type HistoryEntry, type ID, type Line, type LineExportRequest, type LineTemplateRequest, type LineToRouteCreate, type SocketEvents, type Marker, type MultipleEvents, type ObjectWithId, type PadData, type PadId, type PagedResults, type SocketRequest, type SocketRequestName, type SocketResponse, type Route, type RouteClear, type RouteCreate, type RouteExportRequest, type RouteInfo, type RouteRequest, type SearchResult, type SocketVersion, type TrackPoint, type Type, type View, type Writable, type SocketClientToServerEvents, type SocketServerToClientEvents, type LineTemplate, type LinePointsEvent, PadNotFoundError } from "facilmap-types"; +import { type Bbox, type BboxWithZoom, type CRU, type EventHandler, type EventName, type FindOnMapQuery, type FindPadsQuery, type FindPadsResult, type FindQuery, type GetPadQuery, type HistoryEntry, type ID, type Line, type LineExportRequest, type LineTemplateRequest, type LineToRouteCreate, type SocketEvents, type Marker, type MultipleEvents, type ObjectWithId, type PadData, type PadId, type PagedResults, type SocketRequest, type SocketRequestName, type SocketResponse, type Route, type RouteClear, type RouteCreate, type RouteExportRequest, type RouteInfo, type RouteRequest, type SearchResult, type SocketVersion, type TrackPoint, type Type, type View, type Writable, type SocketClientToServerEvents, type SocketServerToClientEvents, type LineTemplate, type LinePointsEvent, PadNotFoundError, type SetLanguageRequest } from "facilmap-types"; import { deserializeError, errorConstructors } from "serialize-error"; export interface ClientEvents extends SocketEvents { @@ -355,6 +355,10 @@ export default class Client { return await this._setPadId(padId); } + async setLanguage(language: SetLanguageRequest): Promise { + await this._emit("setLanguage", language); + } + async updateBbox(bbox: BboxWithZoom): Promise>> { const isZoomChange = this.bbox && bbox.zoom !== this.bbox.zoom; diff --git a/docs/src/developers/embed.md b/docs/src/developers/embed.md index aeded1e8..4f5d0981 100644 --- a/docs/src/developers/embed.md +++ b/docs/src/developers/embed.md @@ -18,6 +18,7 @@ You can control the display of different components by using the following query * `autofocus`: Autofocus the search field (default: `false`) * `legend`: Show the legend if available (default: `true`) * `interactive`: Enable [interactive mode](#interactive-mode) (default: `false`) +* `lang`: Fix the user interface to one particular language, for example `en`. If not specified, the user language is auto-detected. Example: diff --git a/docs/src/developers/i18n.md b/docs/src/developers/i18n.md index 3613ce17..95b9e7b8 100644 --- a/docs/src/developers/i18n.md +++ b/docs/src/developers/i18n.md @@ -1,8 +1,8 @@ # I18n FacilMap uses [i18next](https://www.i18next.com/) for internationalization throughout the frontend, the server and its libraries. It detects the desired user language like this: -* In the browser, [i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) is used to detect the user’s language. It looks at the configured browser languages ([`navigator.languages`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/languages)) and checks for which one a translation exists. The configured language can be overridden by setting an `i18next` item in local/session storage or the cookies or appending an `?lng=` query parameter to the URL. -* On the server, when a request is handled through HTTP (including the WebSocket), [i18next-http-middleware](https://www.npmjs.com/package/i18next-http-middleware) is used to detect the user’s language. It looks at the configured browser languages ([`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)) and checks for which one a translation exists. The configured language can be overridden by setting an `i18next` cookie or by appending an `?lng=` query parameter to the URL. The server stores the selected language in the [Node.js domain](https://nodejs.org/api/domain.html) that is created for each incoming request, causing all functions triggered (sync or async) from the request to use the language setting of the request. +* In the browser, [i18next-browser-languageDetector](https://github.com/i18next/i18next-browser-languageDetector) is used to detect the user’s language. It looks at the configured browser languages ([`navigator.languages`](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/languages)) and checks for which one a translation exists. The configured language can be overridden by setting a `lang` cookie or appending a `?lang=` query parameter to the URL. +* On the server, when a request is handled through HTTP (including the WebSocket), [i18next-http-middleware](https://www.npmjs.com/package/i18next-http-middleware) is used to detect the user’s language. It looks at the configured browser languages ([`Accept-Language`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language)) and checks for which one a translation exists. The configured language can be overridden by setting a `lang` cookie or by appending a `?lang=` query parameter to the URL. The server stores the selected language in the [Node.js domain](https://nodejs.org/api/domain.html) that is created for each incoming request, causing all functions triggered (sync or async) from the request to use the language setting of the request. * On the sever, when a function is called outside of an incoming HTTP request, messages are not internationalized and output in English. ## Use FacilMap in an app not using i18next diff --git a/frontend/package.json b/frontend/package.json index b86ff2e8..a0e3397b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -54,6 +54,7 @@ "hammerjs": "^2.0.8", "i18next": "^23.10.1", "jquery": "^3.7.1", + "js-cookie": "^3.0.5", "leaflet": "^1.9.4", "leaflet-draggable-lines": "^2.0.0", "leaflet-graphicscale": "^0.0.4", @@ -82,6 +83,7 @@ "@types/file-saver": "^2.0.7", "@types/hammerjs": "^2.0.45", "@types/jquery": "^3.5.29", + "@types/js-cookie": "^3.0.6", "@types/leaflet": "^1.9.8", "@types/leaflet-mouse-position": "^1.2.4", "@types/leaflet.locatecontrol": "^0.74.4", diff --git a/frontend/src/i18n/de.ts b/frontend/src/i18n/de.ts index 29de0301..f3466d19 100644 --- a/frontend/src/i18n/de.ts +++ b/frontend/src/i18n/de.ts @@ -87,6 +87,12 @@ const messagesDe = { "close": "Schließen", "cancel": "Abbrechen", "save": "Speichern" + }, + + "user-preferences-dialog": { + "title": `Benutzereinstellungen`, + "introduction": `Diese Einstellungen werden als Cookies auf Ihrem Computer gespeichert und werden unabhängig von der geöffneten Karte angewendet.`, + "language": `Sprache` } }; diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 67590dca..f9ca7136 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -87,6 +87,12 @@ const messagesEn = { "close": "Close", "cancel": "Cancel", "save": "Save" + }, + + "user-preferences-dialog": { + "title": `User preferences`, + "introduction": `These settings are stored on your computer as a cookie and are applied independently of the opened map.`, + "language": `Language` } }; diff --git a/frontend/src/lib/components/client-provider.vue b/frontend/src/lib/components/client-provider.vue index 10ed5cd3..abac327b 100644 --- a/frontend/src/lib/components/client-provider.vue +++ b/frontend/src/lib/components/client-provider.vue @@ -9,6 +9,7 @@ import type { ClientContext } from "./facil-map-context-provider/client-context"; import { injectContextRequired } from "./facil-map-context-provider/facil-map-context-provider.vue"; import { useI18n } from "../utils/i18n"; + import { getCurrentLanguage } from "facilmap-utils"; function isPadNotFoundError(serverError: Client["serverError"]): boolean { return !!serverError && serverError instanceof PadNotFoundError; @@ -57,7 +58,11 @@ } } - const newClient = new CustomClient(props.serverUrl, props.padId); + const newClient = new CustomClient(props.serverUrl, props.padId, { + query: { + lang: getCurrentLanguage() + } + }); connectingClient.value = newClient; let lastPadId: PadId | undefined = undefined; diff --git a/frontend/src/lib/components/toolbox/toolbox-tools-dropdown.vue b/frontend/src/lib/components/toolbox/toolbox-tools-dropdown.vue index d5028ffc..bc618ccd 100644 --- a/frontend/src/lib/components/toolbox/toolbox-tools-dropdown.vue +++ b/frontend/src/lib/components/toolbox/toolbox-tools-dropdown.vue @@ -7,6 +7,7 @@ import DropdownMenu from "../ui/dropdown-menu.vue"; import { injectContextRequired, requireClientContext } from "../facil-map-context-provider/facil-map-context-provider.vue"; import ExportDialog from "../export-dialog.vue"; + import UserPreferencesDialog from "../user-preferences-dialog.vue"; const context = injectContextRequired(); const client = requireClientContext(context); @@ -26,6 +27,7 @@ | "export" | "edit-filter" | "history" + | "user-preferences" >(); @@ -95,6 +97,19 @@ draggable="false" >History + +
  • + +
  • + +
  • + User preferences +
  • + + \ No newline at end of file diff --git a/frontend/src/lib/components/user-preferences-dialog.vue b/frontend/src/lib/components/user-preferences-dialog.vue new file mode 100644 index 00000000..7fe756d2 --- /dev/null +++ b/frontend/src/lib/components/user-preferences-dialog.vue @@ -0,0 +1,65 @@ + + + \ No newline at end of file diff --git a/frontend/src/lib/utils/cookies.ts b/frontend/src/lib/utils/cookies.ts new file mode 100644 index 00000000..53b35e3d --- /dev/null +++ b/frontend/src/lib/utils/cookies.ts @@ -0,0 +1,47 @@ +import Cookies from "js-cookie"; +import { computed, reactive, readonly, ref } from "vue"; + +export interface Cookies { + lang?: string; +} + +const cookieCounter = ref(0); + +function cookie(name: string) { + return computed(() => { + cookieCounter.value; + return Cookies.get(name); + }); +} + +export const cookies = readonly(reactive({ + lang: cookie("lang") +})); + +const hasStorageAccessP = (async () => { + if ("hasStorageAccess" in document) { + return await document.hasStorageAccess(); + } else { + return true; + } +})(); + +async function setLongTermCookie(name: keyof Cookies, value: string): Promise { + try { + Cookies.set(name, value, { + expires: 3650, + partitioned: !(await hasStorageAccessP) + }); + } finally { + cookieCounter.value++; + } +} + +export async function setLangCookie(value: string): Promise { + await setLongTermCookie("lang", value); +} + +// Renew long-term cookies (see https://developer.chrome.com/blog/cookie-max-age-expires) +if (cookies.lang) { + void setLangCookie(cookies.lang); +} \ No newline at end of file diff --git a/frontend/src/lib/utils/i18n.ts b/frontend/src/lib/utils/i18n.ts index 3852b91b..82c8ac39 100644 --- a/frontend/src/lib/utils/i18n.ts +++ b/frontend/src/lib/utils/i18n.ts @@ -38,14 +38,21 @@ onI18nReady((i18n) => { i18n.on("loaded", rerender); }); -export function useI18n(): Pick { +export function useI18n(): { + t: i18n["t"]; + changeLanguage: (lang: string) => Promise; +} { return { t: new Proxy(getRawI18n().getFixedT(null, namespace), { apply: (target, thisArg, argumentsList) => { rerenderCounter.value; return target.apply(thisArg, argumentsList as any); } - }) + }), + + changeLanguage: async (lang) => { + await getRawI18n().changeLanguage(lang); + } }; } diff --git a/server/src/i18n.ts b/server/src/i18n.ts index 9d7636f4..9cb3a78b 100644 --- a/server/src/i18n.ts +++ b/server/src/i18n.ts @@ -1,10 +1,11 @@ -import { defaultI18nGetter, getRawI18n, onI18nReady, setLanguageDetector, setI18nGetter, isCustomLanguageDetector, isCustomI18nGetter } from "facilmap-utils"; +import { defaultI18nGetter, getRawI18n, onI18nReady, setLanguageDetector, setI18nGetter, isCustomLanguageDetector, isCustomI18nGetter, LANG_QUERY, LANG_COOKIE } from "facilmap-utils"; import messagesEn from "./i18n/en"; import messagesDe from "./i18n/de"; import type { i18n } from "i18next"; import type { Domain } from "domain"; -import type { RequestHandler } from "express"; +import { Router } from "express"; import i18nextHttpMiddleware from "i18next-http-middleware"; +import { type Socket as SocketIO } from "socket.io"; const namespace = "facilmap-server"; @@ -29,42 +30,84 @@ onI18nReady((i18n) => { i18n.addResourceBundle("de", namespace, messagesDe); }); -export const i18nMiddleware: RequestHandler[] = [ - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - (req, res, next) => { - i18nextHttpMiddleware.handle(getRawI18n())(req, res, next); - }, - - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - (req, res, next) => { - if ((req as any).i18n) { - if (!process.domain!.facilmap) { - process.domain!.facilmap = {}; - } +export function setRawDomainI18n(i18n: i18n): void { + if (!process.domain) { + throw new Error("Domain is not initialized"); + } - process.domain!.facilmap.i18n = (req as any).i18n; - } + if (!process.domain.facilmap) { + process.domain.facilmap = {}; + } + + process.domain.facilmap.i18n = i18n; +} - next(); +export function getRawDomainI18n(): i18n | undefined { + return process.domain?.facilmap?.i18n; +} + +export const i18nMiddleware = Router(); +i18nMiddleware.use((req, res, next) => { + i18nextHttpMiddleware.handle(getRawI18n())(req, res, next); +}); +i18nMiddleware.use((req, res, next) => { + if ((req as any).i18n) { + setRawDomainI18n(req.i18n); } -]; + + next(); +}); + +export async function handleSocketConnection(socket: SocketIO): Promise { + await new Promise((resolve, reject) => { + const req = { + query: socket.handshake.query, + url: socket.handshake.url, + headers: socket.handshake.headers + } as any; + + const res = { + headers: {}, + setHeader: () => undefined + } as any; + + i18nMiddleware(req, res, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); +} if (!isCustomLanguageDetector) { - setLanguageDetector(i18nextHttpMiddleware.LanguageDetector); + setLanguageDetector(i18nextHttpMiddleware.LanguageDetector, { + order: ["querystring", "cookie", "header"], + lookupQuerystring: LANG_QUERY, + lookupCookie: LANG_COOKIE + }); } if (!isCustomI18nGetter) { setI18nGetter(() => { - if (process.domain?.facilmap?.i18n) { - return process.domain?.facilmap?.i18n; - } else { - return defaultI18nGetter(); - } + return getRawDomainI18n() ?? defaultI18nGetter(); }); } -export function getI18n(): Pick { +export function getI18n(): { + t: i18n["t"]; + changeLanguage: (lang: string) => Promise; +} { return { - t: getRawI18n().getFixedT(null, namespace) + t: getRawI18n().getFixedT(null, namespace), + changeLanguage: async (lang) => { + const i18n = getRawDomainI18n(); + if (!i18n) { + throw new Error("Domain not initialized, refusing to change language for main instance."); + } + + await i18n.changeLanguage(lang); + } }; } \ No newline at end of file diff --git a/server/src/socket/socket-v2.ts b/server/src/socket/socket-v2.ts index 2d5a4d8e..5ab8014d 100644 --- a/server/src/socket/socket-v2.ts +++ b/server/src/socket/socket-v2.ts @@ -113,6 +113,10 @@ export class SocketConnectionV2 extends SocketConnection { return await this.getPadObjects(pad); }, + setLanguage: async (settings) => { + await getI18n().changeLanguage(settings.lang); + }, + updateBbox: async (bbox) => { this.validatePermissions(Writable.READ); diff --git a/server/src/socket/socket.ts b/server/src/socket/socket.ts index 23112c11..900607f4 100644 --- a/server/src/socket/socket.ts +++ b/server/src/socket/socket.ts @@ -6,6 +6,7 @@ import { SocketVersion } from "facilmap-types"; import type { SocketConnection } from "./socket-common"; import { SocketConnectionV1 } from "./socket-v1"; import { SocketConnectionV2 } from "./socket-v2"; +import { handleSocketConnection } from "../i18n.js"; const constructors: Record SocketConnection> = { [SocketVersion.V1]: SocketConnectionV1, @@ -44,6 +45,12 @@ export default class Socket { socket.disconnect(); }); - new constructors[version](socket, this.database); + d.run(async () => { + await handleSocketConnection(socket); + }).then(() => { + new constructors[version](socket, this.database); + }).catch((err) => { + d.emit("error", err); + }); } } diff --git a/types/src/socket/socket-common.ts b/types/src/socket/socket-common.ts index c62129ee..ceac0f24 100644 --- a/types/src/socket/socket-common.ts +++ b/types/src/socket/socket-common.ts @@ -70,3 +70,8 @@ export interface RoutePointsEvent { // socket.io converts undefined to null, so if we send an event as undefined, it will arrive as null export const nullOrUndefinedValidator = z.null().or(z.undefined()).transform((val) => val ?? null); + +export const setLanguageRequestValidator = z.object({ + lang: z.string() +}); +export type SetLanguageRequest = z.infer; \ No newline at end of file diff --git a/types/src/socket/socket-versions.ts b/types/src/socket/socket-versions.ts index 59f51672..9b98c436 100644 --- a/types/src/socket/socket-versions.ts +++ b/types/src/socket/socket-versions.ts @@ -8,7 +8,7 @@ import { type View, viewValidator } from "../view.js"; import type { MultipleEvents } from "../events.js"; import type { SearchResult } from "../searchResult.js"; import * as z from "zod"; -import { findPadsQueryValidator, getPadQueryValidator, type FindPadsResult, type PagedResults, type FindOnMapResult, lineTemplateRequestValidator, lineExportRequestValidator, findQueryValidator, findOnMapQueryValidator, routeExportRequestValidator, type LinePointsEvent, type RoutePointsEvent, nullOrUndefinedValidator, type LineTemplate } from "./socket-common"; +import { findPadsQueryValidator, getPadQueryValidator, type FindPadsResult, type PagedResults, type FindOnMapResult, lineTemplateRequestValidator, lineExportRequestValidator, findQueryValidator, findOnMapQueryValidator, routeExportRequestValidator, type LinePointsEvent, type RoutePointsEvent, nullOrUndefinedValidator, type LineTemplate, setLanguageRequestValidator } from "./socket-common"; import type { HistoryEntry } from "../historyEntry"; export const requestDataValidatorsV2 = { @@ -44,7 +44,8 @@ export const requestDataValidatorsV2 = { editView: viewValidator.update.extend({ id: idValidator }), deleteView: objectWithIdValidator, geoip: nullOrUndefinedValidator, - setPadId: z.string() + setPadId: z.string(), + setLanguage: setLanguageRequestValidator }; export interface ResponseDataMapV2 { @@ -81,6 +82,7 @@ export interface ResponseDataMapV2 { deleteView: View; geoip: Bbox | null; setPadId: MultipleEvents; + setLanguage: void; } export interface MapEventsV2 { diff --git a/utils/src/i18n-utils.ts b/utils/src/i18n-utils.ts index 5951af72..956c0e2f 100644 --- a/utils/src/i18n-utils.ts +++ b/utils/src/i18n-utils.ts @@ -1,14 +1,31 @@ -import i18next, { type Module, type Newable, type i18n } from "i18next"; +import i18next, { type CustomPluginOptions, type Module, type Newable, type i18n } from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; +export const LANGUAGES = { + en: "English", + de: "Deutsch" +}; + +export const DEFAULT_LANGUAGE = "en"; + +export const LANG_COOKIE = "lang"; +export const LANG_QUERY = "lang"; + let hasBeenUsed = false; const onFirstUse: Array<(i18n: i18n) => void> = []; let languageDetector: Module | Newable | undefined = typeof window !== "undefined" ? LanguageDetector : undefined; +let languageDetectorOptions: CustomPluginOptions["detection"] = typeof window !== "undefined" ? { + order: ['querystring', 'cookie', 'navigator'], + lookupQuerystring: LANG_QUERY, + lookupCookie: LANG_COOKIE, + caches: [] +} : undefined; export let isCustomLanguageDetector = false; -export function setLanguageDetector(detector: Module | Newable | undefined): void { +export function setLanguageDetector(detector: Module | Newable | undefined, options?: CustomPluginOptions["detection"]): void { languageDetector = detector; + languageDetectorOptions = options; isCustomLanguageDetector = true; } @@ -23,8 +40,10 @@ export const defaultI18nGetter = (): i18n => { void i18next.init({ initImmediate: false, - ...(languageDetector ? {} : { lng: "en" }), - fallbackLng: "en" + supportedLngs: Object.keys(LANGUAGES), + ...(languageDetector ? {} : { lng: DEFAULT_LANGUAGE }), + fallbackLng: DEFAULT_LANGUAGE, + detection: languageDetectorOptions }); } @@ -63,3 +82,7 @@ export function onI18nReady(callback: (i18n: i18n) => void): void { onFirstUse.push(callback); } } + +export function getCurrentLanguage(): string { + return getRawI18n().language; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index e483a827..1766852a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1031,6 +1031,13 @@ __metadata: languageName: node linkType: hard +"@types/js-cookie@npm:^3.0.6": + version: 3.0.6 + resolution: "@types/js-cookie@npm:3.0.6" + checksum: 272d551687547445cb210213c73e72e0e5d58ad73e2e444a65d688b8ff9425529779ee0cd6492aaa1f070161916d4254ef2b1a76d64179100437f60749d094ef + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.6": version: 21.1.6 resolution: "@types/jsdom@npm:21.1.6" @@ -3670,6 +3677,7 @@ __metadata: "@types/file-saver": ^2.0.7 "@types/hammerjs": ^2.0.45 "@types/jquery": ^3.5.29 + "@types/js-cookie": ^3.0.6 "@types/leaflet": ^1.9.8 "@types/leaflet-mouse-position": ^1.2.4 "@types/leaflet.locatecontrol": ^0.74.4 @@ -3689,6 +3697,7 @@ __metadata: happy-dom: ^13.6.2 i18next: ^23.10.1 jquery: ^3.7.1 + js-cookie: ^3.0.5 leaflet: ^1.9.4 leaflet-draggable-lines: ^2.0.0 leaflet-graphicscale: ^0.0.4 @@ -5020,6 +5029,13 @@ __metadata: languageName: node linkType: hard +"js-cookie@npm:^3.0.5": + version: 3.0.5 + resolution: "js-cookie@npm:3.0.5" + checksum: 2dbd2809c6180fbcf060c6957cb82dbb47edae0ead6bd71cbeedf448aa6b6923115003b995f7d3e3077bfe2cb76295ea6b584eb7196cca8ba0a09f389f64967a + languageName: node + linkType: hard + "js-tokens@npm:^8.0.2": version: 8.0.3 resolution: "js-tokens@npm:8.0.3"