@naturalcycles/js-lib 14.255.0 → 14.257.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cfg/frontend/tsconfig.json +67 -0
- package/dist/bot.d.ts +60 -0
- package/dist/bot.js +129 -0
- package/dist/browser/adminService.d.ts +69 -0
- package/dist/browser/adminService.js +98 -0
- package/dist/browser/analytics.util.d.ts +12 -0
- package/dist/browser/analytics.util.js +59 -0
- package/dist/browser/i18n/fetchTranslationLoader.d.ts +13 -0
- package/dist/browser/i18n/fetchTranslationLoader.js +17 -0
- package/dist/browser/i18n/translation.service.d.ts +53 -0
- package/dist/browser/i18n/translation.service.js +61 -0
- package/dist/browser/imageFitter.d.ts +60 -0
- package/dist/browser/imageFitter.js +69 -0
- package/dist/browser/script.util.d.ts +14 -0
- package/dist/browser/script.util.js +50 -0
- package/dist/browser/topbar.d.ts +23 -0
- package/dist/browser/topbar.js +137 -0
- package/dist/decorators/memo.util.d.ts +2 -1
- package/dist/decorators/memo.util.js +8 -6
- package/dist/decorators/swarmSafe.decorator.d.ts +9 -0
- package/dist/decorators/swarmSafe.decorator.js +42 -0
- package/dist/error/assert.d.ts +2 -1
- package/dist/error/assert.js +15 -13
- package/dist/error/error.util.js +9 -6
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/zod/zod.util.d.ts +1 -1
- package/dist-esm/bot.js +125 -0
- package/dist-esm/browser/adminService.js +94 -0
- package/dist-esm/browser/analytics.util.js +54 -0
- package/dist-esm/browser/i18n/fetchTranslationLoader.js +13 -0
- package/dist-esm/browser/i18n/translation.service.js +56 -0
- package/dist-esm/browser/imageFitter.js +65 -0
- package/dist-esm/browser/script.util.js +46 -0
- package/dist-esm/browser/topbar.js +134 -0
- package/dist-esm/decorators/memo.util.js +3 -1
- package/dist-esm/decorators/swarmSafe.decorator.js +38 -0
- package/dist-esm/error/assert.js +3 -1
- package/dist-esm/error/error.util.js +4 -1
- package/dist-esm/index.js +8 -0
- package/package.json +2 -1
- package/src/bot.ts +155 -0
- package/src/browser/adminService.ts +157 -0
- package/src/browser/analytics.util.ts +68 -0
- package/src/browser/i18n/fetchTranslationLoader.ts +16 -0
- package/src/browser/i18n/translation.service.ts +102 -0
- package/src/browser/imageFitter.ts +128 -0
- package/src/browser/script.util.ts +52 -0
- package/src/browser/topbar.ts +147 -0
- package/src/datetime/localDate.ts +16 -0
- package/src/datetime/localTime.ts +39 -0
- package/src/decorators/debounce.ts +1 -0
- package/src/decorators/memo.util.ts +4 -1
- package/src/decorators/swarmSafe.decorator.ts +47 -0
- package/src/error/assert.ts +5 -11
- package/src/error/error.util.ts +4 -1
- package/src/index.ts +8 -0
- package/src/json-schema/jsonSchemaBuilder.ts +20 -0
- package/src/semver.ts +2 -0
- package/src/zod/zod.util.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naturalcycles/js-lib",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.257.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"prepare": "husky",
|
|
6
6
|
"build": "dev-lib build-esm-cjs",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"files": [
|
|
37
37
|
"dist",
|
|
38
38
|
"dist-esm",
|
|
39
|
+
"cfg",
|
|
39
40
|
"src",
|
|
40
41
|
"!src/test",
|
|
41
42
|
"!src/**/*.test.*",
|
package/src/bot.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Relevant material:
|
|
2
|
+
// https://deviceandbrowserinfo.com/learning_zone/articles/detecting-headless-chrome-puppeteer-2024
|
|
3
|
+
|
|
4
|
+
import { isServerSide } from './env'
|
|
5
|
+
|
|
6
|
+
export interface BotDetectionServiceCfg {
|
|
7
|
+
/**
|
|
8
|
+
* Defaults to false.
|
|
9
|
+
* If true - the instance will memoize (remember) the results of the detection
|
|
10
|
+
* and won't re-run it.
|
|
11
|
+
*/
|
|
12
|
+
memoizeResults?: boolean
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Defaults to false.
|
|
16
|
+
* If set to true: `getBotReason()` would return BotReason.CDP if CDP is detected.
|
|
17
|
+
* Otherwise - `getBotReason()` will not perform the CDP check.
|
|
18
|
+
*/
|
|
19
|
+
treatCDPAsBotReason?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Service to detect bots and CDP (Chrome DevTools Protocol).
|
|
24
|
+
*
|
|
25
|
+
* @experimental
|
|
26
|
+
*/
|
|
27
|
+
export class BotDetectionService {
|
|
28
|
+
constructor(public cfg: BotDetectionServiceCfg = {}) {}
|
|
29
|
+
|
|
30
|
+
// memoized results
|
|
31
|
+
private botReason: BotReason | null | undefined
|
|
32
|
+
private cdp: boolean | undefined
|
|
33
|
+
|
|
34
|
+
isBotOrCDP(): boolean {
|
|
35
|
+
return !!this.getBotReason() || this.isCDP()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
isBot(): boolean {
|
|
39
|
+
return !!this.getBotReason()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Returns null if it's not a Bot,
|
|
44
|
+
* otherwise a truthy BotReason.
|
|
45
|
+
*/
|
|
46
|
+
getBotReason(): BotReason | null {
|
|
47
|
+
if (this.cfg.memoizeResults && this.botReason !== undefined) {
|
|
48
|
+
return this.botReason
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.botReason = this.detectBotReason()
|
|
52
|
+
return this.botReason
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private detectBotReason(): BotReason | null {
|
|
56
|
+
// SSR - not a bot
|
|
57
|
+
if (isServerSide()) return null
|
|
58
|
+
const { navigator } = globalThis
|
|
59
|
+
if (!navigator) return BotReason.NoNavigator
|
|
60
|
+
const { userAgent } = navigator
|
|
61
|
+
if (!userAgent) return BotReason.NoUserAgent
|
|
62
|
+
|
|
63
|
+
if (/bot|headless|electron|phantom|slimer/i.test(userAgent)) {
|
|
64
|
+
return BotReason.UserAgent
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (navigator.webdriver) {
|
|
68
|
+
return BotReason.WebDriver
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Kirill: commented out, as it's no longer seems reliable,
|
|
72
|
+
// e.g generates false positives with latest Android clients (e.g. Chrome 129)
|
|
73
|
+
// if (navigator.plugins?.length === 0) {
|
|
74
|
+
// return BotReason.ZeroPlugins // Headless Chrome
|
|
75
|
+
// }
|
|
76
|
+
|
|
77
|
+
if ((navigator.languages as any) === '') {
|
|
78
|
+
return BotReason.EmptyLanguages // Headless Chrome
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// isChrome is true if the browser is Chrome, Chromium or Opera
|
|
82
|
+
// this is "the chrome test" from https://intoli.com/blog/not-possible-to-block-chrome-headless/
|
|
83
|
+
// this property is for some reason not present by default in headless chrome
|
|
84
|
+
// Kirill: criterium removed due to false positives with Android
|
|
85
|
+
// if (userAgent.includes('Chrome') && !(globalThis as any).chrome) {
|
|
86
|
+
// return BotReason.ChromeWithoutChrome // Headless Chrome
|
|
87
|
+
// }
|
|
88
|
+
|
|
89
|
+
if (this.cfg.treatCDPAsBotReason && this.detectCDP()) {
|
|
90
|
+
return BotReason.CDP
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* CDP stands for Chrome DevTools Protocol.
|
|
98
|
+
* This function tests if the current environment is a CDP environment.
|
|
99
|
+
* If it's true - it's one of:
|
|
100
|
+
*
|
|
101
|
+
* 1. Bot, automated with CDP, e.g Puppeteer, Playwright or such.
|
|
102
|
+
* 2. Developer with Chrome DevTools open.
|
|
103
|
+
*
|
|
104
|
+
* 2 is certainly not a bot, but unfortunately we can't distinguish between the two.
|
|
105
|
+
* That's why this function is not part of `isBot()`, because it can give "false positive" with DevTools.
|
|
106
|
+
*
|
|
107
|
+
* Based on: https://deviceandbrowserinfo.com/learning_zone/articles/detecting-headless-chrome-puppeteer-2024
|
|
108
|
+
*/
|
|
109
|
+
isCDP(): boolean {
|
|
110
|
+
if (this.cfg.memoizeResults && this.cdp !== undefined) {
|
|
111
|
+
return this.cdp
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.cdp = this.detectCDP()
|
|
115
|
+
return this.cdp
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private detectCDP(): boolean {
|
|
119
|
+
if (isServerSide()) return false
|
|
120
|
+
let cdpCheck1 = false
|
|
121
|
+
try {
|
|
122
|
+
/* eslint-disable */
|
|
123
|
+
// biome-ignore lint/suspicious/useErrorMessage: ok
|
|
124
|
+
const e = new window.Error()
|
|
125
|
+
window.Object.defineProperty(e, 'stack', {
|
|
126
|
+
configurable: false,
|
|
127
|
+
enumerable: false,
|
|
128
|
+
// biome-ignore lint/complexity/useArrowFunction: ok
|
|
129
|
+
get: function () {
|
|
130
|
+
cdpCheck1 = true
|
|
131
|
+
return ''
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
// This is part of the detection and shouldn't be deleted
|
|
135
|
+
window.console.debug(e)
|
|
136
|
+
/* eslint-enable */
|
|
137
|
+
} catch {}
|
|
138
|
+
return cdpCheck1
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export enum BotReason {
|
|
143
|
+
NoNavigator = 1,
|
|
144
|
+
NoUserAgent = 2,
|
|
145
|
+
UserAgent = 3,
|
|
146
|
+
WebDriver = 4,
|
|
147
|
+
// ZeroPlugins = 5,
|
|
148
|
+
EmptyLanguages = 6,
|
|
149
|
+
// ChromeWithoutChrome = 7,
|
|
150
|
+
/**
|
|
151
|
+
* This is when CDP is considered to be a reason to be a Bot.
|
|
152
|
+
* By default it's not.
|
|
153
|
+
*/
|
|
154
|
+
CDP = 8,
|
|
155
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { _Memo } from '../decorators/memo.decorator'
|
|
2
|
+
import { isServerSide } from '../env'
|
|
3
|
+
import { _stringify } from '../string/stringify'
|
|
4
|
+
import { Promisable } from '../typeFest'
|
|
5
|
+
|
|
6
|
+
export interface AdminModeCfg {
|
|
7
|
+
/**
|
|
8
|
+
* Function (predicate) to detect if needed keys are pressed.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* predicate: e => e.ctrlKey && e.key === 'L'
|
|
12
|
+
*
|
|
13
|
+
* @default
|
|
14
|
+
* Detects Ctrl+Shift+L
|
|
15
|
+
*/
|
|
16
|
+
predicate?: (e: KeyboardEvent) => boolean
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Called when RedDot is clicked. Implies that AdminMode is enabled.
|
|
20
|
+
*/
|
|
21
|
+
onRedDotClick?: () => any
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Called when AdminMode was changed.
|
|
25
|
+
*/
|
|
26
|
+
onChange?: (adminMode: boolean) => any
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Called BEFORE entering AdminMode.
|
|
30
|
+
* Serves as a predicate that can cancel entering AdminMode if false is returned.
|
|
31
|
+
* Return true to allow.
|
|
32
|
+
* Function is awaited before proceeding.
|
|
33
|
+
*/
|
|
34
|
+
beforeEnter?: () => Promisable<boolean>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Called BEFORE exiting AdminMode.
|
|
38
|
+
* Serves as a predicate that can cancel exiting AdminMode if false is returned.
|
|
39
|
+
* Return true to allow.
|
|
40
|
+
* Function is awaited before proceeding.
|
|
41
|
+
*/
|
|
42
|
+
beforeExit?: () => Promisable<boolean>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @default true
|
|
46
|
+
* If true - it will "persist" the adminMode state in LocalStorage
|
|
47
|
+
*/
|
|
48
|
+
persistToLocalStorage?: boolean
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The key for LocalStorage persistence.
|
|
52
|
+
*
|
|
53
|
+
* @default '__adminMode__'
|
|
54
|
+
*/
|
|
55
|
+
localStorageKey?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const RED_DOT_ID = '__red-dot__'
|
|
59
|
+
const NOOP = (): void => {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @experimental
|
|
63
|
+
*
|
|
64
|
+
* Allows to listen for AdminMode keypress combination (Ctrl+Shift+L by default) to toggle AdminMode,
|
|
65
|
+
* indicated by RedDot DOM element.
|
|
66
|
+
*
|
|
67
|
+
* todo: help with Authentication
|
|
68
|
+
*/
|
|
69
|
+
export class AdminService {
|
|
70
|
+
constructor(cfg?: AdminModeCfg) {
|
|
71
|
+
this.cfg = {
|
|
72
|
+
predicate: e => e.ctrlKey && e.key === 'L',
|
|
73
|
+
persistToLocalStorage: true,
|
|
74
|
+
localStorageKey: '__adminMode__',
|
|
75
|
+
onRedDotClick: NOOP,
|
|
76
|
+
onChange: NOOP,
|
|
77
|
+
beforeEnter: () => true,
|
|
78
|
+
beforeExit: () => true,
|
|
79
|
+
...cfg,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
cfg: Required<AdminModeCfg>
|
|
84
|
+
|
|
85
|
+
adminMode = false
|
|
86
|
+
|
|
87
|
+
private listening = false
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Start listening to keyboard events to toggle AdminMode when detected.
|
|
91
|
+
*/
|
|
92
|
+
startListening(): void {
|
|
93
|
+
if (this.listening || isServerSide()) return
|
|
94
|
+
|
|
95
|
+
this.adminMode = !!localStorage.getItem(this.cfg.localStorageKey)
|
|
96
|
+
|
|
97
|
+
if (this.adminMode) this.toggleRedDotVisibility()
|
|
98
|
+
|
|
99
|
+
document.addEventListener('keydown', this.keydownListener.bind(this), { passive: true })
|
|
100
|
+
|
|
101
|
+
this.listening = true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
stopListening(): void {
|
|
105
|
+
if (isServerSide()) return
|
|
106
|
+
document.removeEventListener('keydown', this.keydownListener)
|
|
107
|
+
this.listening = false
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private async keydownListener(e: KeyboardEvent): Promise<void> {
|
|
111
|
+
// console.log(e)
|
|
112
|
+
if (!this.cfg.predicate(e)) return
|
|
113
|
+
await this.toggleRedDot()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async toggleRedDot(): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
const allow = await this.cfg[this.adminMode ? 'beforeExit' : 'beforeEnter']()
|
|
119
|
+
if (!allow) return // no change
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(err)
|
|
122
|
+
// ok to show alert to Admins, it's not user-facing
|
|
123
|
+
alert(_stringify(err))
|
|
124
|
+
return // treat as "not allowed"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.adminMode = !this.adminMode
|
|
128
|
+
|
|
129
|
+
this.toggleRedDotVisibility()
|
|
130
|
+
|
|
131
|
+
if (this.cfg.persistToLocalStorage) {
|
|
132
|
+
const { localStorageKey } = this.cfg
|
|
133
|
+
if (this.adminMode) {
|
|
134
|
+
localStorage.setItem(localStorageKey, '1')
|
|
135
|
+
} else {
|
|
136
|
+
localStorage.removeItem(localStorageKey)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.cfg.onChange(this.adminMode)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private toggleRedDotVisibility(): void {
|
|
144
|
+
this.getRedDotElement().style.display = this.adminMode ? 'block' : 'none'
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
@_Memo()
|
|
148
|
+
private getRedDotElement(): HTMLElement {
|
|
149
|
+
const el = document.createElement('div')
|
|
150
|
+
el.id = RED_DOT_ID
|
|
151
|
+
el.style.cssText =
|
|
152
|
+
'position:fixed;width:24px;height:24px;margin-top:-12px;background-color:red;opacity:0.5;top:50%;left:0;z-index:9999999;cursor:pointer;border-radius:0 3px 3px 0'
|
|
153
|
+
el.addEventListener('click', () => this.cfg.onRedDotClick())
|
|
154
|
+
document.body.append(el)
|
|
155
|
+
return el
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isServerSide } from '@naturalcycles/js-lib'
|
|
2
|
+
import { loadScript } from './script.util'
|
|
3
|
+
|
|
4
|
+
declare global {
|
|
5
|
+
interface Window {
|
|
6
|
+
dataLayer: any[]
|
|
7
|
+
gtag: (...args: any[]) => void
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* eslint-disable unicorn/prefer-global-this */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pass enabled = false to only init window.gtag, but not load actual gtag script (e.g in dev mode).
|
|
15
|
+
*/
|
|
16
|
+
export async function loadGTag(gtagId: string, enabled = true): Promise<void> {
|
|
17
|
+
if (isServerSide()) return
|
|
18
|
+
|
|
19
|
+
window.dataLayer ||= []
|
|
20
|
+
window.gtag ||= function gtag() {
|
|
21
|
+
// biome-ignore lint/complexity/useArrowFunction: ok
|
|
22
|
+
// biome-ignore lint/style/noArguments: ok
|
|
23
|
+
window.dataLayer.push(arguments)
|
|
24
|
+
}
|
|
25
|
+
window.gtag('js', new Date())
|
|
26
|
+
window.gtag('config', gtagId)
|
|
27
|
+
|
|
28
|
+
if (!enabled) return
|
|
29
|
+
|
|
30
|
+
await loadScript(`https://www.googletagmanager.com/gtag/js?id=${gtagId}`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function loadGTM(gtmId: string, enabled = true): Promise<void> {
|
|
34
|
+
if (isServerSide()) return
|
|
35
|
+
|
|
36
|
+
window.dataLayer ||= []
|
|
37
|
+
window.dataLayer.push({
|
|
38
|
+
'gtm.start': Date.now(),
|
|
39
|
+
event: 'gtm.js',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (!enabled) return
|
|
43
|
+
|
|
44
|
+
await loadScript(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function loadHotjar(hjid: number): void {
|
|
48
|
+
if (isServerSide()) return
|
|
49
|
+
|
|
50
|
+
/* eslint-disable */
|
|
51
|
+
// prettier-ignore
|
|
52
|
+
;
|
|
53
|
+
;((h: any, o, t, j, a?: any, r?: any) => {
|
|
54
|
+
h.hj =
|
|
55
|
+
h.hj ||
|
|
56
|
+
function hj() {
|
|
57
|
+
// biome-ignore lint/style/noArguments: ok
|
|
58
|
+
;(h.hj.q = h.hj.q || []).push(arguments)
|
|
59
|
+
}
|
|
60
|
+
h._hjSettings = { hjid, hjsv: 6 }
|
|
61
|
+
a = o.querySelectorAll('head')[0]
|
|
62
|
+
r = o.createElement('script')
|
|
63
|
+
r.async = 1
|
|
64
|
+
r.src = t + h._hjSettings.hjid + j + h._hjSettings.hjsv
|
|
65
|
+
a.append(r)
|
|
66
|
+
})(window, document, 'https://static.hotjar.com/c/hotjar-', '.js?sv=')
|
|
67
|
+
/* eslint-enable */
|
|
68
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Fetcher } from '../../http/fetcher'
|
|
2
|
+
import { StringMap } from '../../types'
|
|
3
|
+
import { TranslationLoader } from './translation.service'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Use `baseUrl` to prefix your language files.
|
|
7
|
+
* Example URL structure:
|
|
8
|
+
* ${baseUrl}/${locale}.json
|
|
9
|
+
*/
|
|
10
|
+
export class FetchTranslationLoader implements TranslationLoader {
|
|
11
|
+
constructor(public fetcher: Fetcher) {}
|
|
12
|
+
|
|
13
|
+
async load(locale: string): Promise<StringMap> {
|
|
14
|
+
return await this.fetcher.get(`${locale}.json`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { pMap } from '../../promise/pMap'
|
|
2
|
+
import { StringMap } from '../../types'
|
|
3
|
+
|
|
4
|
+
export type MissingTranslationHandler = (key: string, params?: StringMap<any>) => string
|
|
5
|
+
|
|
6
|
+
export const defaultMissingTranslationHandler: MissingTranslationHandler = key => {
|
|
7
|
+
console.warn(`[tr] missing: ${key}`)
|
|
8
|
+
return `[${key}]`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TranslationServiceCfg {
|
|
12
|
+
defaultLocale: string
|
|
13
|
+
supportedLocales: string[]
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* It is allowed to set it later. Will default to `defaultLocale` in that case.
|
|
17
|
+
*/
|
|
18
|
+
currentLocale?: string
|
|
19
|
+
|
|
20
|
+
translationLoader: TranslationLoader
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Defaults to `defaultMissingTranslationHandler` that returns `[${key}]` and emits console warning.
|
|
24
|
+
*/
|
|
25
|
+
missingTranslationHandler?: MissingTranslationHandler
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TranslationServiceCfgComplete extends TranslationServiceCfg {
|
|
29
|
+
missingTranslationHandler: MissingTranslationHandler // non-optional
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TranslationLoader {
|
|
33
|
+
load: (locale: string) => Promise<StringMap>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class TranslationService {
|
|
37
|
+
constructor(cfg: TranslationServiceCfg, preloadedLocales: StringMap<StringMap> = {}) {
|
|
38
|
+
this.cfg = {
|
|
39
|
+
...cfg,
|
|
40
|
+
missingTranslationHandler: defaultMissingTranslationHandler,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.locales = {
|
|
44
|
+
...preloadedLocales,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.currentLocale = cfg.currentLocale || cfg.defaultLocale
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
cfg: TranslationServiceCfgComplete
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Cache of loaded locales
|
|
54
|
+
*/
|
|
55
|
+
locales: StringMap<StringMap>
|
|
56
|
+
|
|
57
|
+
currentLocale: string
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Manually set locale data, bypassing the TranslationLoader.
|
|
61
|
+
*/
|
|
62
|
+
setLocale(localeName: string, locale: StringMap): void {
|
|
63
|
+
this.locales[localeName] = locale
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
getLocale(locale: string): StringMap | undefined {
|
|
67
|
+
return this.locales[locale]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Loads locale(s) (if not already cached) via configured TranslationLoader.
|
|
72
|
+
* Resolves promise when done (ready to be used).
|
|
73
|
+
*/
|
|
74
|
+
async loadLocale(locale: string | string[]): Promise<void> {
|
|
75
|
+
const locales = Array.isArray(locale) ? locale : [locale]
|
|
76
|
+
|
|
77
|
+
await pMap(locales, async locale => {
|
|
78
|
+
if (this.locales[locale]) return // already loaded
|
|
79
|
+
this.locales[locale] = await this.cfg.translationLoader.load(locale)
|
|
80
|
+
// console.log(`[tr] locale loaded: ${locale}`)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Will invoke `missingTranslationHandler` on missing tranlation.
|
|
86
|
+
*
|
|
87
|
+
* Does NOT do any locale loading. The locale needs to be loaded beforehand:
|
|
88
|
+
* either pre-loaded and passed to the constructor,
|
|
89
|
+
* or `await loadLocale(locale)`.
|
|
90
|
+
*/
|
|
91
|
+
translate(key: string, params?: StringMap): string {
|
|
92
|
+
return this.translateIfExists(key, params) || this.cfg.missingTranslationHandler(key, params)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Does NOT invoke `missingTranslationHandler`, returns `undefined` instead.
|
|
97
|
+
*/
|
|
98
|
+
translateIfExists(key: string, _params?: StringMap): string | undefined {
|
|
99
|
+
// todo: support params
|
|
100
|
+
return this.locales[this.currentLocale]?.[key] || this.locales[this.cfg.defaultLocale]?.[key]
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export interface FitImagesCfg {
|
|
2
|
+
/**
|
|
3
|
+
* Container of the images
|
|
4
|
+
*/
|
|
5
|
+
containerElement: HTMLElement
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Array of image metadatas (most notably: aspectRatio).
|
|
9
|
+
*/
|
|
10
|
+
images: FitImage[]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Will be called on each layout change.
|
|
14
|
+
* Should be listened to to update the width/height of the images in your DOM.
|
|
15
|
+
*/
|
|
16
|
+
onChange: (images: FitImage[]) => any
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Max image height in pixels.
|
|
20
|
+
*
|
|
21
|
+
* @default 300
|
|
22
|
+
*/
|
|
23
|
+
maxHeight?: number
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Margin between images.
|
|
27
|
+
*
|
|
28
|
+
* @default 8
|
|
29
|
+
*/
|
|
30
|
+
margin?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface FitImage {
|
|
34
|
+
src: string
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* width divided by height
|
|
38
|
+
*/
|
|
39
|
+
aspectRatio: number
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Calculated image width to fit the layout.
|
|
43
|
+
*/
|
|
44
|
+
fitWidth?: number
|
|
45
|
+
/**
|
|
46
|
+
* Calculated image height to fit the layout.
|
|
47
|
+
*/
|
|
48
|
+
fitHeight?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Calculates the width/height of the images to fit in the layout.
|
|
53
|
+
*
|
|
54
|
+
* Currently does not mutate the cfg.images array, but DOES mutate individual images with .fitWidth, .fitHeight properties.
|
|
55
|
+
*
|
|
56
|
+
* @experimental
|
|
57
|
+
*/
|
|
58
|
+
export class ImageFitter {
|
|
59
|
+
constructor(cfg: FitImagesCfg) {
|
|
60
|
+
this.cfg = {
|
|
61
|
+
maxHeight: 300,
|
|
62
|
+
margin: 8,
|
|
63
|
+
...cfg,
|
|
64
|
+
}
|
|
65
|
+
this.resizeObserver = new ResizeObserver(entries => this.update(entries))
|
|
66
|
+
this.resizeObserver.observe(cfg.containerElement)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
cfg!: Required<FitImagesCfg>
|
|
70
|
+
resizeObserver: ResizeObserver
|
|
71
|
+
containerWidth = -1
|
|
72
|
+
|
|
73
|
+
stop(): void {
|
|
74
|
+
this.resizeObserver.disconnect()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private update(entries: ResizeObserverEntry[]): void {
|
|
78
|
+
const width = Math.floor(entries[0]!.contentRect.width)
|
|
79
|
+
if (width === this.containerWidth) return // we're only interested in width changes
|
|
80
|
+
this.containerWidth = width
|
|
81
|
+
|
|
82
|
+
console.log(`resize ${width}`)
|
|
83
|
+
this.doLayout(this.cfg.images)
|
|
84
|
+
this.cfg.onChange(this.cfg.images)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private doLayout(imgs: readonly FitImage[]): void {
|
|
88
|
+
if (imgs.length === 0) return // nothing to do
|
|
89
|
+
const { maxHeight } = this.cfg
|
|
90
|
+
|
|
91
|
+
let imgNodes = imgs.slice(0)
|
|
92
|
+
|
|
93
|
+
w: while (imgNodes.length > 0) {
|
|
94
|
+
let slice: FitImage[]
|
|
95
|
+
let h: number
|
|
96
|
+
|
|
97
|
+
for (let i = 1; i <= imgNodes.length; i++) {
|
|
98
|
+
slice = imgNodes.slice(0, i)
|
|
99
|
+
h = this.getHeigth(slice)
|
|
100
|
+
|
|
101
|
+
if (h < maxHeight) {
|
|
102
|
+
this.setHeight(slice, h)
|
|
103
|
+
imgNodes = imgNodes.slice(i)
|
|
104
|
+
continue w
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
this.setHeight(slice!, Math.min(maxHeight, h!))
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private getHeigth(images: readonly FitImage[]): number {
|
|
114
|
+
const width = this.containerWidth - images.length * this.cfg.margin
|
|
115
|
+
let r = 0
|
|
116
|
+
images.forEach(img => (r += img.aspectRatio))
|
|
117
|
+
|
|
118
|
+
return width / r // have to round down because Firefox will automatically roundup value with number of decimals > 3
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// mutates/sets images' fitWidth, fitHeight properties
|
|
122
|
+
private setHeight(images: readonly FitImage[], height: number): void {
|
|
123
|
+
images.forEach(img => {
|
|
124
|
+
img.fitWidth = Math.floor(height * img.aspectRatio)
|
|
125
|
+
img.fitHeight = Math.floor(height)
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { isServerSide } from '../env'
|
|
2
|
+
import { _objectAssign } from '../types'
|
|
3
|
+
|
|
4
|
+
export type LoadScriptOptions = Partial<HTMLScriptElement>
|
|
5
|
+
export type LoadCSSOptions = Partial<HTMLLinkElement>
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* opt.async defaults to `true`.
|
|
9
|
+
* No other options are set by default.
|
|
10
|
+
*/
|
|
11
|
+
export async function loadScript(src: string, opt?: LoadScriptOptions): Promise<void> {
|
|
12
|
+
if (isServerSide()) return
|
|
13
|
+
|
|
14
|
+
return await new Promise<void>((resolve, reject) => {
|
|
15
|
+
const s = _objectAssign(document.createElement('script'), {
|
|
16
|
+
src,
|
|
17
|
+
async: true,
|
|
18
|
+
...opt,
|
|
19
|
+
onload: resolve as any,
|
|
20
|
+
onerror: (_event, _source, _lineno, _colno, err) => {
|
|
21
|
+
reject(err || new Error(`loadScript failed: ${src}`))
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
document.head.append(s)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Default options:
|
|
30
|
+
* rel: 'stylesheet'
|
|
31
|
+
*
|
|
32
|
+
* No other options are set by default.
|
|
33
|
+
*/
|
|
34
|
+
export async function loadCSS(href: string, opt?: LoadCSSOptions): Promise<void> {
|
|
35
|
+
if (isServerSide()) return
|
|
36
|
+
|
|
37
|
+
return await new Promise<void>((resolve, reject) => {
|
|
38
|
+
const link = _objectAssign(document.createElement('link'), {
|
|
39
|
+
href,
|
|
40
|
+
rel: 'stylesheet',
|
|
41
|
+
// type seems to be unnecessary: https://stackoverflow.com/a/5409146/4919972
|
|
42
|
+
// type: 'text/css',
|
|
43
|
+
...opt,
|
|
44
|
+
onload: resolve as any,
|
|
45
|
+
onerror: (_event, _source, _lineno, _colno, err) => {
|
|
46
|
+
reject(err || new Error(`loadCSS failed: ${href}`))
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
document.head.append(link)
|
|
51
|
+
})
|
|
52
|
+
}
|