diff --git a/app/views/base/embed.scala b/app/views/base/embed.scala
index 6a7ccf2e72b4..8105206ac529 100644
--- a/app/views/base/embed.scala
+++ b/app/views/base/embed.scala
@@ -21,7 +21,6 @@ object embed:
(ctx.bg == "system").option(page.ui.systemThemeScript(ctx.nonce.some)),
page.ui.pieceSprite(ctx.pieceSet.name),
cssTag("common.theme.embed"),
- link(rel := "stylesheet", href := assetUrl("css/theme/font-face.css")),
cssKeys.map(cssTag),
page.ui.scriptsPreload(modules.flatMap(_.map(_.key)))
),
@@ -67,7 +66,6 @@ object embed:
(ctx.bg == "system").option(page.ui.systemThemeScript(ctx.nonce.some)),
page.ui.pieceSprite(ctx.pieceSet.name),
cssTag("common.theme.embed"),
- link(rel := "stylesheet", href := assetUrl("css/theme/font-face.css")),
cssKeys.map(cssTag),
page.ui.sitePreload(
List[I18nModule.Selector](_.site, _.timeago) ++ i18nModules,
diff --git a/app/views/base/page.scala b/app/views/base/page.scala
index 5ca055bc2cb3..97f5f37afcb2 100644
--- a/app/views/base/page.scala
+++ b/app/views/base/page.scala
@@ -21,10 +21,10 @@ object page:
raw(s"""""")
private def boardPreload(using ctx: Context) = frag(
- preload(staticAssetUrl(s"images/board/${ctx.pref.currentTheme.file}"), "image", crossorigin = false),
+ preload(assetUrl(s"images/board/${ctx.pref.currentTheme.file}"), "image", crossorigin = false),
ctx.pref.is3d.option(
preload(
- staticAssetUrl(s"images/staunton/board/${ctx.pref.currentTheme3d.file}"),
+ assetUrl(s"images/staunton/board/${ctx.pref.currentTheme3d.file}"),
"image",
crossorigin = false
)
@@ -61,7 +61,6 @@ object page:
else s"${ctx.me.so(_.username.value + " ")} $prodTitle"
,
cssTag("common.theme.all"),
- link(rel := "stylesheet", href := assetUrl("css/theme/font-face.css")),
cssTag("site"),
pref.is3d.option(cssTag("common.board-3d")),
ctx.data.inquiry.isDefined.option(cssTag("mod.inquiry")),
@@ -81,8 +80,10 @@ object page:
noTranslate,
p.openGraph.map(lila.web.ui.openGraph),
p.atomLinkTag | dailyNewsAtom,
- (pref.bg == lila.pref.Pref.Bg.TRANSPARENT).option(pref.bgImgOrDefault).map { img =>
- val url = escapeHtmlRaw(img).replace("&", "&")
+ (pref.bg == lila.pref.Pref.Bg.TRANSPARENT).option(pref.bgImgOrDefault).map { loc =>
+ val url =
+ if loc.startsWith("/assets/") then assetUrl(loc.drop(8))
+ else escapeHtmlRaw(loc).replace("&", "&")
raw(s"""""")
},
fontPreload,
diff --git a/modules/web/src/main/AssetManifest.scala b/modules/web/src/main/AssetManifest.scala
index 23517126055c..3719a485d277 100644
--- a/modules/web/src/main/AssetManifest.scala
+++ b/modules/web/src/main/AssetManifest.scala
@@ -101,7 +101,7 @@ final class AssetManifest(environment: Environment, net: NetConfig, ws: Standalo
.map { (k, asset) =>
val hash = (asset \ "hash").as[String]
val name = k.substring(k.lastIndexOf('/') + 1)
- val extPos = name.indexOf('.')
+ val extPos = name.lastIndexOf('.')
val hashedName =
if extPos < 0 then s"${name}.$hash"
else s"${name.slice(0, extPos)}.$hash${name.substring(extPos)}"
diff --git a/package.json b/package.json
index 4fff639a25fc..5c1cf04c4e5b 100644
--- a/package.json
+++ b/package.json
@@ -24,14 +24,14 @@
},
"dependencies": {
"@types/lichess": "workspace:*",
- "@types/web": "^0.0.194",
- "@typescript-eslint/eslint-plugin": "^8.20.0",
- "@typescript-eslint/parser": "^8.20.0",
+ "@types/web": "^0.0.201",
+ "@typescript-eslint/eslint-plugin": "^8.23.0",
+ "@typescript-eslint/parser": "^8.23.0",
"ab": "github:lichess-org/ab-stub",
"chessground": "^9.1.1",
"chessops": "^0.14.2",
- "eslint": "^9.18.0",
- "lint-staged": "^15.3.0",
+ "eslint": "^9.20.0",
+ "lint-staged": "^15.4.3",
"onchange": "^7.1.0",
"prettier": "^3.4.2",
"snabbdom": "3.5.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1ca77c6af9a9..3fa8fec47eaa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,14 +12,14 @@ importers:
specifier: workspace:*
version: link:ui/@types/lichess
'@types/web':
- specifier: ^0.0.194
- version: 0.0.194
+ specifier: ^0.0.201
+ version: 0.0.201
'@typescript-eslint/eslint-plugin':
- specifier: ^8.20.0
- version: 8.20.0(@typescript-eslint/parser@8.20.0(eslint@9.18.0)(typescript@5.7.3))(eslint@9.18.0)(typescript@5.7.3)
+ specifier: ^8.23.0
+ version: 8.23.0(@typescript-eslint/parser@8.23.0(eslint@9.20.0)(typescript@5.7.3))(eslint@9.20.0)(typescript@5.7.3)
'@typescript-eslint/parser':
- specifier: ^8.20.0
- version: 8.20.0(eslint@9.18.0)(typescript@5.7.3)
+ specifier: ^8.23.0
+ version: 8.23.0(eslint@9.20.0)(typescript@5.7.3)
ab:
specifier: github:lichess-org/ab-stub
version: https://codeload.github.com/lichess-org/ab-stub/tar.gz/94236bf34dbc9c05daf50f4c9842d859b9142be0
@@ -30,11 +30,11 @@ importers:
specifier: ^0.14.2
version: 0.14.2
eslint:
- specifier: ^9.18.0
- version: 9.18.0
+ specifier: ^9.20.0
+ version: 9.20.0
lint-staged:
- specifier: ^15.3.0
- version: 15.3.0
+ specifier: ^15.4.3
+ version: 15.4.3
onchange:
specifier: ^7.1.0
version: 7.1.0
@@ -49,7 +49,7 @@ importers:
version: 5.7.3
vitest:
specifier: ^3.0.5
- version: 3.0.5(@types/node@22.10.6)(jsdom@26.0.0)(yaml@2.6.1)
+ version: 3.0.5(@types/node@22.10.6)(jsdom@26.0.0)(yaml@2.7.0)
bin:
dependencies:
@@ -908,12 +908,16 @@ packages:
resolution: {integrity: sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/core@0.11.0':
+ resolution: {integrity: sha512-DWUB2pksgNEb6Bz2fggIy1wh6fGgZP4Xyy/Mt0QZPiloKKXerbqq9D3SBQTlCRYOrcRPu4vuz+CGjwdfqxnoWA==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@eslint/eslintrc@3.2.0':
resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@eslint/js@9.18.0':
- resolution: {integrity: sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==}
+ '@eslint/js@9.20.0':
+ resolution: {integrity: sha512-iZA07H9io9Wn836aVTytRaNqh00Sad+EamwOVJT12GTLw1VGMFV/4JaME+JjLtr9fiGaoWgYnS54wrfWsSs4oQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.4':
@@ -1135,8 +1139,8 @@ packages:
'@types/sortablejs@1.15.8':
resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==}
- '@types/web@0.0.194':
- resolution: {integrity: sha512-VKseTFF3Y8SNbpZqdVFNWQ677ujwNyrI9LcySEUwZX5iebbcdE235Lq/vqrfCzj1oFsXyVUUBqq4x8enXSakMA==}
+ '@types/web@0.0.201':
+ resolution: {integrity: sha512-pGg1tHPiGUxPB+T3ROqqvYAf/X52c3doP8IDOOyYlZhwE9XsuK5slPu9BJvZufHWonp4tE4eIILdnyD8dFVpkQ==}
'@types/webrtc@0.0.44':
resolution: {integrity: sha512-4BJZdzrApNFeuXgucyqs24k69f7oti3wUcGEbFbaV08QBh7yEe3tnRRuYXlyXJNXiumpZujiZqUZZ2/gMSeO0g==}
@@ -1147,51 +1151,51 @@ packages:
'@types/zxcvbn@4.4.5':
resolution: {integrity: sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==}
- '@typescript-eslint/eslint-plugin@8.20.0':
- resolution: {integrity: sha512-naduuphVw5StFfqp4Gq4WhIBE2gN1GEmMUExpJYknZJdRnc+2gDzB8Z3+5+/Kv33hPQRDGzQO/0opHE72lZZ6A==}
+ '@typescript-eslint/eslint-plugin@8.23.0':
+ resolution: {integrity: sha512-vBz65tJgRrA1Q5gWlRfvoH+w943dq9K1p1yDBY2pc+a1nbBLZp7fB9+Hk8DaALUbzjqlMfgaqlVPT1REJdkt/w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/parser@8.20.0':
- resolution: {integrity: sha512-gKXG7A5HMyjDIedBi6bUrDcun8GIjnI8qOwVLiY3rx6T/sHP/19XLJOnIq/FgQvWLHja5JN/LSE7eklNBr612g==}
+ '@typescript-eslint/parser@8.23.0':
+ resolution: {integrity: sha512-h2lUByouOXFAlMec2mILeELUbME5SZRN/7R9Cw2RD2lRQQY08MWMM+PmVVKKJNK1aIwqTo9t/0CvOxwPbRIE2Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/scope-manager@8.20.0':
- resolution: {integrity: sha512-J7+VkpeGzhOt3FeG1+SzhiMj9NzGD/M6KoGn9f4dbz3YzK9hvbhVTmLj/HiTp9DazIzJ8B4XcM80LrR9Dm1rJw==}
+ '@typescript-eslint/scope-manager@8.23.0':
+ resolution: {integrity: sha512-OGqo7+dXHqI7Hfm+WqkZjKjsiRtFUQHPdGMXzk5mYXhJUedO7e/Y7i8AK3MyLMgZR93TX4bIzYrfyVjLC+0VSw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/type-utils@8.20.0':
- resolution: {integrity: sha512-bPC+j71GGvA7rVNAHAtOjbVXbLN5PkwqMvy1cwGeaxUoRQXVuKCebRoLzm+IPW/NtFFpstn1ummSIasD5t60GA==}
+ '@typescript-eslint/type-utils@8.23.0':
+ resolution: {integrity: sha512-iIuLdYpQWZKbiH+RkCGc6iu+VwscP5rCtQ1lyQ7TYuKLrcZoeJVpcLiG8DliXVkUxirW/PWlmS+d6yD51L9jvA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/types@8.20.0':
- resolution: {integrity: sha512-cqaMiY72CkP+2xZRrFt3ExRBu0WmVitN/rYPZErA80mHjHx/Svgp8yfbzkJmDoQ/whcytOPO9/IZXnOc+wigRA==}
+ '@typescript-eslint/types@8.23.0':
+ resolution: {integrity: sha512-1sK4ILJbCmZOTt9k4vkoulT6/y5CHJ1qUYxqpF1K/DBAd8+ZUL4LlSCxOssuH5m4rUaaN0uS0HlVPvd45zjduQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- '@typescript-eslint/typescript-estree@8.20.0':
- resolution: {integrity: sha512-Y7ncuy78bJqHI35NwzWol8E0X7XkRVS4K4P4TCyzWkOJih5NDvtoRDW4Ba9YJJoB2igm9yXDdYI/+fkiiAxPzA==}
+ '@typescript-eslint/typescript-estree@8.23.0':
+ resolution: {integrity: sha512-LcqzfipsB8RTvH8FX24W4UUFk1bl+0yTOf9ZA08XngFwMg4Kj8A+9hwz8Cr/ZS4KwHrmo9PJiLZkOt49vPnuvQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/utils@8.20.0':
- resolution: {integrity: sha512-dq70RUw6UK9ei7vxc4KQtBRk7qkHZv447OUZ6RPQMQl71I3NZxQJX/f32Smr+iqWrB02pHKn2yAdHBb0KNrRMA==}
+ '@typescript-eslint/utils@8.23.0':
+ resolution: {integrity: sha512-uB/+PSo6Exu02b5ZEiVtmY6RVYO7YU5xqgzTIVZwTHvvK3HsL8tZZHFaTLFtRG3CsV4A5mhOv+NZx5BlhXPyIA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <5.8.0'
- '@typescript-eslint/visitor-keys@8.20.0':
- resolution: {integrity: sha512-v/BpkeeYAsPkKCkR8BDwcno0llhzWVqPOamQrAEMdpZav2Y9OVjd9dwJyBLJWwf335B5DmlifECIkZRJCaGaHA==}
+ '@typescript-eslint/visitor-keys@8.23.0':
+ resolution: {integrity: sha512-oWWhcWDLwDfu++BGTZcmXWqpwtkwb5o7fxUIGksMQQDSdPW9prsSnfIOZMlsj4vBOSrcnjIUZMiIjODgGosFhQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@vitest/expect@3.0.5':
@@ -1397,8 +1401,8 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
- commander@12.1.0:
- resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
+ commander@13.1.0:
+ resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
concat-map@0.0.1:
@@ -1516,8 +1520,8 @@ packages:
resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
- eslint@9.18.0:
- resolution: {integrity: sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==}
+ eslint@9.20.0:
+ resolution: {integrity: sha512-aL4F8167Hg4IvsW89ejnpTwx+B/UQRzJPGgbIOl+4XqffWsahVVsLEWoZvnrVuwpWmnRd7XeXmQI1zlKcFDteA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
peerDependencies:
@@ -1768,8 +1772,8 @@ packages:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
- lint-staged@15.3.0:
- resolution: {integrity: sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==}
+ lint-staged@15.4.3:
+ resolution: {integrity: sha512-FoH1vOeouNh1pw+90S+cnuoFwRfUD9ijY2GKy5h7HS3OR7JVir2N2xrsa0+Twc1B7cW72L+88geG5cW4wIhn7g==}
engines: {node: '>=18.12.0'}
hasBin: true
@@ -2252,8 +2256,8 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
- ts-api-utils@2.0.0:
- resolution: {integrity: sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==}
+ ts-api-utils@2.0.1:
+ resolution: {integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
@@ -2433,8 +2437,8 @@ packages:
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
- yaml@2.6.1:
- resolution: {integrity: sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==}
+ yaml@2.7.0:
+ resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==}
engines: {node: '>= 14'}
hasBin: true
@@ -2574,9 +2578,9 @@ snapshots:
'@esbuild/win32-x64@0.24.2':
optional: true
- '@eslint-community/eslint-utils@4.4.1(eslint@9.18.0)':
+ '@eslint-community/eslint-utils@4.4.1(eslint@9.20.0)':
dependencies:
- eslint: 9.18.0
+ eslint: 9.20.0
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
@@ -2593,6 +2597,10 @@ snapshots:
dependencies:
'@types/json-schema': 7.0.15
+ '@eslint/core@0.11.0':
+ dependencies:
+ '@types/json-schema': 7.0.15
+
'@eslint/eslintrc@3.2.0':
dependencies:
ajv: 6.12.6
@@ -2607,7 +2615,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@eslint/js@9.18.0': {}
+ '@eslint/js@9.20.0': {}
'@eslint/object-schema@2.1.4': {}
@@ -2785,7 +2793,7 @@ snapshots:
'@types/sortablejs@1.15.8': {}
- '@types/web@0.0.194': {}
+ '@types/web@0.0.201': {}
'@types/webrtc@0.0.44': {}
@@ -2795,81 +2803,81 @@ snapshots:
'@types/zxcvbn@4.4.5': {}
- '@typescript-eslint/eslint-plugin@8.20.0(@typescript-eslint/parser@8.20.0(eslint@9.18.0)(typescript@5.7.3))(eslint@9.18.0)(typescript@5.7.3)':
+ '@typescript-eslint/eslint-plugin@8.23.0(@typescript-eslint/parser@8.23.0(eslint@9.20.0)(typescript@5.7.3))(eslint@9.20.0)(typescript@5.7.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
- '@typescript-eslint/parser': 8.20.0(eslint@9.18.0)(typescript@5.7.3)
- '@typescript-eslint/scope-manager': 8.20.0
- '@typescript-eslint/type-utils': 8.20.0(eslint@9.18.0)(typescript@5.7.3)
- '@typescript-eslint/utils': 8.20.0(eslint@9.18.0)(typescript@5.7.3)
- '@typescript-eslint/visitor-keys': 8.20.0
- eslint: 9.18.0
+ '@typescript-eslint/parser': 8.23.0(eslint@9.20.0)(typescript@5.7.3)
+ '@typescript-eslint/scope-manager': 8.23.0
+ '@typescript-eslint/type-utils': 8.23.0(eslint@9.20.0)(typescript@5.7.3)
+ '@typescript-eslint/utils': 8.23.0(eslint@9.20.0)(typescript@5.7.3)
+ '@typescript-eslint/visitor-keys': 8.23.0
+ eslint: 9.20.0
graphemer: 1.4.0
ignore: 5.3.2
natural-compare: 1.4.0
- ts-api-utils: 2.0.0(typescript@5.7.3)
+ ts-api-utils: 2.0.1(typescript@5.7.3)
typescript: 5.7.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/parser@8.20.0(eslint@9.18.0)(typescript@5.7.3)':
+ '@typescript-eslint/parser@8.23.0(eslint@9.20.0)(typescript@5.7.3)':
dependencies:
- '@typescript-eslint/scope-manager': 8.20.0
- '@typescript-eslint/types': 8.20.0
- '@typescript-eslint/typescript-estree': 8.20.0(typescript@5.7.3)
- '@typescript-eslint/visitor-keys': 8.20.0
+ '@typescript-eslint/scope-manager': 8.23.0
+ '@typescript-eslint/types': 8.23.0
+ '@typescript-eslint/typescript-estree': 8.23.0(typescript@5.7.3)
+ '@typescript-eslint/visitor-keys': 8.23.0
debug: 4.4.0
- eslint: 9.18.0
+ eslint: 9.20.0
typescript: 5.7.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/scope-manager@8.20.0':
+ '@typescript-eslint/scope-manager@8.23.0':
dependencies:
- '@typescript-eslint/types': 8.20.0
- '@typescript-eslint/visitor-keys': 8.20.0
+ '@typescript-eslint/types': 8.23.0
+ '@typescript-eslint/visitor-keys': 8.23.0
- '@typescript-eslint/type-utils@8.20.0(eslint@9.18.0)(typescript@5.7.3)':
+ '@typescript-eslint/type-utils@8.23.0(eslint@9.20.0)(typescript@5.7.3)':
dependencies:
- '@typescript-eslint/typescript-estree': 8.20.0(typescript@5.7.3)
- '@typescript-eslint/utils': 8.20.0(eslint@9.18.0)(typescript@5.7.3)
+ '@typescript-eslint/typescript-estree': 8.23.0(typescript@5.7.3)
+ '@typescript-eslint/utils': 8.23.0(eslint@9.20.0)(typescript@5.7.3)
debug: 4.4.0
- eslint: 9.18.0
- ts-api-utils: 2.0.0(typescript@5.7.3)
+ eslint: 9.20.0
+ ts-api-utils: 2.0.1(typescript@5.7.3)
typescript: 5.7.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/types@8.20.0': {}
+ '@typescript-eslint/types@8.23.0': {}
- '@typescript-eslint/typescript-estree@8.20.0(typescript@5.7.3)':
+ '@typescript-eslint/typescript-estree@8.23.0(typescript@5.7.3)':
dependencies:
- '@typescript-eslint/types': 8.20.0
- '@typescript-eslint/visitor-keys': 8.20.0
+ '@typescript-eslint/types': 8.23.0
+ '@typescript-eslint/visitor-keys': 8.23.0
debug: 4.4.0
fast-glob: 3.3.2
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.6.3
- ts-api-utils: 2.0.0(typescript@5.7.3)
+ ts-api-utils: 2.0.1(typescript@5.7.3)
typescript: 5.7.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/utils@8.20.0(eslint@9.18.0)(typescript@5.7.3)':
+ '@typescript-eslint/utils@8.23.0(eslint@9.20.0)(typescript@5.7.3)':
dependencies:
- '@eslint-community/eslint-utils': 4.4.1(eslint@9.18.0)
- '@typescript-eslint/scope-manager': 8.20.0
- '@typescript-eslint/types': 8.20.0
- '@typescript-eslint/typescript-estree': 8.20.0(typescript@5.7.3)
- eslint: 9.18.0
+ '@eslint-community/eslint-utils': 4.4.1(eslint@9.20.0)
+ '@typescript-eslint/scope-manager': 8.23.0
+ '@typescript-eslint/types': 8.23.0
+ '@typescript-eslint/typescript-estree': 8.23.0(typescript@5.7.3)
+ eslint: 9.20.0
typescript: 5.7.3
transitivePeerDependencies:
- supports-color
- '@typescript-eslint/visitor-keys@8.20.0':
+ '@typescript-eslint/visitor-keys@8.23.0':
dependencies:
- '@typescript-eslint/types': 8.20.0
+ '@typescript-eslint/types': 8.23.0
eslint-visitor-keys: 4.2.0
'@vitest/expect@3.0.5':
@@ -2879,13 +2887,13 @@ snapshots:
chai: 5.1.2
tinyrainbow: 2.0.0
- '@vitest/mocker@3.0.5(vite@6.0.7(@types/node@22.10.6)(yaml@2.6.1))':
+ '@vitest/mocker@3.0.5(vite@6.0.7(@types/node@22.10.6)(yaml@2.7.0))':
dependencies:
'@vitest/spy': 3.0.5
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
- vite: 6.0.7(@types/node@22.10.6)(yaml@2.6.1)
+ vite: 6.0.7(@types/node@22.10.6)(yaml@2.7.0)
'@vitest/pretty-format@3.0.5':
dependencies:
@@ -3081,7 +3089,7 @@ snapshots:
delayed-stream: 1.0.0
optional: true
- commander@12.1.0: {}
+ commander@13.1.0: {}
concat-map@0.0.1: {}
@@ -3193,14 +3201,14 @@ snapshots:
eslint-visitor-keys@4.2.0: {}
- eslint@9.18.0:
+ eslint@9.20.0:
dependencies:
- '@eslint-community/eslint-utils': 4.4.1(eslint@9.18.0)
+ '@eslint-community/eslint-utils': 4.4.1(eslint@9.20.0)
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.19.0
- '@eslint/core': 0.10.0
+ '@eslint/core': 0.11.0
'@eslint/eslintrc': 3.2.0
- '@eslint/js': 9.18.0
+ '@eslint/js': 9.20.0
'@eslint/plugin-kit': 0.2.5
'@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1
@@ -3480,10 +3488,10 @@ snapshots:
lilconfig@3.1.3: {}
- lint-staged@15.3.0:
+ lint-staged@15.4.3:
dependencies:
chalk: 5.4.1
- commander: 12.1.0
+ commander: 13.1.0
debug: 4.4.0
execa: 8.0.1
lilconfig: 3.1.3
@@ -3491,7 +3499,7 @@ snapshots:
micromatch: 4.0.8
pidtree: 0.6.0
string-argv: 0.3.2
- yaml: 2.6.1
+ yaml: 2.7.0
transitivePeerDependencies:
- supports-color
@@ -3957,7 +3965,7 @@ snapshots:
tree-kill@1.2.2: {}
- ts-api-utils@2.0.0(typescript@5.7.3):
+ ts-api-utils@2.0.1(typescript@5.7.3):
dependencies:
typescript: 5.7.3
@@ -3979,13 +3987,13 @@ snapshots:
uuid@9.0.0: {}
- vite-node@3.0.5(@types/node@22.10.6)(yaml@2.6.1):
+ vite-node@3.0.5(@types/node@22.10.6)(yaml@2.7.0):
dependencies:
cac: 6.7.14
debug: 4.4.0
es-module-lexer: 1.6.0
pathe: 2.0.2
- vite: 6.0.7(@types/node@22.10.6)(yaml@2.6.1)
+ vite: 6.0.7(@types/node@22.10.6)(yaml@2.7.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -4000,7 +4008,7 @@ snapshots:
- tsx
- yaml
- vite@6.0.7(@types/node@22.10.6)(yaml@2.6.1):
+ vite@6.0.7(@types/node@22.10.6)(yaml@2.7.0):
dependencies:
esbuild: 0.24.2
postcss: 8.5.1
@@ -4008,12 +4016,12 @@ snapshots:
optionalDependencies:
'@types/node': 22.10.6
fsevents: 2.3.3
- yaml: 2.6.1
+ yaml: 2.7.0
- vitest@3.0.5(@types/node@22.10.6)(jsdom@26.0.0)(yaml@2.6.1):
+ vitest@3.0.5(@types/node@22.10.6)(jsdom@26.0.0)(yaml@2.7.0):
dependencies:
'@vitest/expect': 3.0.5
- '@vitest/mocker': 3.0.5(vite@6.0.7(@types/node@22.10.6)(yaml@2.6.1))
+ '@vitest/mocker': 3.0.5(vite@6.0.7(@types/node@22.10.6)(yaml@2.7.0))
'@vitest/pretty-format': 3.0.5
'@vitest/runner': 3.0.5
'@vitest/snapshot': 3.0.5
@@ -4029,8 +4037,8 @@ snapshots:
tinyexec: 0.3.2
tinypool: 1.0.2
tinyrainbow: 2.0.0
- vite: 6.0.7(@types/node@22.10.6)(yaml@2.6.1)
- vite-node: 3.0.5(@types/node@22.10.6)(yaml@2.6.1)
+ vite: 6.0.7(@types/node@22.10.6)(yaml@2.7.0)
+ vite-node: 3.0.5(@types/node@22.10.6)(yaml@2.7.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.10.6
@@ -4117,7 +4125,7 @@ snapshots:
y18n@4.0.3: {}
- yaml@2.6.1: {}
+ yaml@2.7.0: {}
yargs-parser@18.1.3:
dependencies:
diff --git a/ui/.build/src/build.ts b/ui/.build/src/build.ts
index 6d8b7f7bd4dc..aef393c470a1 100644
--- a/ui/.build/src/build.ts
+++ b/ui/.build/src/build.ts
@@ -15,24 +15,30 @@ import { unique } from './algo.ts';
import { clean } from './clean.ts';
export async function build(pkgs: string[]): Promise {
- env.startTime = Date.now();
+ try {
+ env.startTime = Date.now();
- chdir(env.rootDir);
+ chdir(env.rootDir);
- if (env.install) execSync('pnpm install', { stdio: 'inherit' });
- if (!pkgs.length) env.log(`Parsing packages in '${c.cyan(env.uiDir)}'`);
+ if (env.install) execSync('pnpm install', { stdio: 'inherit' });
+ if (!pkgs.length) env.log(`Parsing packages in '${c.cyan(env.uiDir)}'`);
- await Promise.allSettled([parsePackages(), fs.promises.mkdir(env.buildTempDir)]);
+ await Promise.allSettled([parsePackages(), fs.promises.mkdir(env.buildTempDir)]);
- pkgs
- .filter(x => !env.packages.has(x))
- .forEach(x => env.exit(`${errorMark} - unknown package '${c.magenta(x)}'`));
+ pkgs
+ .filter(x => !env.packages.has(x))
+ .forEach(x => env.exit(`${errorMark} - unknown package '${c.magenta(x)}'`));
- env.building = pkgs.length === 0 ? [...env.packages.values()] : unique(pkgs.flatMap(p => env.deps(p)));
+ env.building = pkgs.length === 0 ? [...env.packages.values()] : unique(pkgs.flatMap(p => env.deps(p)));
- if (pkgs.length) env.log(`Building ${c.grey(env.building.map(x => x.name).join(', '))}`);
+ if (pkgs.length) env.log(`Building ${c.grey(env.building.map(x => x.name).join(', '))}`);
- await Promise.all([i18n(), sync().then(hash), sass(), tsc(), esbuild()]);
+ await Promise.all([i18n(), sync().then(hash).then(sass), tsc(), esbuild()]);
+ } catch (e) {
+ const errorText = `${errorMark} ${e instanceof Error ? (e.stack ?? e.message) : String(e)}`;
+ if (env.watch) env.log(errorText);
+ else env.exit(errorText);
+ }
await monitor(pkgs);
}
diff --git a/ui/.build/src/env.ts b/ui/.build/src/env.ts
index b8f0086f11ad..f8b4388e6b06 100644
--- a/ui/.build/src/env.ts
+++ b/ui/.build/src/env.ts
@@ -1,7 +1,7 @@
-import p from 'node:path';
import type { Package } from './parse.ts';
import fs from 'node:fs';
import ps from 'node:process';
+import { join, resolve, dirname } from 'node:path';
import { unique, isEquivalent, trimLines } from './algo.ts';
import { updateManifest } from './manifest.ts';
import { taskOk } from './task.ts';
@@ -9,23 +9,23 @@ import { taskOk } from './task.ts';
// state, logging, status
export const env = new (class {
- readonly rootDir = p.resolve(p.dirname(new URL(import.meta.url).pathname), '../../..');
- readonly uiDir = p.join(this.rootDir, 'ui');
- readonly outDir = p.join(this.rootDir, 'public');
- readonly cssOutDir = p.join(this.outDir, 'css');
- readonly jsOutDir = p.join(this.outDir, 'compiled');
- readonly hashOutDir = p.join(this.outDir, 'hashed');
- readonly themeDir = p.join(this.uiDir, 'common', 'css', 'theme');
- readonly themeGenDir = p.join(this.themeDir, 'gen');
- readonly buildDir = p.join(this.uiDir, '.build');
- readonly lockFile = p.join(this.buildDir, 'instance.lock');
- readonly buildTempDir = p.join(this.buildDir, 'build');
- readonly cssTempDir = p.join(this.buildTempDir, 'css');
- readonly buildSrcDir = p.join(this.buildDir, 'src');
- readonly typesDir = p.join(this.uiDir, '@types');
- readonly i18nSrcDir = p.join(this.rootDir, 'translation', 'source');
- readonly i18nDestDir = p.join(this.rootDir, 'translation', 'dest');
- readonly i18nJsDir = p.join(this.rootDir, 'translation', 'js');
+ readonly rootDir = resolve(dirname(new URL(import.meta.url).pathname), '../../..');
+ readonly uiDir = join(this.rootDir, 'ui');
+ readonly outDir = join(this.rootDir, 'public');
+ readonly cssOutDir = join(this.outDir, 'css');
+ readonly jsOutDir = join(this.outDir, 'compiled');
+ readonly hashOutDir = join(this.outDir, 'hashed');
+ readonly themeDir = join(this.uiDir, 'common', 'css', 'theme');
+ readonly themeGenDir = join(this.themeDir, 'gen');
+ readonly buildDir = join(this.uiDir, '.build');
+ readonly lockFile = join(this.buildDir, 'instance.lock');
+ readonly buildTempDir = join(this.buildDir, 'build');
+ readonly cssTempDir = join(this.buildTempDir, 'css');
+ readonly buildSrcDir = join(this.buildDir, 'src');
+ readonly typesDir = join(this.uiDir, '@types');
+ readonly i18nSrcDir = join(this.rootDir, 'translation', 'source');
+ readonly i18nDestDir = join(this.rootDir, 'translation', 'dest');
+ readonly i18nJsDir = join(this.rootDir, 'translation', 'js');
watch = false;
clean = false;
@@ -54,7 +54,7 @@ export const env = new (class {
}
get manifestFile(): string {
- return p.join(this.jsOutDir, `manifest.${this.prod ? 'prod' : 'dev'}.json`);
+ return join(this.jsOutDir, `manifest.${this.prod ? 'prod' : 'dev'}.json`);
}
*tasks(
diff --git a/ui/.build/src/esbuild.ts b/ui/.build/src/esbuild.ts
index ab887fb2d6ff..c5bfe0e850b1 100644
--- a/ui/.build/src/esbuild.ts
+++ b/ui/.build/src/esbuild.ts
@@ -1,6 +1,6 @@
-import p from 'node:path';
import es from 'esbuild';
import fs from 'node:fs';
+import { join, basename } from 'node:path';
import { env, errorMark, warnMark, c } from './env.ts';
import { type Manifest, updateManifest } from './manifest.ts';
import { task, stopTask } from './task.ts';
@@ -67,9 +67,9 @@ function inlineTask() {
const inlineToModule: Record = {};
for (const [pkg, bundle] of env.tasks('bundle'))
if (bundle.inline)
- inlineToModule[p.join(pkg.root, bundle.inline)] = bundle.module
- ? p.basename(bundle.module, '.ts')
- : p.basename(bundle.inline, '.inline.ts');
+ inlineToModule[join(pkg.root, bundle.inline)] = bundle.module
+ ? basename(bundle.module, '.ts')
+ : basename(bundle.inline, '.inline.ts');
return task({
key: 'inline',
ctx: 'esbuild',
diff --git a/ui/.build/src/hash.ts b/ui/.build/src/hash.ts
index 9c28701e4aab..57266660a8ef 100644
--- a/ui/.build/src/hash.ts
+++ b/ui/.build/src/hash.ts
@@ -1,10 +1,10 @@
import fs from 'node:fs';
-import p from 'node:path';
import crypto from 'node:crypto';
+import { relative, join, resolve } from 'node:path';
import { task } from './task.ts';
import { type Manifest, updateManifest } from './manifest.ts';
import { env, c } from './env.ts';
-import type { Package } from './parse.ts';
+import { type Package, isClose } from './parse.ts';
import { isEquivalent } from './algo.ts';
export async function hash(): Promise {
@@ -15,14 +15,16 @@ export async function hash(): Promise {
for (const [pkg, { glob, update, ...rest }] of env.tasks('hash')) {
update ? hashRuns.push({ glob, update, pkg, ...rest }) : pathOnly.glob.push(glob);
- env.log(`${c.grey(pkg.name)} '${c.cyan(glob)}' -> '${c.cyan('public/hashed')}'`, 'hash');
+ hashLog(glob, '', pkg?.name);
}
if (pathOnly.glob.length) hashRuns.push(pathOnly);
await fs.promises.mkdir(env.hashOutDir).catch(() => {});
+ const symlinkHashes = await symlinkTargetHashes();
await Promise.all(
hashRuns.map(({ glob, update, pkg }) =>
task({
+ pkg,
ctx: 'hash',
debounce: 300,
root: env.rootDir,
@@ -33,27 +35,23 @@ export async function hash(): Promise {
const shouldLog = !isEquivalent(files, fullList);
await Promise.all(
files.map(async src => {
- const { name, hash } = await hashLink(p.relative(env.outDir, src));
+ const name = relative(env.outDir, src);
+ const hash =
+ symlinkHashes[name] && !(await isLinkStale(hashedBasename(name, symlinkHashes[name])))
+ ? symlinkHashes[name]
+ : await hashAndLink(name);
hashed[name] = { hash };
- if (shouldLog)
- env.log(
- `'${c.cyan(src)}' -> '${c.cyan(p.join('public', 'hashed', asHashed(name, hash)))}'`,
- 'hash',
- );
+ if (shouldLog) hashLog(src, hashedBasename(name, hash), pkg?.name);
}),
);
if (update && pkg?.root) {
const updates: Record = {};
- for (const src of fullList.map(f => p.relative(env.outDir, f))) {
- updates[src] = asHashed(src, hashed[src].hash!);
+ for (const src of fullList.map(f => relative(env.outDir, f))) {
+ updates[src] = hashedBasename(src, hashed[src].hash!);
}
- const { name, hash } = await replaceHash(p.relative(pkg.root, update), pkg.root, updates);
+ const { name, hash } = await replaceHash(relative(pkg.root, update), pkg.root, updates);
hashed[name] = { hash };
- if (shouldLog)
- env.log(
- `${c.grey(pkg.name)} '${c.cyan(name)}' -> '${c.cyan(p.join('public', 'hashed', asHashed(name, hash)))}'`,
- 'hash',
- );
+ if (shouldLog) hashLog(name, hashedBasename(name, hash), pkg.name);
}
updateManifest({ hashed });
},
@@ -62,34 +60,71 @@ export async function hash(): Promise {
);
}
+export async function symlinkTargetHashes(newLinks?: string[]) {
+ const targetHashes = {} as Record;
+ if (newLinks && newLinks.length === 0) return targetHashes;
+
+ await fs.promises.readdir(env.hashOutDir).then(files =>
+ Promise.all(
+ files.map(async symlink => {
+ const [, hash] = symlink.match(/^.+\.([0-9a-f]{8})(?:\.[^\.]*)?$/) ?? [];
+ if (!hash || (newLinks && !newLinks.some(l => l.endsWith(symlink)))) return;
+ const absSymlink = join(env.hashOutDir, symlink);
+ try {
+ const [target, stale] = await Promise.all([fs.promises.readlink(absSymlink), isLinkStale(symlink)]);
+ if (!stale) targetHashes[relative(env.outDir, resolve(env.hashOutDir, target))] = hash;
+ } catch {}
+ }),
+ ),
+ );
+ return targetHashes;
+}
+
+export function hashedBasename(path: string, hash: string) {
+ const name = path.slice(path.lastIndexOf('/') + 1);
+ const extPos = name.lastIndexOf('.');
+ return extPos < 0 ? `${name}.${hash}` : `${name.slice(0, extPos)}.${hash}${name.slice(extPos)}`;
+}
+
+async function isLinkStale(symlink: string | undefined) {
+ if (!symlink) return true;
+ const absSymlink = join(env.hashOutDir, symlink);
+ const [{ mtimeMs: linkMs }, { mtimeMs: targetMs }] = await Promise.all([
+ fs.promises.lstat(absSymlink),
+ fs.promises.stat(absSymlink),
+ ]);
+ return !isClose(linkMs, targetMs);
+}
+
async function replaceHash(name: string, root: string, files: Record) {
const result = Object.entries(files).reduce(
(data, [from, to]) => data.replaceAll(from, to),
- await fs.promises.readFile(p.join(root, name), 'utf8'),
+ await fs.promises.readFile(join(root, name), 'utf8'),
);
const hash = crypto.createHash('sha256').update(result).digest('hex').slice(0, 8);
- await fs.promises.writeFile(p.join(env.hashOutDir, asHashed(name, hash)), result);
+ await fs.promises.writeFile(join(env.hashOutDir, hashedBasename(name, hash)), result);
return { name, hash };
}
-async function hashLink(name: string) {
- const src = p.join(env.outDir, name);
+async function hashAndLink(name: string) {
+ const src = join(env.outDir, name);
const hash = crypto
.createHash('sha256')
.update(await fs.promises.readFile(src))
.digest('hex')
.slice(0, 8);
- await link(name, hash);
- return { name, hash };
+ const link = join(env.hashOutDir, hashedBasename(name, hash));
+ const [{ mtime }] = await Promise.all([
+ fs.promises.stat(join(env.outDir, name)),
+ fs.promises.symlink(relative(env.outDir, name), link).catch(() => {}),
+ ]);
+ await fs.promises.lutimes(link, mtime, mtime);
+ return hash;
}
-async function link(name: string, hash: string) {
- const link = p.join(env.hashOutDir, asHashed(name, hash));
- return fs.promises.symlink(p.join('..', name), link).catch(() => {});
-}
-
-function asHashed(path: string, hash: string) {
- const name = path.slice(path.lastIndexOf('/') + 1);
- const extPos = name.indexOf('.');
- return extPos < 0 ? `${name}.${hash}` : `${name.slice(0, extPos)}.${hash}${name.slice(extPos)}`;
+function hashLog(src: string, hashName: string, pkgName?: string) {
+ env.log(
+ `${pkgName ? c.grey(pkgName) + ' ' : ''}'${c.cyan(src)}' -> '${c.cyan(join('public', 'hashed', hashName))}'`,
+ 'hash',
+ );
}
diff --git a/ui/.build/src/i18n.ts b/ui/.build/src/i18n.ts
index c577e5901921..d8a807ec84b9 100644
--- a/ui/.build/src/i18n.ts
+++ b/ui/.build/src/i18n.ts
@@ -1,13 +1,13 @@
-import p from 'node:path';
import crypto from 'node:crypto';
import fs from 'node:fs';
import fg from 'fast-glob';
+import { join, basename } from 'node:path';
import { XMLParser } from 'fast-xml-parser';
import { env } from './env.ts';
-import { readable } from './parse.ts';
+import { readable, isClose } from './parse.ts';
import { task } from './task.ts';
import { type Manifest, updateManifest } from './manifest.ts';
-import { quantize, zip } from './algo.ts';
+import { zip } from './algo.ts';
import { transform } from 'esbuild';
type Plural = { [key in 'zero' | 'one' | 'two' | 'few' | 'many' | 'other']?: string };
@@ -19,13 +19,13 @@ let dicts: Map = new Map();
let locales: string[];
let cats: string[];
-export async function i18n(): Promise {
- if (!env.begin('i18n')) return;
+export function i18n(): Promise {
+ if (!env.begin('i18n')) return Promise.resolve();
return task({
glob: [
{ cwd: env.i18nSrcDir, path: '*.xml' },
- { cwd: p.join(env.i18nDestDir, 'site'), path: '*.xml' },
+ { cwd: join(env.i18nDestDir, 'site'), path: '*.xml' },
],
ctx: 'i18n',
debounce: 500,
@@ -33,11 +33,11 @@ export async function i18n(): Promise {
env.log(`Building`, 'i18n');
[locales, cats] = (
await Promise.all([
- fg.glob('*.xml', { cwd: p.join(env.i18nDestDir, 'site') }),
+ fg.glob('*.xml', { cwd: join(env.i18nDestDir, 'site') }),
fg.glob('*.xml', { cwd: env.i18nSrcDir }),
])
).map(list => list.map(x => x.split('.')[0]));
- await Promise.allSettled(cats.map(async cat => fs.promises.mkdir(p.join(env.i18nDestDir, cat))));
+ await Promise.allSettled(cats.map(async cat => fs.promises.mkdir(join(env.i18nDestDir, cat))));
await compileTypings();
await compileJavascripts();
await i18nManifest();
@@ -46,7 +46,7 @@ export async function i18n(): Promise {
}
async function compileTypings(): Promise {
- const typingsPathname = p.join(env.typesDir, 'lichess', `i18n.d.ts`);
+ const typingsPathname = join(env.typesDir, 'lichess', `i18n.d.ts`);
const [tstat, catStats] = await Promise.all([
fs.promises.stat(typingsPathname).catch(() => undefined),
Promise.all(cats.map(cat => updated(cat))),
@@ -58,7 +58,7 @@ async function compileTypings(): Promise {
zip(
cats,
await Promise.all(
- cats.map(d => fs.promises.readFile(p.join(env.i18nSrcDir, `${d}.xml`), 'utf8').then(parseXml)),
+ cats.map(d => fs.promises.readFile(join(env.i18nSrcDir, `${d}.xml`), 'utf8').then(parseXml)),
),
),
);
@@ -83,11 +83,8 @@ async function compileTypings(): Promise {
.join('') +
'}\n',
);
- const mstat = catStats.reduce(
- (a, b) => (a && b && quantize(a.mtimeMs, 2000) > quantize(b.mtimeMs, 2000) ? a : b),
- tstat || false,
- );
- if (mstat) await fs.promises.utimes(typingsPathname, mstat.mtime, mstat.mtime);
+ const histat = catStats.reduce((a, b) => (a && b && a.mtimeMs - b.mtimeMs > 2 ? a : b), tstat || false);
+ if (histat) await fs.promises.utimes(typingsPathname, histat.mtime, histat.mtime);
}
}
@@ -106,11 +103,11 @@ function compileJavascripts(): Promise {
async function writeJavascript(cat: string, locale?: string, xstat: fs.Stats | false = false) {
if (!dicts.has(cat))
- dicts.set(cat, await fs.promises.readFile(p.join(env.i18nSrcDir, `${cat}.xml`), 'utf8').then(parseXml));
+ dicts.set(cat, await fs.promises.readFile(join(env.i18nSrcDir, `${cat}.xml`), 'utf8').then(parseXml));
const localeSpecific = locale
? await fs.promises
- .readFile(p.join(env.i18nDestDir, cat, `${locale}.xml`), 'utf-8')
+ .readFile(join(env.i18nDestDir, cat, `${locale}.xml`), 'utf-8')
.catch(() => '')
.then(parseXml)
: new Map();
@@ -142,7 +139,7 @@ async function writeJavascript(cat: string, locale?: string, xstat: fs.Stats | f
.join(';') +
'})()';
- const filename = p.join(env.i18nJsDir, `${cat}.${locale ?? 'en-GB'}.js`);
+ const filename = join(env.i18nJsDir, `${cat}.${locale ?? 'en-GB'}.js`);
await fs.promises.writeFile(filename, code);
if (!xstat) return;
@@ -150,13 +147,11 @@ async function writeJavascript(cat: string, locale?: string, xstat: fs.Stats | f
}
async function updated(cat: string, locale?: string): Promise {
- const xmlPath = locale
- ? p.join(env.i18nDestDir, cat, `${locale}.xml`)
- : p.join(env.i18nSrcDir, `${cat}.xml`);
- const jsPath = p.join(env.i18nJsDir, `${cat}.${locale ?? 'en-GB'}.js`);
+ const xmlPath = locale ? join(env.i18nDestDir, cat, `${locale}.xml`) : join(env.i18nSrcDir, `${cat}.xml`);
+ const jsPath = join(env.i18nJsDir, `${cat}.${locale ?? 'en-GB'}.js`);
const [xml, js] = await Promise.allSettled([fs.promises.stat(xmlPath), fs.promises.stat(jsPath)]);
return xml.status === 'rejected' ||
- (js.status !== 'rejected' && quantize(xml.value.mtimeMs, 2000) <= quantize(js.value.mtimeMs, 2000))
+ (js.status !== 'rejected' && isClose(xml.value.mtimeMs, js.value.mtimeMs))
? false
: xml.value.size > 64 && xml.value;
}
@@ -185,14 +180,14 @@ async function min(js: string): Promise {
export async function i18nManifest(): Promise {
const i18n: Manifest = {};
- fs.mkdirSync(p.join(env.jsOutDir, 'i18n'), { recursive: true });
+ fs.mkdirSync(join(env.jsOutDir, 'i18n'), { recursive: true });
await Promise.all(
(await fg.glob('*.js', { cwd: env.i18nJsDir, absolute: true })).map(async file => {
- const name = `i18n/${p.basename(file, '.js')}`;
+ const name = `i18n/${basename(file, '.js')}`;
const content = await fs.promises.readFile(file, 'utf-8');
const hash = crypto.createHash('md5').update(content).digest('hex').slice(0, 12);
- const destPath = p.join(env.jsOutDir, `${name}.${hash}.js`);
+ const destPath = join(env.jsOutDir, `${name}.${hash}.js`);
i18n[name] = { hash };
diff --git a/ui/.build/src/manifest.ts b/ui/.build/src/manifest.ts
index 77b89eddd47f..7f0608ee6a07 100644
--- a/ui/.build/src/manifest.ts
+++ b/ui/.build/src/manifest.ts
@@ -1,7 +1,7 @@
import cps from 'node:child_process';
-import p from 'node:path';
import fs from 'node:fs';
import crypto from 'node:crypto';
+import { join } from 'node:path';
import { env, c } from './env.ts';
import { jsLogger } from './console.ts';
import { taskOk } from './task.ts';
@@ -93,8 +93,8 @@ async function writeManifest() {
env.prod ? undefined : 2,
);
await Promise.all([
- fs.promises.writeFile(p.join(env.jsOutDir, `manifest.${hash}.js`), clientManifest),
- fs.promises.writeFile(p.join(env.jsOutDir, `manifest.${env.prod ? 'prod' : 'dev'}.json`), serverManifest),
+ fs.promises.writeFile(join(env.jsOutDir, `manifest.${hash}.js`), clientManifest),
+ fs.promises.writeFile(join(env.jsOutDir, `manifest.${env.prod ? 'prod' : 'dev'}.json`), serverManifest),
]);
manifest.dirty = false;
const serverHash = crypto.createHash('sha256').update(serverManifest).digest('hex').slice(0, 8);
diff --git a/ui/.build/src/parse.ts b/ui/.build/src/parse.ts
index 7d71a8863615..65e3f2a4b066 100644
--- a/ui/.build/src/parse.ts
+++ b/ui/.build/src/parse.ts
@@ -1,5 +1,5 @@
import fs from 'node:fs';
-import p from 'node:path';
+import { dirname, join, basename } from 'node:path';
import fg from 'fast-glob';
import { env } from './env.ts';
@@ -28,7 +28,7 @@ interface Sync {
}
export async function parsePackages(): Promise {
- for (const dir of (await glob('ui/[^@.]*/package.json')).map(pkg => p.dirname(pkg))) {
+ for (const dir of (await glob('ui/[^@.]*/package.json')).map(pkg => dirname(pkg))) {
const pkgInfo = await parsePackage(dir);
env.packages.set(pkgInfo.name, pkgInfo);
}
@@ -58,7 +58,7 @@ export async function folderSize(folder: string): Promise {
const sizes = await Promise.all(
entries.map(async entry => {
- const fullPath = p.join(dir, entry.name);
+ const fullPath = join(dir, entry.name);
if (entry.isDirectory()) return getSize(fullPath);
if (entry.isFile()) return (await fs.promises.stat(fullPath)).size;
return 0;
@@ -81,7 +81,7 @@ export async function subfolders(folder: string, depth = 1): Promise {
if (depth > 0)
await Promise.all(
(await fs.promises.readdir(folder).catch(() => [])).map(f =>
- fs.promises.stat(p.join(folder, f)).then(s => s.isDirectory() && folders.push(p.join(folder, f))),
+ fs.promises.stat(join(folder, f)).then(s => s.isDirectory() && folders.push(join(folder, f))),
),
);
return folders;
@@ -98,10 +98,14 @@ export function isGlob(path: string): boolean {
return /[*?!{}[\]()]/.test(path);
}
+export function isClose(a: number | undefined, b: number | undefined, epsilon = 2) {
+ return a === b || Math.abs((a ?? NaN) - (b ?? NaN)) < epsilon; // for mtimeMs jitter
+}
+
async function parsePackage(root: string): Promise {
const pkgInfo: Package = {
- pkg: JSON.parse(await fs.promises.readFile(p.join(root, 'package.json'), 'utf8')),
- name: p.basename(root),
+ pkg: JSON.parse(await fs.promises.readFile(join(root, 'package.json'), 'utf8')),
+ name: basename(root),
root,
bundle: [],
sync: [],
@@ -111,7 +115,7 @@ async function parsePackage(root: string): Promise {
const build = pkgInfo.pkg.build;
// 'hash' and 'sync' paths beginning with '/' are repo relative, otherwise they are package relative
- const normalize = (file: string) => (file[0] === '/' ? file.slice(1) : p.join('ui', pkgInfo.name, file));
+ const normalize = (file: string) => (file[0] === '/' ? file.slice(1) : join('ui', pkgInfo.name, file));
const normalizeObject = >(o: T) =>
Object.fromEntries(Object.entries(o).map(([k, v]) => [k, typeof v === 'string' ? normalize(v) : v]));
diff --git a/ui/.build/src/sass.ts b/ui/.build/src/sass.ts
index 49780804053e..909727eb2456 100644
--- a/ui/.build/src/sass.ts
+++ b/ui/.build/src/sass.ts
@@ -1,14 +1,15 @@
import cps from 'node:child_process';
import fs from 'node:fs';
import ps from 'node:process';
-import p from 'node:path';
import crypto from 'node:crypto';
+import { join, relative, basename, dirname, resolve } from 'node:path';
import clr from 'tinycolor2';
import { env, c, errorMark } from './env.ts';
import { readable, glob } from './parse.ts';
import { task } from './task.ts';
import { updateManifest } from './manifest.ts';
import { clamp, isEquivalent, trimLines } from './algo.ts';
+import { symlinkTargetHashes, hashedBasename } from './hash.ts';
const importMap = new Map>();
const colorMixMap = new Map();
@@ -25,56 +26,61 @@ export function stopSass(): void {
themeColorMap.clear();
}
-export async function sass(): Promise {
+export async function sass(): Promise {
if (!env.begin('sass')) return;
await Promise.allSettled([
fs.promises.mkdir(env.cssOutDir),
fs.promises.mkdir(env.themeGenDir),
- fs.promises.mkdir(p.join(env.buildTempDir, 'css')),
+ fs.promises.mkdir(join(env.buildTempDir, 'css')),
]);
let remaining: Set;
return task({
ctx: 'sass',
- glob: { cwd: env.uiDir, path: '*/css/**/*.scss' },
+ glob: [
+ { cwd: env.uiDir, path: '*/css/**/*.scss' },
+ { cwd: env.hashOutDir, path: '*' },
+ ],
debounce: 300,
root: env.rootDir,
execute: async (modified, fullList) => {
const concreteAll = new Set(fullList.filter(isConcrete));
const partialTouched = modified.filter(isPartial);
- const transitiveTouched = partialTouched.flatMap(p => [...importersOf(p)]);
+ const urlTargetTouched = await sourcesWithUrls(modified.filter(isUrlTarget));
+ const transitiveTouched = [...partialTouched, ...urlTargetTouched].flatMap(p => [...dependsOn(p)]);
const concreteTouched = [...new Set([...transitiveTouched, ...modified])].filter(isConcrete);
remaining = remaining
? new Set([...remaining, ...concreteTouched].filter(x => concreteAll.has(x)))
: concreteAll;
- if (modified.some(src => src.startsWith('ui/common/css/theme/_'))) {
+ if (partialTouched.some(src => src.startsWith('ui/common/css/theme/_'))) {
await parseThemeColorDefs();
}
const oldMixes = Object.fromEntries(colorMixMap);
-
const processed = new Set();
await Promise.all(concreteTouched.map(src => parseScss(src, processed)));
if (!isEquivalent(oldMixes, Object.fromEntries(colorMixMap))) {
await buildColorMixes();
await buildColorWrap();
- remaining.add('ui/common/css/build/common.theme.all.scss');
- remaining.add('ui/common/css/build/common.theme.embed.scss');
+ for (const src of await glob('common.theme.*.scss', { cwd: 'ui/common/css/build' }))
+ remaining.add(relative(env.rootDir, src)); // TODO test me
}
+ const buildSources = [...remaining];
+ remaining = new Set(await compile(buildSources, remaining.size < concreteAll.size));
- remaining = new Set(await compile([...remaining], remaining.size < concreteAll.size));
+ if (remaining.size) return;
- if (!remaining.size)
- updateManifest({
- css: Object.fromEntries(
- await Promise.all((await glob(p.join(env.cssTempDir, '*.css'))).map(hashMoveCss)),
- ),
- });
+ const replacements = urlReplacements();
+ updateManifest({
+ css: Object.fromEntries(
+ await Promise.all(buildSources.map(async scss => hashCss(absTempCss(scss), replacements[scss]))),
+ ),
+ });
},
});
}
@@ -84,12 +90,12 @@ async function compile(sources: string[], logAll = true): Promise {
const sassBin =
process.env.SASS_PATH ??
(await fs.promises.realpath(
- p.join(env.buildDir, 'node_modules', `sass-embedded-${ps.platform}-${ps.arch}`, 'dart-sass', 'sass'),
+ join(env.buildDir, 'node_modules', `sass-embedded-${ps.platform}-${ps.arch}`, 'dart-sass', 'sass'),
));
if (!(await readable(sassBin))) env.exit(`Sass executable not found '${c.cyan(sassBin)}'`, 'sass');
- return new Promise(resolve => {
- if (!sources.length) return resolve([]);
+ return new Promise(resolveWithErrors => {
+ if (!sources.length) return resolveWithErrors([]);
if (logAll) sources.forEach(src => env.log(`Building '${c.cyan(src)}'`, 'sass'));
else env.log('Building', 'sass');
@@ -99,52 +105,60 @@ async function compile(sources: string[], logAll = true): Promise {
sassBin,
sassArgs.concat(
env.prod ? ['--style=compressed', '--no-source-map'] : ['--embed-sources'],
- sources.map(
- (src: string) => `${src}:${p.join(env.cssTempDir, p.basename(src).replace(/(.*)scss$/, '$1css'))}`,
- ),
+ sources.map((src: string) => `${src}:${absTempCss(src)}`),
),
);
sassPs.stderr?.on('data', (buf: Buffer) => sassError(buf.toString('utf8')));
sassPs.stdout?.on('data', (buf: Buffer) => sassError(buf.toString('utf8')));
sassPs.on('close', async (code: number) => {
- if (code === 0) resolve([]);
+ if (code === 0) resolveWithErrors([]);
else
- failed(sources)
- .then(resolve)
- .catch(() => resolve(sources));
+ Promise.all(sources.filter(scss => !readable(absTempCss(scss))))
+ .then(resolveWithErrors)
+ .catch(() => resolveWithErrors(sources));
});
});
}
// recursively parse scss file and its imports to build dependency and color maps
async function parseScss(src: string, processed: Set) {
- if (p.dirname(src).endsWith('/gen')) return;
+ if (dirname(src).endsWith('/gen')) return;
if (processed.has(src)) return;
processed.add(src);
const text = await fs.promises.readFile(src, 'utf8');
- for (const match of text.matchAll(/\$m-([-_a-z0-9]+)/g)) {
- const [str, mix] = [match[1], parseColor(match[1])];
- if (!mix) {
- env.log(`${errorMark} Invalid color mix: '${c.magenta(str)}' in '${c.cyan(src)}'`, 'sass');
+ for (const [, mixName] of text.matchAll(/\$m-([-_a-z0-9]+)/g)) {
+ const mixColor = parseColor(mixName);
+ if (!mixColor) {
+ env.log(`${errorMark} Invalid color mix: '${c.magenta(mixName)}' in '${c.cyan(src)}'`, 'sass');
continue;
}
- colorMixMap.set(str, mix);
+ colorMixMap.set(mixName, mixColor);
+ }
+
+ for (const [, scssUrl] of text.matchAll(/[^a-zA-Z0-9\-_]url\((?:['"])?(\.\.\/[^'")]+)/g)) {
+ const url = scssUrl.replaceAll(/#\{[^}]+\}/g, '*'); // scss interpolation -> glob
+
+ if (url.includes('*')) {
+ for (const file of await glob(url, { cwd: env.cssOutDir, absolute: false })) {
+ if (!importMap.get(file)?.add(src)) importMap.set(file, new Set([src]));
+ }
+ } else if (!importMap.get(url)?.add(src)) importMap.set(url, new Set([src]));
}
- for (const match of text.matchAll(/^@(?:import|use)\s+['"](.*)['"]/gm)) {
- if (match.length !== 2) continue;
+ for (const [, cssImport] of text.matchAll(/^@(?:import|use)\s+['"](.*)['"]/gm)) {
+ if (!cssImport) continue;
- const absDep = (await readable(p.resolve(p.dirname(src), match[1]) + '.scss'))
- ? p.resolve(p.dirname(src), match[1] + '.scss')
- : p.resolve(p.dirname(src), resolvePartial(match[1]));
+ const absDep = (await readable(resolve(dirname(src), cssImport + '.scss')))
+ ? resolve(dirname(src), cssImport + '.scss')
+ : resolve(dirname(src), resolvePartial(cssImport));
if (/node_modules.*\.css/.test(absDep)) continue;
- else if (!absDep.startsWith(env.uiDir)) throw `Bad import '${match[1]}`;
+ else if (!absDep.startsWith(env.uiDir)) throw `Bad import '${cssImport}`;
- const dep = p.relative(env.rootDir, absDep);
+ const dep = relative(env.rootDir, absDep);
if (!importMap.get(dep)?.add(src)) importMap.set(dep, new Set([src]));
await parseScss(dep, processed);
}
@@ -193,7 +207,7 @@ async function parseThemeColorDefs() {
// given color definitions and mix instructions, build mixed color css variables in themed scss mixins
async function buildColorMixes() {
- const out = fs.createWriteStream(p.join(env.themeGenDir, '_mix.scss'));
+ const out = fs.createWriteStream(join(env.themeGenDir, '_mix.scss'));
for (const theme of themeColorMap.keys()) {
const colorMap = themeColorMap.get(theme)!;
out.write(`@mixin ${theme}-mix {\n`);
@@ -226,7 +240,7 @@ async function buildColorWrap() {
const cssVars = new Set();
for (const color of colorMixMap.keys()) cssVars.add(`m-${color}`);
- for (const file of await glob(p.join(env.themeDir, '_*.scss'))) {
+ for (const file of await glob(join(env.themeDir, '_*.scss'))) {
for (const line of (await fs.promises.readFile(file, 'utf8')).split('\n')) {
if (line.indexOf('--') === -1) continue;
const commentIndex = line.indexOf('//');
@@ -241,8 +255,8 @@ async function buildColorWrap() {
.map(variable => `$${variable}: var(--${variable});`)
.join('\n') + '\n';
- const wrapFile = p.join(env.themeDir, 'gen', '_wrap.scss');
- await fs.promises.mkdir(p.dirname(wrapFile), { recursive: true });
+ const wrapFile = join(env.themeDir, 'gen', '_wrap.scss');
+ await fs.promises.mkdir(dirname(wrapFile), { recursive: true });
if (await readable(wrapFile)) {
if ((await fs.promises.readFile(wrapFile, 'utf8')) === scssWrap) return; // don't touch wrap if no changes
}
@@ -264,43 +278,60 @@ function parseColor(colorMix: string) {
: undefined;
}
-async function failed(attempted: string[]): Promise {
- return Promise.all(
- attempted.filter(src => !readable(p.join(env.cssTempDir, `${p.basename(src, '.scss')}.css`))),
- );
-}
+async function hashCss(src: string, replacements: Record | undefined) {
+ let content = await fs.promises.readFile(src, 'utf-8');
+ let modified = false;
-function isConcrete(src: string) {
- return !p.basename(src).startsWith('_');
+ for (const [search, replace] of Object.entries(replacements ?? {})) {
+ content = content.replaceAll(search, replace);
+ modified = true;
+ }
+ const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 8);
+ const baseName = basename(src, '.css');
+ const outName = join(env.cssOutDir, `${baseName}.${hash}.css`);
+ await Promise.allSettled([
+ env.prod ? undefined : fs.promises.rename(`${src}.map`, `${join(env.cssOutDir, baseName)}.css.map`),
+ modified
+ ? fs.promises.writeFile(outName, content).then(() => fs.promises.unlink(src))
+ : fs.promises.rename(src, outName),
+ ]);
+ return [baseName, { hash }];
}
-function isPartial(src: string) {
- return p.basename(src).startsWith('_');
+async function sourcesWithUrls(urls: string[]) {
+ const importers = new Set();
+ for (const [target, hash] of Object.entries(await symlinkTargetHashes(urls))) {
+ const url = relative(env.hashOutDir, join(env.outDir, target));
+ const depSet = importMap.get(url) ?? new Set();
+ depSet.delete([...depSet].find(f => f.startsWith('public/hashed/')) ?? '');
+ depSet.forEach(i => importers.add(i));
+ depSet.add(join('public/hashed', hashedBasename(url, hash)));
+ importMap.set(url, depSet);
+ }
+ return [...importers];
}
-function resolvePartial(partial: string): string {
- const nameBegin = partial.lastIndexOf(p.sep) + 1;
- return `${partial.slice(0, nameBegin)}_${partial.slice(nameBegin)}.scss`;
+function urlReplacements() {
+ const replacements: Record> = {};
+ for (const [url, depSet] of importMap) {
+ if (!url.startsWith('../')) continue;
+ const hashed = [...depSet].find(f => f.startsWith('public/hashed/'));
+ if (!hashed) continue;
+ for (const src of [...dependsOn(url)].filter(isConcrete)) {
+ replacements[src] ??= {};
+ replacements[src][url] = relative(env.cssOutDir, hashed);
+ }
+ }
+ return replacements;
}
-function importersOf(srcFile: string, bset = new Set()): Set {
- if (bset.has(srcFile)) return bset;
+function dependsOn(srcFile: string, bset = new Set()): Set {
+ if (srcFile.startsWith('public/hashed/') || bset.has(srcFile)) return bset;
bset.add(srcFile);
- for (const dep of importMap.get(srcFile) ?? []) importersOf(dep, bset);
+ for (const dep of importMap.get(srcFile) ?? []) dependsOn(dep, bset);
return bset;
}
-async function hashMoveCss(src: string) {
- const content = await fs.promises.readFile(src, 'utf-8');
- const hash = crypto.createHash('sha256').update(content).digest('hex').slice(0, 8);
- const basename = p.basename(src, '.css');
- await Promise.allSettled([
- env.prod ? undefined : fs.promises.rename(`${src}.map`, p.join(env.cssOutDir, `${basename}.css.map`)),
- fs.promises.rename(src, p.join(env.cssOutDir, `${basename}.${hash}.css`)),
- ]);
- return [p.basename(src, '.css'), { hash }];
-}
-
function sassError(error: string) {
for (const err of trimLines(error)) {
if (err.startsWith('Error:')) {
@@ -309,3 +340,24 @@ function sassError(error: string) {
} else env.log(err, 'sass');
}
}
+
+function resolvePartial(partial: string): string {
+ const nameBegin = partial.lastIndexOf('/') + 1;
+ return `${partial.slice(0, nameBegin)}_${partial.slice(nameBegin)}.scss`;
+}
+
+function absTempCss(scss: string) {
+ return join(env.cssTempDir, `${basename(scss, '.scss')}.css`);
+}
+
+function isConcrete(src: string) {
+ return src.startsWith('ui/') && !basename(src).startsWith('_');
+}
+
+function isPartial(src: string) {
+ return src.startsWith('ui/') && basename(src).startsWith('_');
+}
+
+function isUrlTarget(src: string) {
+ return src.startsWith('public/');
+}
diff --git a/ui/.build/src/sync.ts b/ui/.build/src/sync.ts
index c8c3a6344976..141db793675c 100644
--- a/ui/.build/src/sync.ts
+++ b/ui/.build/src/sync.ts
@@ -1,9 +1,9 @@
import fs from 'node:fs';
-import p from 'node:path';
+import { join, dirname } from 'node:path';
import { task } from './task.ts';
import { env, c } from './env.ts';
-import { isGlob, isFolder } from './parse.ts';
-import { quantize, isEquivalent } from './algo.ts';
+import { isGlob, isFolder, isClose } from './parse.ts';
+import { isEquivalent } from './algo.ts';
export async function sync(): Promise {
if (!env.begin('sync')) return;
@@ -20,7 +20,7 @@ export async function sync(): Promise {
env.log(`${c.grey(pkg.name)} '${c.cyan(sync.src)}' -> '${c.cyan(sync.dest)}'`, 'sync');
return Promise.all(
files.map(async f => {
- if ((await syncOne(f, p.join(env.rootDir, sync.dest, f.slice(root.length)))) && logEvery)
+ if ((await syncOne(f, join(env.rootDir, sync.dest, f.slice(root.length)))) && logEvery)
env.log(
`${c.grey(pkg.name)} '${c.cyan(f.slice(root.length))}' -> '${c.cyan(sync.dest)}'`,
'sync',
@@ -39,10 +39,10 @@ async function syncOne(absSrc: string, absDest: string): Promise {
await Promise.allSettled([
fs.promises.stat(absSrc),
fs.promises.stat(absDest),
- fs.promises.mkdir(p.dirname(absDest), { recursive: true }),
+ fs.promises.mkdir(dirname(absDest), { recursive: true }),
])
).map(x => (x.status === 'fulfilled' ? (x.value as fs.Stats) : undefined));
- if (src && (!dest || quantize(src.mtimeMs, 300) !== quantize(dest.mtimeMs, 300))) {
+ if (src && !(dest && isClose(src.mtimeMs, dest.mtimeMs))) {
await fs.promises.copyFile(absSrc, absDest);
await fs.promises.utimes(absDest, src.atime, src.mtime);
return true;
@@ -51,8 +51,8 @@ async function syncOne(absSrc: string, absDest: string): Promise {
}
async function syncRoot(cwd: string, path: string): Promise {
- if (!(isGlob(path) || (await isFolder(p.join(cwd, path))))) return p.join(cwd, p.dirname(path));
- const [head, ...tail] = path.split(p.sep);
+ if (!(isGlob(path) || (await isFolder(join(cwd, path))))) return join(cwd, dirname(path));
+ const [head, ...tail] = path.split('/');
if (isGlob(head)) return cwd;
- return syncRoot(p.join(cwd, head), tail.join(p.sep));
+ return syncRoot(join(cwd, head), tail.join('/'));
}
diff --git a/ui/.build/src/task.ts b/ui/.build/src/task.ts
index ba061284a0c3..4ac282fa7bbe 100644
--- a/ui/.build/src/task.ts
+++ b/ui/.build/src/task.ts
@@ -1,8 +1,8 @@
import fg from 'fast-glob';
import mm from 'micromatch';
import fs from 'node:fs';
-import p from 'node:path';
-import { glob, isFolder, subfolders } from './parse.ts';
+import { join, relative, basename } from 'node:path';
+import { type Package, glob, isFolder, subfolders, isClose } from './parse.ts';
import { randomToken } from './algo.ts';
import { type Context, env, c, errorMark } from './env.ts';
@@ -13,9 +13,14 @@ const fileTimes = new Map();
type Path = string;
type AbsPath = string;
type CwdPath = { cwd: AbsPath; path: Path };
-type Debounce = { time: number; timeout?: NodeJS.Timeout; rename: boolean; files: Set };
type TaskKey = string;
type FSWatch = { watcher: fs.FSWatcher; cwd: AbsPath; keys: Set };
+type Debounce = {
+ time: number;
+ timer?: NodeJS.Timeout;
+ rename: boolean;
+ files: Set;
+};
type Task = Omit & {
glob: CwdPath[];
key: TaskKey;
@@ -26,20 +31,19 @@ type Task = Omit & {
type TaskOpts = {
glob: CwdPath | CwdPath[];
execute: (touched: AbsPath[], fullList: AbsPath[]) => Promise;
- key?: TaskKey; // optional key for replace & cancel
- ctx?: Context; // optional context for logging
- pkg?: string; // optional package for logging
+ key?: TaskKey; // optional key for overwrite, stop, tickle
+ ctx?: Context; // optional build step context for logging
+ pkg?: Package; // optional package reference
debounce?: number; // optional number in ms
- root?: AbsPath; // optional relative root for file lists, otherwise all paths are absolute
+ root?: AbsPath; // default absolute - optional relative root for file lists
globListOnly?: boolean; // default false - ignore file mods, only execute when glob list changes
monitorOnly?: boolean; // default false - do not execute on initial traverse, only on future changes
noEnvStatus?: boolean; // default false - don't inform env.done of task status
};
-export async function task(o: TaskOpts): Promise {
+export async function task(o: TaskOpts): Promise {
const { monitorOnly: noInitial, debounce, key: inKey } = o;
const glob = Array().concat(o.glob ?? []);
- if (glob.length === 0) return;
if (inKey) stopTask(inKey);
const newWatch: Task = {
...o,
@@ -51,13 +55,14 @@ export async function task(o: TaskOpts): Promise {
};
tasks.set(newWatch.key, newWatch);
if (env.watch) newWatch.glob.forEach(g => watchGlob(g, newWatch.key));
- if (!noInitial) return execute(newWatch);
+ if (!noInitial) await execute(newWatch);
+ return newWatch.key;
}
export function stopTask(keys?: TaskKey | TaskKey[]) {
const stopKeys = Array().concat(keys ?? [...tasks.keys()]);
for (const key of stopKeys) {
- clearTimeout(tasks.get(key)?.debounce.timeout);
+ clearTimeout(tasks.get(key)?.debounce.timer);
tasks.delete(key);
for (const [folder, fw] of fsWatches) {
if (fw.keys.delete(key) && fw.keys.size === 0) {
@@ -73,31 +78,84 @@ export function taskOk(ctx?: Context): boolean {
return all.filter(w => !w.monitorOnly).length > 0 && all.every(w => w.status === 'ok');
}
-export async function watchGlob({ cwd, path: globPath }: CwdPath, key: TaskKey): Promise {
+async function execute(t: Task): Promise {
+ const makeRelative = (files: AbsPath[]) => (t.root ? files.map(f => relative(t.root!, f)) : files);
+ const debounced = [...t.debounce.files];
+ const modified: AbsPath[] = [];
+ const { rename } = t.debounce;
+ t.debounce.rename = false;
+ t.debounce.files.clear();
+
+ if (rename) {
+ const files = await globTimes(t.glob);
+ const keys = [...files.keys()];
+ if (t.globListOnly && !(t.fileTimes.size === files.size && keys.every(f => t.fileTimes.has(f)))) {
+ modified.push(...keys);
+ } else if (!t.globListOnly) {
+ for (const [fullpath, time] of [...files]) {
+ if (!isClose(t.fileTimes.get(fullpath), time)) modified.push(fullpath);
+ }
+ }
+ t.fileTimes = files;
+ } else if (!t.globListOnly) {
+ await Promise.all(
+ debounced.map(async file => {
+ const fileTime = await cachedFileTime(file);
+ if (isClose(t.fileTimes.get(file), fileTime)) return;
+ t.fileTimes.set(file, fileTime);
+ modified.push(file);
+ }),
+ );
+ }
+ if (modified.length === 0) return;
+
+ if (t.ctx) env.begin(t.ctx);
+ t.status = undefined;
+ try {
+ await t.execute(makeRelative(modified), makeRelative([...t.fileTimes.keys()]));
+ t.status = 'ok';
+ if (t.ctx && !t.noEnvStatus && taskOk(t.ctx)) env.done(t.ctx);
+ } catch (e) {
+ t.status = 'error';
+ const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
+ if (!env.watch) env.exit(`${errorMark} ${message}`, t.ctx);
+ else if (e)
+ env.log(`${errorMark} ${t.pkg?.name ? `[${c.grey(t.pkg.name)}] ` : ''}- ${c.grey(message)}`, t.ctx);
+ if (t.ctx && !t.noEnvStatus) env.done(t.ctx, -1);
+ }
+}
+
+async function watchGlob({ cwd, path: globPath }: CwdPath, key: TaskKey): Promise {
if (!(await isFolder(cwd))) return;
- const [head, ...tail] = globPath.split(p.sep);
- const path = tail.join(p.sep);
+ const [head, ...tail] = globPath.split('/');
+ const path = tail.join('/');
if (head.includes('**')) {
await subfolders(cwd, 10).then(folders => folders.forEach(f => addFsWatch(f, key)));
} else if (/[*?!{}[\]()]/.test(head) && path) {
await subfolders(cwd, 1).then(folders =>
Promise.all(
- folders.filter(f => mm.isMatch(p.basename(f), head)).map(f => watchGlob({ cwd: f, path }, key)),
+ folders.filter(f => mm.isMatch(basename(f), head)).map(f => watchGlob({ cwd: f, path }, key)),
),
);
} else if (path) {
- return watchGlob({ cwd: p.join(cwd, head), path }, key);
+ return watchGlob({ cwd: join(cwd, head), path }, key);
}
addFsWatch(cwd, key);
}
-async function onFsChange(fsw: FSWatch, event: string, filename: string | null) {
- const fullpath = p.join(fsw.cwd, filename ?? '');
+async function onFsEvent(fsw: FSWatch, event: string, filename: string | null) {
+ const fullpath = join(fsw.cwd, filename ?? '');
- if (event === 'change') fileTimes.set(fullpath, await cachedFileTime(fullpath, true));
+ if (event === 'change')
+ try {
+ await cachedFileTime(fullpath, true);
+ } catch {
+ fileTimes.delete(fullpath);
+ event = 'rename';
+ }
for (const watch of [...fsw.keys].map(k => tasks.get(k)!)) {
- const fullglobs = watch.glob.map(({ cwd, path }) => p.join(cwd, path));
+ const fullglobs = watch.glob.map(({ cwd, path }) => join(cwd, path));
if (!mm.isMatch(fullpath, fullglobs)) {
if (event === 'change') continue;
try {
@@ -113,57 +171,8 @@ async function onFsChange(fsw: FSWatch, event: string, filename: string | null)
}
if (event === 'rename') watch.debounce.rename = true;
if (event === 'change') watch.debounce.files.add(fullpath);
- clearTimeout(watch.debounce.timeout);
- watch.debounce.timeout = setTimeout(() => execute(watch), watch.debounce.time);
- }
-}
-
-async function execute(watch: Task): Promise {
- const relative = (files: AbsPath[]) => (watch.root ? files.map(f => p.relative(watch.root!, f)) : files);
- const debounced = Object.freeze([...watch.debounce.files]);
- const modified: AbsPath[] = [];
- watch.debounce.files.clear();
-
- if (watch.debounce.rename) {
- watch.debounce.rename = false;
- const files = await globTimes(watch.glob);
- const keys = [...files.keys()];
- if (
- watch.globListOnly &&
- (watch.fileTimes.size !== files.size || !keys.every(f => watch.fileTimes.has(f)))
- ) {
- modified.push(...keys);
- } else if (!watch.globListOnly) {
- for (const [fullpath, time] of [...files]) {
- if (watch.fileTimes.get(fullpath) !== time) modified.push(fullpath);
- }
- }
- watch.fileTimes = files;
- } else if (!watch.globListOnly) {
- await Promise.all(
- debounced.map(async file => {
- const fileTime = await cachedFileTime(file);
- if (watch.fileTimes.get(file) === fileTime) return;
- watch.fileTimes.set(file, fileTime);
- modified.push(file);
- }),
- );
- }
- if (!modified.length) return;
-
- if (watch.ctx) env.begin(watch.ctx);
- watch.status = undefined;
- try {
- await watch.execute(relative(modified), relative([...watch.fileTimes.keys()]));
- watch.status = 'ok';
- if (watch.ctx && !watch.noEnvStatus && taskOk(watch.ctx)) env.done(watch.ctx);
- } catch (e) {
- watch.status = 'error';
- const message = e instanceof Error ? (e.stack ?? e.message) : String(e);
- if (!env.watch) env.exit(`${errorMark} ${message}`, watch.ctx);
- else if (e)
- env.log(`${errorMark} ${watch.pkg ? `[${c.grey(watch.pkg)}] ` : ''}- ${c.grey(message)}`, watch.ctx);
- if (watch.ctx && !watch.noEnvStatus) env.done(watch.ctx, -1);
+ clearTimeout(watch.debounce.timer);
+ watch.debounce.timer = setTimeout(() => execute(watch), watch.debounce.time);
}
}
@@ -173,7 +182,7 @@ function addFsWatch(root: AbsPath, key: TaskKey) {
return;
}
const fsWatch = { watcher: fs.watch(root), cwd: root, keys: new Set([key]) };
- fsWatch.watcher.on('change', (event, f) => onFsChange(fsWatch, event, String(f)));
+ fsWatch.watcher.on('change', (event, f) => onFsEvent(fsWatch, event, String(f)));
fsWatches.set(root, fsWatch);
}
diff --git a/ui/.build/src/tsc.ts b/ui/.build/src/tsc.ts
index d43fc454d7f4..4bf5c32deddb 100644
--- a/ui/.build/src/tsc.ts
+++ b/ui/.build/src/tsc.ts
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import os from 'node:os';
-import p from 'node:path';
+import { join, resolve, dirname, basename, relative } from 'node:path';
import ts from 'typescript';
import fg from 'fast-glob';
import { Worker } from 'node:worker_threads';
@@ -15,8 +15,8 @@ const spamGuard = new Map(); // dedup tsc errors
export async function tsc(): Promise {
if (!env.begin('tsc')) return;
await Promise.allSettled([
- fs.promises.mkdir(p.join(env.buildTempDir, 'noCheck')),
- fs.promises.mkdir(p.join(env.buildTempDir, 'noEmit')),
+ fs.promises.mkdir(join(env.buildTempDir, 'noCheck')),
+ fs.promises.mkdir(join(env.buildTempDir, 'noEmit')),
]);
const buildPaths = (await fg.glob('*/tsconfig*.json', { cwd: env.uiDir, absolute: true }))
@@ -52,20 +52,20 @@ export async function stopTsc(): Promise {
}
function assignWork(buckets: SplitConfig[][], key: 'noCheck' | 'noEmit'): Promise {
- let resolve: (() => void) | undefined = undefined;
+ let okResolve: (() => void) | undefined = undefined;
const status: ('ok' | 'busy' | 'error')[] = [];
- const ok = new Promise(res => (resolve = res));
+ const okPromise = new Promise(res => (okResolve = res));
const onError = (err: Error): void => env.exit(err.message, 'tsc');
const onMessage = (msg: Message): void => {
// the watch builder always gives us a 6194 first time thru, even when errors are found
- if (env.watch && resolve && msg.type === 'ok' && status[msg.index] === 'error') return;
+ if (env.watch && okResolve && msg.type === 'ok' && status[msg.index] === 'error') return;
status[msg.index] = msg.type;
if (msg.type === 'error') return tscError(msg.data);
if (status.some(s => s !== 'ok')) return;
- resolve?.();
+ okResolve?.();
if (key === 'noEmit') env.done('tsc');
};
@@ -76,12 +76,12 @@ function assignWork(buckets: SplitConfig[][], key: 'noCheck' | 'noEmit'): Promis
index: status.length,
};
status.push('busy');
- const worker = new Worker(p.resolve(env.buildSrcDir, 'tscWorker.ts'), { workerData });
+ const worker = new Worker(resolve(env.buildSrcDir, 'tscWorker.ts'), { workerData });
workers.push(worker);
worker.on('message', onMessage);
worker.on('error', onError);
}
- return ok;
+ return okPromise;
}
// the splitConfig transform generates noCheck and noEmit tsconfigs within the 'build' temp folder.
@@ -97,14 +97,14 @@ interface SplitConfig {
}
async function splitConfig(cfgPath: string): Promise {
- const root = p.dirname(cfgPath);
- const pkgName = p.basename(root);
+ const root = dirname(cfgPath);
+ const pkgName = basename(root);
const { config, error } = ts.readConfigFile(cfgPath, ts.sys.readFile);
const io: Promise[] = [];
if (error) throw new Error(`'${cfgPath}': ${error.messageText}`);
- const co: any = ts.parseJsonConfigFileContent(config, ts.sys, p.dirname(cfgPath)).options;
+ const co: any = ts.parseJsonConfigFileContent(config, ts.sys, dirname(cfgPath)).options;
if (co.moduleResolution) co.moduleResolution = ts.ModuleResolutionKind[co.moduleResolution];
if (co.module) co.module = ts.ModuleKind[co.module];
@@ -114,26 +114,26 @@ async function splitConfig(cfgPath: string): Promise {
co.incremental = true;
config.compilerOptions = co;
- config.size = await folderSize(p.join(root, 'src'));
- config.include = config.include?.map((glob: string) => p.resolve(root, glob.replace('${configDir}', '.')));
+ config.size = await folderSize(join(root, 'src'));
+ config.include = config.include?.map((glob: string) => resolve(root, glob.replace('${configDir}', '.')));
config.include ??= [co.rootDir ? `${co.rootDir}/**/*` : `${root}/src/**/*`];
config.exclude = config.exclude
?.filter((glob: string) => !env.test || !glob.includes('tests'))
- .map((glob: string) => p.resolve(root, glob.replace('${configDir}', '.')));
+ .map((glob: string) => resolve(root, glob.replace('${configDir}', '.')));
config.extends = undefined;
config.references = env.workspaceDeps
.get(pkgName)
- ?.map(ref => ({ path: p.join(env.buildTempDir, 'noCheck', `${ref}.tsconfig.json`) }));
+ ?.map(ref => ({ path: join(env.buildTempDir, 'noCheck', `${ref}.tsconfig.json`) }));
const noEmitData = structuredClone(config);
- const noEmit = p.join(env.buildTempDir, 'noEmit', `${pkgName}.tsconfig.json`);
+ const noEmit = join(env.buildTempDir, 'noEmit', `${pkgName}.tsconfig.json`);
noEmitData.compilerOptions.noEmit = true;
- noEmitData.compilerOptions.tsBuildInfoFile = p.join(env.buildTempDir, 'noEmit', `${pkgName}.tsbuildinfo`);
- if (env.test && (await readable(p.join(root, 'tests')))) {
- noEmitData.include.push(p.join(root, 'tests'));
+ noEmitData.compilerOptions.tsBuildInfoFile = join(env.buildTempDir, 'noEmit', `${pkgName}.tsbuildinfo`);
+ if (env.test && (await readable(join(root, 'tests')))) {
+ noEmitData.include.push(join(root, 'tests'));
noEmitData.compilerOptions.rootDir = root;
noEmitData.compilerOptions.skipLibCheck = true;
- noEmitData.size += await folderSize(p.join(root, 'tests'));
+ noEmitData.size += await folderSize(join(root, 'tests'));
}
io.push(fs.promises.writeFile(noEmit, JSON.stringify(noEmitData, undefined, 2)));
@@ -141,14 +141,10 @@ async function splitConfig(cfgPath: string): Promise {
if (!co.noEmit) {
const noCheckData = structuredClone(config);
- const noCheck = p.join(env.buildTempDir, 'noCheck', `${pkgName}.tsconfig.json`);
+ const noCheck = join(env.buildTempDir, 'noCheck', `${pkgName}.tsconfig.json`);
noCheckData.compilerOptions.noCheck = true;
noCheckData.compilerOptions.emitDeclarationOnly = true;
- noCheckData.compilerOptions.tsBuildInfoFile = p.join(
- env.buildTempDir,
- 'noCheck',
- `${pkgName}.tsbuildinfo`,
- );
+ noCheckData.compilerOptions.tsBuildInfoFile = join(env.buildTempDir, 'noCheck', `${pkgName}.tsbuildinfo`);
res.push({ type: 'noCheck', configFile: noCheck, pkgName, size: config.size });
io.push(fs.promises.writeFile(noCheck, JSON.stringify(noCheckData, undefined, 2)));
}
@@ -167,7 +163,7 @@ function tscError({ code, text, file, line, col }: ErrorMessage['data']): void {
const message = `${ts.flattenDiagnosticMessageText(text, '\n', 0)}`;
let location = '';
if (file) {
- location = `${c.grey('in')} '${c.cyan(p.relative(env.uiDir, file))}`;
+ location = `${c.grey('in')} '${c.cyan(relative(env.uiDir, file))}`;
if (line !== undefined) location += c.grey(`:${line + 1}:${col + 1}`);
location += `' - `;
}
diff --git a/ui/@types/lichess/index.d.ts b/ui/@types/lichess/index.d.ts
index 97e374c58a26..e0df5c18ca7c 100644
--- a/ui/@types/lichess/index.d.ts
+++ b/ui/@types/lichess/index.d.ts
@@ -129,7 +129,7 @@ interface Cookie {
interface AssetUrlOpts {
documentOrigin?: boolean;
pathOnly?: boolean;
- version?: false | string;
+ pathVersion?: true | string;
}
type Timeout = ReturnType;
@@ -217,7 +217,6 @@ type Perf = Exclude | Speed;
type Uci = string;
type San = string;
-type AlmostSan = string;
type Ply = number;
type Seconds = number;
type Centis = number;
diff --git a/ui/bits/css/practice/_icons.scss b/ui/bits/css/practice/_icons.scss
index 837d95e8c737..ca56b2aec604 100644
--- a/ui/bits/css/practice/_icons.scss
+++ b/ui/bits/css/practice/_icons.scss
@@ -1,144 +1,144 @@
i.practice {
- background: img-url('practice/help.svg');
+ background: url(../images/practice/help.svg);
background-size: cover;
}
[class='ptQ1LLvm'] {
- background-image: img-url('practice/backstab.svg');
+ background-image: url(../images/practice/backstab.svg);
}
[class='E1lqtqFt'] {
- background-image: img-url('practice/arrowed.svg');
+ background-image: url(../images/practice/arrowed.svg);
}
[class='xebrDvFe'] {
- background-image: img-url('practice/key.svg');
+ background-image: url(../images/practice/key.svg);
}
[class='A4ujYOer'] {
- background-image: img-url('practice/push.svg');
+ background-image: url(../images/practice/push.svg);
}
[class='9ogFv8Ac'] {
- background-image: img-url('practice/voodoo-doll.svg');
+ background-image: url(../images/practice/voodoo-doll.svg);
}
[class='Qj281y1p'] {
- background-image: img-url('practice/trident.svg');
+ background-image: url(../images/practice/trident.svg);
}
[class='tuoBxVE5'] {
- background-image: img-url('practice/pierced-body.svg');
+ background-image: url(../images/practice/pierced-body.svg);
}
[class='BJy6fEDf'] {
- background-image: img-url('practice/stone-pile.svg');
+ background-image: url(../images/practice/stone-pile.svg);
}
[class='Rg2cMBZ6'] {
- background-image: img-url('practice/stone-spear.svg');
+ background-image: url(../images/practice/stone-spear.svg);
}
[class='fE4k21MW'] {
- background-image: img-url('practice/pocket-bow.svg');
+ background-image: url(../images/practice/pocket-bow.svg);
}
[class='8yadFPpU'] {
- background-image: img-url('practice/sword-in-stone.svg');
+ background-image: url(../images/practice/sword-in-stone.svg);
}
[class='PDkQDt6u'] {
- background-image: img-url('practice/catapult.svg');
+ background-image: url(../images/practice/catapult.svg);
}
[class='96Lij7wH'] {
- background-image: img-url('practice/musket.svg');
+ background-image: url(../images/practice/musket.svg);
}
[class='o734CNqp'] {
- background-image: img-url('practice/breaking-chain.svg');
+ background-image: url(../images/practice/breaking-chain.svg);
}
[class='9cKgYrHb'] {
- background-image: img-url('practice/cement-shoes.svg');
+ background-image: url(../images/practice/cement-shoes.svg);
}
[class='MnsJEWnI'] {
- background-image: img-url('practice/boxing-glove-surprise.svg');
+ background-image: url(../images/practice/boxing-glove-surprise.svg);
}
[class='ITWY4GN2'] {
- background-image: img-url('practice/two-shadows.svg');
+ background-image: url(../images/practice/two-shadows.svg);
}
[class='lyVYjhPG'] {
- background-image: img-url('practice/skeletal-hand.svg');
+ background-image: url(../images/practice/skeletal-hand.svg);
}
[class='s5pLU7Of'] {
- background-image: img-url('practice/trojan-horse.svg');
+ background-image: url(../images/practice/trojan-horse.svg);
}
[class='kdKpaYLW'] {
- background-image: img-url('practice/divert.svg');
+ background-image: url(../images/practice/divert.svg);
}
[class='jOZejFWk'] {
- background-image: img-url('practice/magnet.svg');
+ background-image: url(../images/practice/magnet.svg);
}
[class='49fDW0wP'] {
- background-image: img-url('practice/upgrade.svg');
+ background-image: url(../images/practice/upgrade.svg);
}
[class='0YcGiH4Y'] {
- background-image: img-url('practice/quicksand.svg');
+ background-image: url(../images/practice/quicksand.svg);
}
[class='CgjKPvxQ'] {
- background-image: img-url('practice/back-forth.svg');
+ background-image: url(../images/practice/back-forth.svg);
}
[class='udx042D6'] {
- background-image: img-url('practice/mining.svg');
+ background-image: url(../images/practice/mining.svg);
}
[class='Grmtwuft'] {
- background-image: img-url('practice/detour.svg');
+ background-image: url(../images/practice/detour.svg);
}
[class='g1fxVZu9'] {
- background-image: img-url('practice/bolt-shield.svg');
+ background-image: url(../images/practice/bolt-shield.svg);
}
[class='RUQASaZm'] {
- background-image: img-url('practice/rogue.svg');
+ background-image: url(../images/practice/rogue.svg);
}
[class='9c6GrCTk'] {
- background-image: img-url('practice/siege-tower.svg');
+ background-image: url(../images/practice/siege-tower.svg);
}
[class='ByhlXnmM'] {
- background-image: img-url('practice/ghost-ally.svg');
+ background-image: url(../images/practice/ghost-ally.svg);
}
[class='pt20yRkT'] {
- background-image: img-url('practice/stone-tower.svg');
+ background-image: url(../images/practice/stone-tower.svg);
}
[class='MkDViieT'] {
- background-image: img-url('practice/guarded-tower.svg');
+ background-image: url(../images/practice/guarded-tower.svg);
}
[class='pqUSUw8Y'] {
- background-image: img-url('practice/siege-tower.svg');
+ background-image: url(../images/practice/siege-tower.svg);
}
[class='heQDnvq7'] {
- background-image: img-url('practice/locked-fortress.svg');
+ background-image: url(../images/practice/locked-fortress.svg);
}
[class='wS23j5Tm'] {
- background-image: img-url('practice/tower-fall.svg');
+ background-image: url(../images/practice/tower-fall.svg);
}
diff --git a/ui/bits/css/practice/_side.scss b/ui/bits/css/practice/_side.scss
index e0ff0b98acab..db4cb379b831 100644
--- a/ui/bits/css/practice/_side.scss
+++ b/ui/bits/css/practice/_side.scss
@@ -68,7 +68,7 @@
height: 100%;
background: #ccc3; // better
- background-image: img-url('grain.png');
+ background-image: url(../images/grain.png);
transform: translateX(-100px);
animation:
animatedBackground 50s linear infinite,
diff --git a/ui/bits/css/ublog/_card.scss b/ui/bits/css/ublog/_card.scss
index 8c7b376c669c..e9481beedd9d 100644
--- a/ui/bits/css/ublog/_card.scss
+++ b/ui/bits/css/ublog/_card.scss
@@ -63,7 +63,7 @@
width: 100%;
height: auto;
&.ublog-post-image-default {
- background-image: img-url('placeholder-margin.png');
+ background-image: url(../images/placeholder-margin.png);
background-size: cover;
background-position: center;
}
diff --git a/ui/bits/src/bits.diagnosticDialog.ts b/ui/bits/src/bits.diagnosticDialog.ts
index b173d66a96fd..d460527d520f 100644
--- a/ui/bits/src/bits.diagnosticDialog.ts
+++ b/ui/bits/src/bits.diagnosticDialog.ts
@@ -5,27 +5,34 @@ import { escapeHtml, myUserId } from 'common';
import { storage } from 'common/storage';
import { log } from 'common/permalog';
-export async function initModule(): Promise {
- const ops = processQueryParams();
- const logs = await log.get();
+interface DiagnosticOpts {
+ text: string;
+ header?: string;
+ submit?: string;
+}
+
+export async function initModule(opts?: DiagnosticOpts): Promise {
+ const ops = opts ? 0 : processQueryParams();
+ const logs = !opts && (await log.get());
const text =
+ opts?.text ??
`Browser: ${navigator.userAgent}\n` +
- `Cores: ${navigator.hardwareConcurrency}, ` +
- `Touch: ${isTouchDevice()} ${navigator.maxTouchPoints}, ` +
- `Screen: ${window.screen.width}x${window.screen.height}, ` +
- `Lang: ${navigator.language}, ` +
- `Engine: ${storage.get('ceval.engine')}, ` +
- `Threads: ${storage.get('ceval.threads')}, ` +
- `Blindfold: ${storage.boolean('blindfold.' + (myUserId() || 'anon')).get()}, ` +
- `Pieces: ${document.body.dataset.pieceSet}` +
- (logs ? `\n\n${logs}` : '');
+ `Cores: ${navigator.hardwareConcurrency}, ` +
+ `Touch: ${isTouchDevice()} ${navigator.maxTouchPoints}, ` +
+ `Screen: ${window.screen.width}x${window.screen.height}, ` +
+ `Lang: ${navigator.language}, ` +
+ `Engine: ${storage.get('ceval.engine')}, ` +
+ `Threads: ${storage.get('ceval.threads')}, ` +
+ `Blindfold: ${storage.boolean('blindfold.' + (myUserId() || 'anon')).get()}, ` +
+ `Pieces: ${document.body.dataset.pieceSet}` +
+ (logs ? `\n\n${logs}` : '');
const escaped = escapeHtml(text);
const flash = ops > 0 ? `Changes applied
` : '';
const submit = myUserId()
? $html`
`
: '';
const clear = logs ? `` : '';
@@ -34,8 +41,9 @@ export async function initModule(): Promise {
class: 'diagnostic',
css: [{ hashed: 'bits.diagnosticDialog' }],
modal: true,
+ focus: '.copy',
htmlText: $html`
- Diagnostics
${flash}
+ ${opts?.header ?? 'Diagnostics'}
${flash}
${escaped}
${clear} ${copy} ${submit} `,
});
diff --git a/ui/bits/src/bits.flairPicker.ts b/ui/bits/src/bits.flairPicker.ts
index e460ffb0d046..443bdbc87e27 100644
--- a/ui/bits/src/bits.flairPicker.ts
+++ b/ui/bits/src/bits.flairPicker.ts
@@ -34,7 +34,7 @@ export async function initModule(cfg: Config): Promise {
}
const makeEmojiData = async () => {
- const res = await fetch(site.asset.url('flair/list.txt'));
+ const res = await fetch(site.asset.url('flair/list.txt', { pathVersion: true }));
const text = await res.text();
const lines = text.split('\n').slice(0, -1);
const data = {
diff --git a/ui/ceval/css/_ctrl.scss b/ui/ceval/css/_ctrl.scss
index 7a8b04b855a5..ebd15143f598 100644
--- a/ui/ceval/css/_ctrl.scss
+++ b/ui/ceval/css/_ctrl.scss
@@ -142,7 +142,7 @@
}
&.computing .bar span {
- background-image: img-url('bar-highlight.png');
+ background-image: url(../images/bar-highlight.png);
animation: bar-anim 1000s linear infinite;
}
}
diff --git a/ui/ceval/src/engines/simpleEngine.ts b/ui/ceval/src/engines/simpleEngine.ts
index fd2fe861d329..88d53742eaf3 100644
--- a/ui/ceval/src/engines/simpleEngine.ts
+++ b/ui/ceval/src/engines/simpleEngine.ts
@@ -31,7 +31,7 @@ export class SimpleEngine implements CevalEngine {
this.protocol.compute(work);
if (!this.worker) {
- this.worker = new Worker(site.asset.url(this.url, { pathOnly: true }));
+ this.worker = new Worker(site.asset.url(this.url, { pathVersion: true, pathOnly: true }));
this.worker.addEventListener('message', e => this.protocol.received(e.data), true);
this.worker.addEventListener(
'error',
diff --git a/ui/ceval/src/engines/stockfishWebEngine.ts b/ui/ceval/src/engines/stockfishWebEngine.ts
index 31e82e1e4e3c..576b3966b57e 100644
--- a/ui/ceval/src/engines/stockfishWebEngine.ts
+++ b/ui/ceval/src/engines/stockfishWebEngine.ts
@@ -33,19 +33,19 @@ export class StockfishWebEngine implements CevalEngine {
}
async boot(): Promise {
- const [version, root, js] = [this.info.assets.version, this.info.assets.root, this.info.assets.js];
- const makeModule = await import(site.asset.url(`${root}/${js}`, { version, documentOrigin: true }));
+ const [pathVersion, root, js] = [this.info.assets.version, this.info.assets.root, this.info.assets.js];
+ const makeModule = await import(site.asset.url(`${root}/${js}`, { pathVersion, documentOrigin: true }));
const module: StockfishWeb = await makeModule.default({
wasmMemory: sharedWasmMemory(this.info.minMem!),
onError: (msg: string) => Promise.reject(new Error(msg)),
- locateFile: (file: string) => site.asset.url(`${root}/${file}`, { version }),
+ locateFile: (file: string) => site.asset.url(`${root}/${file}`, { pathVersion }),
});
if (this.info.tech === 'NNUE') {
if (this.info.variants?.length === 1) {
const model = this.info.variants[0].toLowerCase(); // set variant first for fairy stockfish
module.uci(`setoption name UCI_Variant value ${model === 'threecheck' ? '3check' : model}`);
}
- this.store = await objectStorage({ store: 'nnue' }).catch(() => undefined);
+ this.store = await objectStorage({ store: 'nnue', db: 'nnue--db' }).catch(() => undefined);
module.onError = this.makeErrorHandler(module);
const nnueFilenames: string[] = this.info.assets.nnue ?? [];
if (!nnueFilenames.length)
@@ -69,7 +69,7 @@ export class StockfishWebEngine implements CevalEngine {
if (storedBuffer && storedBuffer.byteLength > 128 * 1024) return storedBuffer;
const req = new XMLHttpRequest();
- req.open('get', site.asset.url(`lifat/nnue/${nnueFilename}`, { version: false }), true);
+ req.open('get', site.asset.url(`lifat/nnue/${nnueFilename}`), true);
req.responseType = 'arraybuffer';
req.onprogress = e => this.status?.({ download: { bytes: e.loaded, total: e.total } });
diff --git a/ui/ceval/src/engines/threadedEngine.ts b/ui/ceval/src/engines/threadedEngine.ts
index a80370c71f28..616fbce5985c 100644
--- a/ui/ceval/src/engines/threadedEngine.ts
+++ b/ui/ceval/src/engines/threadedEngine.ts
@@ -65,7 +65,7 @@ export class ThreadedEngine implements CevalEngine {
}
private async boot() {
- const [root, js, wasm, version] = [
+ const [root, js, wasm, pathVersion] = [
this.info.assets.root,
this.info.assets.js,
this.info.assets.wasm,
@@ -78,7 +78,7 @@ export class ThreadedEngine implements CevalEngine {
const cache = window.indexedDB && new Cache('ceval-wasm-cache');
try {
if (cache) {
- const [found, data] = await cache.get(wasmPath, version!);
+ const [found, data] = await cache.get(wasmPath, pathVersion!);
if (found) wasmBinary = data;
}
} catch (e) {
@@ -87,7 +87,7 @@ export class ThreadedEngine implements CevalEngine {
if (!wasmBinary) {
wasmBinary = await new Promise((resolve, reject) => {
const req = new XMLHttpRequest();
- req.open('GET', site.asset.url(wasmPath, { version }), true);
+ req.open('GET', site.asset.url(wasmPath, { pathVersion }), true);
req.responseType = 'arraybuffer';
req.onerror = event => reject(event);
req.onprogress = event => this.status?.({ download: { bytes: event.loaded, total: event.total } });
@@ -99,20 +99,20 @@ export class ThreadedEngine implements CevalEngine {
});
}
try {
- await cache.set(wasmPath, version!, wasmBinary);
+ await cache.set(wasmPath, pathVersion!, wasmBinary);
} catch (e) {
console.log('ceval: idb cache store failed:', e);
}
}
// Load Emscripten module.
- await site.asset.loadIife(`${root}/${js}`, { version });
+ await site.asset.loadIife(`${root}/${js}`, { pathVersion });
const sf = await window[this.info.id === '__sf11mv' ? 'StockfishMv' : 'Stockfish']!({
wasmBinary,
printErr: (msg: string) => this.onError(new Error(msg)),
onError: this.onError,
locateFile: (path: string) =>
- site.asset.url(`${root}/${path}`, { version, pathOnly: path.endsWith('.worker.js') }),
+ site.asset.url(`${root}/${path}`, { pathVersion, pathOnly: path.endsWith('.worker.js') }),
wasmMemory: sharedWasmMemory(this.info.minMem!),
});
diff --git a/ui/chess/src/sanWriter.ts b/ui/chess/src/sanWriter.ts
index 10b5185617cc..44b486900176 100644
--- a/ui/chess/src/sanWriter.ts
+++ b/ui/chess/src/sanWriter.ts
@@ -1,5 +1,7 @@
import { charToRole, type Square } from 'chessops';
+type AlmostSan = string;
+
export type Board = { pieces: { [key: number]: string }; turn: boolean };
export type SanToUci = { [key: AlmostSan]: Uci };
diff --git a/ui/common/css/abstract/_functions.scss b/ui/common/css/abstract/_functions.scss
index 25c6652a3a4b..b249776bb0e8 100644
--- a/ui/common/css/abstract/_functions.scss
+++ b/ui/common/css/abstract/_functions.scss
@@ -1,7 +1,3 @@
-@function img-url($path) {
- @return url('#{$img-path}/#{$path}');
-}
-
@function at-least($width) {
@return $width - 0.7px;
}
diff --git a/ui/common/css/abstract/_variables.scss b/ui/common/css/abstract/_variables.scss
index ae2138157c2e..6584275beb33 100644
--- a/ui/common/css/abstract/_variables.scss
+++ b/ui/common/css/abstract/_variables.scss
@@ -1,8 +1,5 @@
$debug: false;
-$font-path: '../font';
-$img-path: '../images';
-
$viewport-min-width: 320px;
$block-gap: var(---block-gap);
diff --git a/ui/common/css/build/common.theme.all.scss b/ui/common/css/build/common.theme.all.scss
index 9cffabd465b4..51983fdbc070 100644
--- a/ui/common/css/build/common.theme.all.scss
+++ b/ui/common/css/build/common.theme.all.scss
@@ -1,3 +1,4 @@
+@import '../theme/font-face';
@import '../theme/default';
:root {
diff --git a/ui/common/css/build/common.theme.embed.scss b/ui/common/css/build/common.theme.embed.scss
index 893a866e7365..6b6a132ab86d 100644
--- a/ui/common/css/build/common.theme.embed.scss
+++ b/ui/common/css/build/common.theme.embed.scss
@@ -1,3 +1,4 @@
+@import '../theme/font-face';
@import '../theme/default';
:root {
diff --git a/ui/common/css/component/_loader.scss b/ui/common/css/component/_loader.scss
index 5745102ee865..ac046588abdb 100755
--- a/ui/common/css/component/_loader.scss
+++ b/ui/common/css/component/_loader.scss
@@ -100,9 +100,9 @@ $total_len: $len1 + $len2 + $len3;
}
.ddloader {
- background: img-url('loader/whitex1.png') no-repeat;
+ background: url(../images/loader/whitex1.png) no-repeat;
@include if-light {
- background: img-url('loader/blackx1.png') no-repeat;
+ background: url(../images/loader/blackx1.png) no-repeat;
}
animation: ddloader 0.5s steps(15) infinite !important;
diff --git a/ui/common/css/component/_podium.scss b/ui/common/css/component/_podium.scss
index 32f6b97033c1..c500f7cd7e87 100644
--- a/ui/common/css/component/_podium.scss
+++ b/ui/common/css/component/_podium.scss
@@ -60,19 +60,19 @@
.first .trophy {
height: 9em;
width: 9em;
- background-image: img-url('trophy/lichess-massive.svg');
+ background-image: url(../images/trophy/lichess-massive.svg);
}
.second .trophy {
height: 9em;
width: 7em;
- background-image: img-url('trophy/lichess-silver-1.svg');
+ background-image: url(../images/trophy/lichess-silver-1.svg);
}
.third .trophy {
height: 7em;
width: 7em;
- background-image: img-url('trophy/lichess-bronze-2.svg');
+ background-image: url(../images/trophy/lichess-bronze-2.svg);
}
@media (max-width: at-most($xx-small)) {
diff --git a/ui/common/css/component/_zen-toggle.scss b/ui/common/css/component/_zen-toggle.scss
index 6645e30213d9..4e9fd021f63c 100644
--- a/ui/common/css/component/_zen-toggle.scss
+++ b/ui/common/css/component/_zen-toggle.scss
@@ -13,9 +13,9 @@
flex: 0 0 4em;
height: 4em;
padding: 1em 1em;
- background: center no-repeat url('../logo/lichess-white.svg');
+ background: center no-repeat url(../logo/lichess-white.svg);
@include if-light {
- background-image: url('../logo/lichess.svg');
+ background-image: url(../logo/lichess.svg);
}
background-size: 2em;
opacity: 0.5;
diff --git a/ui/common/css/theme/README.md b/ui/common/css/theme/README.md
index f040e5797f65..83ac7325d858 100644
--- a/ui/common/css/theme/README.md
+++ b/ui/common/css/theme/README.md
@@ -2,72 +2,69 @@
- scss variables start with $ (dollar sign) and are compile-time macros. when the css is
generated, they're replaced by values and no longer exist.
-- css variables begin with -- (two or more hyphens) and exist at runtime. they are set and accessed by
- the browser when it applies styles and can also be set and accessed by javascript.
+- css variables begin with -- (two or more hyphens) and exist at runtime. they are set
+ and accessed by the browser when it applies styles and can also be set and accessed by javascript.
## how color themes work
-each partial scss file in the `ui/common/css/theme` directory describes a color theme. `_default.scss` is special though - in addition to defining the dark theme, it provides
-important definitions to other named themes (`_light.scss`, `_transp.scss`, ...).
-some (or all) of the included colors from `_default.scss` may be overridden by the named theme.
+each partial scss file in the `ui/common/css/theme` directory describes a color theme.
+`_default.scss` is special as in addition to defining the dark theme, it is the baseline
+from which other named themes (`_light.scss`, `_transp.scss`, ...) are extended.
-all of your external style rules will still reference colors as scss variables. under the
-hood, they're generated wrappers defined in theme/gen/\_wrap.scss that look something like:
+every color in this theme system has both an scss and a css variable representation. your external
+style rules will use the scss form beginning with `$c-` or `$m-` when possible due to
+type safety reasons. under the hood, the build script generates import code that maps them.
+something like:
```scss
$c-bg: var(--c-bg);
$c-primary: var(--c-primary);
...
- $m-bad_bg-zebra--mix-20: var(--m-bad_bg-zebra--mix-20);
- $m-bad_bg-zebra2--mix-20: var(--m-bad_bg-zebra2--mix-20);
+ $m-bg_primary--mix-20: var(--m-bg_primary--mix-20);
... // and so on
```
-### scss variables in theme files
+### mixable scss variables defined in your external scss
-these are always prefixed by `$c-` define the base colors of a theme.
-
-currently, the values must be valid css color definitions (hsla, rgba, hex) and may not
-contain scss color functions.
-
-### mixable scss variables in your scss
-
-when the ui/build encounters a variable name
-starting with `$m-` and following the pattern
+outside of the theme files, when the build script encounters a variable name following the pattern
```scss
$m-_---
```
-it performs
-a mutation on the color(s) like the equivalent scss color function would, resulting in a new
-css/scss variable pair in `gen/_mix.scss` and `gen/_wrap.scss` respectively. you don't
-have to do anything special to make this happen aside from following the special syntax
-above within a style rule.
+it performs an operation (`fade`, `mix`, `alpha`, or `lighten`) on mixable theme color(s)
+and a value (all within the same variable name) just like the equivalent scss color function would.
+behind the scenes, this results in a new css/scss variable pair being created but
+you don't have to do anything special aside from using the `$m----` syntax within a style rule.
-for example, say we have $c-primary and $c-bg-zebra defined in a theme file. if ui/build
-encounters
+for example, say we have $c-primary and $c-bg-zebra defined in a theme file. if ui/build is then
+parsing ui/yourModule/css/_yourModule.scss and encounters
```scss
background: $m-primary_bg-zebra--mix-40;
```
-then the background will be set to
-a 40% mix of `$c-primary` with `$c-bg-zebra`.
+then the background will be set to a 40% mix of `$c-primary` with `$c-bg-zebra`.
supported operations are `fade`, `mix`, `alpha`, and `lighten`. `val` is always between
-0 and 100 (where 100 represents either 100% or 1.0 depending on the function). other
-functions `saturate`, `desaturate` etc can be added to `ui/.build/src/sass.ts` if needed.
+0 and 100 (where 100 represents either 100% or 1.0 depending on the function).
+
+### scss color variables defined in theme files
+
+these are always prefixed by `$c-` define the base colors of a theme. only these colors may be used
+in mix expressions.
+
+the values for all scss variables atop the color theme files must be valid css color
+definitions (hsla, rgba, hex) and may not contain scss color functions or other scss variables.
-### css variables
+### css color variables defined on the html element in theme files
-these are not mixable at compile time. but we need them for all colors save ones that are
-auto-created by one of the mix operations described above.
-most of these css variables are defined by the `shared-color-defs` mixin in `_default.scss`.
+these are not mixable, but their definition rules are less strict - they
+may contain any scss expression that resolves to a color (including scss color functions and variables).
+many are defined by the `shared-color-defs` mixin in `_default.scss`.
this mixin uses the scss variables of the including scope to inform most of the values.
-others, such as `--c-page-mask` are the same for all current themes and are just given
-an explicit value, but anything given a value in shared-color-defs can also be
-overridden by the including theme file. that's why the light and transp themes include
+just like scss color variables, html block css variables can be
+overridden by subsequent properties. that's why the light and transp themes include
the `shared-color-defs` mixin first thing in their selector block, so they can then
override the values they want to change.
@@ -109,5 +106,4 @@ same as before
ui/build -w
```
-watch mode should keep everything in sync for you, but you might need to restart if
-you create a brand new file somewhere
+watch mode should keep everything in sync for you
diff --git a/ui/common/css/theme/font-face.css b/ui/common/css/theme/_font-face.scss
similarity index 76%
rename from ui/common/css/theme/font-face.css
rename to ui/common/css/theme/_font-face.scss
index e38ac2215144..f161f561a410 100644
--- a/ui/common/css/theme/font-face.css
+++ b/ui/common/css/theme/_font-face.scss
@@ -3,24 +3,24 @@
@font-face {
font-family: 'Noto Chess';
font-display: block;
- src: url('font/lichess-chess.woff2') format('woff2');
+ src: url(../font/lichess-chess.woff2) format('woff2');
}
@font-face {
font-family: 'racer-car';
- src: url('font/racer-car.woff2') format('woff2');
+ src: url(../font/racer-car.woff2) format('woff2');
}
@font-face {
font-family: 'storm';
- src: url('font/storm.woff2') format('woff2');
+ src: url(../font/storm.woff2) format('woff2');
}
@font-face {
font-family: 'Noto Sans';
font-display: swap;
font-weight: 100 900;
- src: url('font/noto-sans-latin.woff2') format('woff2');
+ src: url(../font/noto-sans-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308,
U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -29,7 +29,7 @@
font-family: 'Noto Sans';
font-display: swap;
font-weight: 100 900;
- src: url('font/noto-sans-latin-ext.woff2') format('woff2');
+ src: url(../font/noto-sans-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -38,7 +38,7 @@
font-family: 'Noto Sans';
font-display: swap;
font-weight: 100 900;
- src: url('font/noto-sans-cyrillic.woff2') format('woff2');
+ src: url(../font/noto-sans-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -46,7 +46,7 @@
font-family: 'Noto Sans';
font-display: swap;
font-weight: 100 900;
- src: url('font/noto-sans-cyrillic-ext.woff2') format('woff2');
+ src: url(../font/noto-sans-cyrillic-ext.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -54,7 +54,7 @@
font-family: 'Noto Sans';
font-display: swap;
font-weight: 100 900;
- src: url('font/noto-sans-greek-ext.woff2') format('woff2');
+ src: url(../font/noto-sans-greek-ext.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
@@ -62,7 +62,7 @@
font-family: 'Noto Sans';
font-display: swap;
font-weight: 100 900;
- src: url('font/noto-sans-greek.woff2') format('woff2');
+ src: url(../font/noto-sans-greek.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
@@ -70,7 +70,7 @@
font-family: 'Noto Sans';
font-display: swap;
font-weight: 100 900;
- src: url('font/noto-sans-devanagari.woff2') format('woff2');
+ src: url(../font/noto-sans-devanagari.woff2) format('woff2');
unicode-range: U+0900-097F, U+1CD0-1CF9, U+200C-200D, U+20A8, U+20B9, U+20F0, U+25CC, U+A830-A839,
U+A8E0-A8FF, U+11B00-11B09;
}
@@ -79,7 +79,7 @@
font-family: 'Noto Sans';
font-display: swap;
font-weight: 100 900;
- src: url('font/noto-sans-vietnamese.woff2') format('woff2');
+ src: url(../font/noto-sans-vietnamese.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301,
U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@@ -88,7 +88,7 @@
font-family: 'Roboto';
font-display: swap;
font-weight: 100 900;
- src: url('font/roboto-latin.woff2') format('woff2');
+ src: url(../font/roboto-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308,
U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@@ -97,7 +97,7 @@
font-family: 'Roboto';
font-display: swap;
font-weight: 100 900;
- src: url('font/roboto-latin-ext.woff2') format('woff2');
+ src: url(../font/roboto-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329,
U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@@ -106,7 +106,7 @@
font-family: 'Roboto';
font-display: swap;
font-weight: 100 900;
- src: url('font/roboto-cyrillic.woff2') format('woff2');
+ src: url(../font/roboto-cyrillic.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@@ -114,7 +114,7 @@
font-family: 'Roboto';
font-display: swap;
font-weight: 100 900;
- src: url('font/roboto-cyrillic-ext.woff2') format('woff2');
+ src: url(../font/roboto-cyrillic-ext.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
@@ -122,7 +122,7 @@
font-family: 'Roboto';
font-display: swap;
font-weight: 100 900;
- src: url('font/roboto-greek.woff2') format('woff2');
+ src: url(../font/roboto-greek.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
@@ -130,7 +130,7 @@
font-family: 'Roboto';
font-display: swap;
font-weight: 100 900;
- src: url('font/roboto-greek-ext.woff2') format('woff2');
+ src: url(../font/roboto-greek-ext.woff2) format('woff2');
unicode-range: U+1F00-1FFF;
}
@@ -138,7 +138,7 @@
font-family: 'Roboto';
font-display: swap;
font-weight: 100 900;
- src: url('font/roboto-vietnamese.woff2') format('woff2');
+ src: url(../font/roboto-vietnamese.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301,
U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
diff --git a/ui/common/css/theme/board/_board-2d.scss b/ui/common/css/theme/board/_board-2d.scss
index 8eaca60bfd60..305a4eea4202 100644
--- a/ui/common/css/theme/board/_board-2d.scss
+++ b/ui/common/css/theme/board/_board-2d.scss
@@ -8,7 +8,7 @@
$file-name: if($name-override, $name-override, $name);
$dir-name: 'board#{if($file-ext == "svg", "/svg", "")}';
&::before {
- background-image: img-url('#{$dir-name}/#{$file-name}.#{$file-ext}');
+ background-image: url(../images/#{$dir-name}/#{$file-name}.#{$file-ext});
}
}
diff --git a/ui/common/css/theme/board/_board-3d.scss b/ui/common/css/theme/board/_board-3d.scss
index f3a38775e7ca..c648069af8d7 100644
--- a/ui/common/css/theme/board/_board-3d.scss
+++ b/ui/common/css/theme/board/_board-3d.scss
@@ -37,7 +37,7 @@ cg-board::before {
@each $name, $board-theme in $board-themes-3d {
body[data-board3d='#{$name}'] .is3d .main-board {
cg-board::before {
- background-image: img-url('staunton/board/#{$name}.png');
+ background-image: url(../images/staunton/board/#{$name}.png);
}
$coord-color-white: map-get($board-theme, coord-color-white);
@@ -72,7 +72,7 @@ $piece-files: (
@each $type in 'Pawn', 'Bishop', 'Knight', 'Rook', 'Queen', 'King' {
body[data-piece-set3d='#{$name}'] .main-board .#{to-lower-case($type)}.#{to-lower-case($color)},
body[data-piece-set3d='#{$name}'] .no-square .#{to-lower-case($type)}.#{to-lower-case($color)} {
- background-image: img-url('staunton/piece/#{$name}/#{$color}-#{$type}.png');
+ background-image: url(../images/staunton/piece/#{$name}/#{$color}-#{$type}.png);
}
}
}
@@ -83,7 +83,7 @@ $piece-files: (
.main-board
.orientation-#{$orientation}
.#{to-lower-case($type)}.#{to-lower-case($color)} {
- background-image: img-url('staunton/piece/#{$name}/#{$color}-#{$type}-Flipped.png');
+ background-image: url(../images/staunton/piece/#{$name}/#{$color}-#{$type}-Flipped.png);
}
}
}
diff --git a/ui/common/css/vendor/_multiple-select.scss b/ui/common/css/vendor/_multiple-select.scss
index 3df88659fc12..dabd6ab63ecf 100644
--- a/ui/common/css/vendor/_multiple-select.scss
+++ b/ui/common/css/vendor/_multiple-select.scss
@@ -46,11 +46,11 @@
right: 0;
width: 20px;
height: 25px;
- background: url('../images/icons/multiple-select.png') left top no-repeat;
+ background: url(../images/icons/multiple-select.png) left top no-repeat;
}
.ms-choice > div.open {
- background: url('../images/icons/multiple-select.png') right top no-repeat;
+ background: url(../images/icons/multiple-select.png) right top no-repeat;
}
.ms-drop {
@@ -98,7 +98,7 @@
border: $border;
border-radius: 0;
box-shadow: none;
- background: img-url('../images/icons/multiple-select.png') no-repeat 100% -22px;
+ background: url(../images/icons/multiple-select.png) no-repeat 100% -22px;
}
.ms-drop ul {
diff --git a/ui/common/package.json b/ui/common/package.json
index 24f762935da1..2109a2fb2c01 100644
--- a/ui/common/package.json
+++ b/ui/common/package.json
@@ -22,11 +22,5 @@
"debounce-promise": "^3.1.2",
"lichess-pgn-viewer": "^2.3.0",
"tablesort": "^5.3.0"
- },
- "build": {
- "hash": {
- "glob": "/public/font/*.woff2",
- "update": "css/theme/font-face.css"
- }
}
}
diff --git a/ui/common/src/dialog.ts b/ui/common/src/dialog.ts
index b7c2cb454db2..62a3a8c8f77d 100644
--- a/ui/common/src/dialog.ts
+++ b/ui/common/src/dialog.ts
@@ -1,7 +1,7 @@
import { onInsert, looseH as h, type VNode, type Attrs, type LooseVNodes } from './snabbdom';
import { isTouchDevice } from './device';
import { escapeHtml, frag, $as } from './common';
-import { eventJanitor } from './event';
+import { Janitor } from './event';
import * as xhr from './xhr';
import * as licon from './licon';
import { pubsub } from './pubsub';
@@ -56,7 +56,7 @@ export type Action =
| { selector?: string; event?: string | string[]; listener: ActionListener }
| { selector?: string; event?: string | string[]; result: string };
-// Safari versions before 15.4 need a polyfill for dialog. this "ready" promise resolves when that's loaded
+// Safari versions before 15.4 need a polyfill for dialog
site.load.then(async () => {
window.addEventListener('resize', onResize);
if (!window.HTMLDialogElement)
@@ -222,8 +222,8 @@ export function snabDialog(o: SnabDialogOpts): VNode {
class DialogWrapper implements Dialog {
private resolve?: (dialog: Dialog) => void;
- private actionEvents = eventJanitor();
- private dialogEvents = eventJanitor();
+ private actionEvents = new Janitor();
+ private dialogEvents = new Janitor();
private observer: MutationObserver = new MutationObserver(list => {
for (const m of list)
if (m.type === 'childList')
@@ -245,7 +245,7 @@ class DialogWrapper implements Dialog {
const justThen = Date.now();
const cancelOnInterval = (e: PointerEvent) => {
- if (Date.now() - justThen < 200 || !dialog.isConnected) return;
+ if (Date.now() - justThen < 200) return; // removed isConnected() check. we catch leaks this way
const r = dialog.getBoundingClientRect();
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom)
this.close('cancel');
@@ -309,7 +309,7 @@ class DialogWrapper implements Dialog {
// attach/reattach existing listeners or provide a set of new ones
updateActions = (actions = this.o.actions) => {
- this.actionEvents.removeAll();
+ this.actionEvents.cleanup();
if (!actions) return;
for (const a of Array.isArray(actions) ? actions : [actions]) {
for (const event of Array.isArray(a.event) ? a.event : a.event ? [a.event] : ['click']) {
@@ -343,8 +343,8 @@ class DialogWrapper implements Dialog {
if ('hashed' in css) site.asset.removeCssPath(css.hashed);
else if ('url' in css) site.asset.removeCss(css.url);
}
- this.actionEvents.removeAll();
- this.dialogEvents.removeAll();
+ this.actionEvents.cleanup();
+ this.dialogEvents.cleanup();
};
}
diff --git a/ui/common/src/event.ts b/ui/common/src/event.ts
index cdf9122eea75..b64c813e8d4f 100644
--- a/ui/common/src/event.ts
+++ b/ui/common/src/event.ts
@@ -1,31 +1,20 @@
-// stale listeners can cause memory loss, dry skin, and tooth decay
+export class Janitor {
+ private cleanupTasks: (() => void)[] = [];
-export interface EventJanitor {
- addListener: (
+ addListener(
target: T,
type: string,
listener: (this: T, ev: E) => any,
options?: boolean | AddEventListenerOptions,
- ) => void;
- removeAll: () => void;
-}
-
-export function eventJanitor(): EventJanitor {
- const removers: (() => void)[] = [];
-
- return {
- addListener: (
- target: T,
- type: string,
- listener: (this: T, ev: E) => any,
- options?: boolean | AddEventListenerOptions,
- ): void => {
- target.addEventListener(type, listener, options);
- removers.push(() => target.removeEventListener(type, listener, options));
- },
- removeAll: () => {
- removers.forEach(r => r());
- removers.length = 0;
- },
- };
+ ): void {
+ target.addEventListener(type, listener, options);
+ this.cleanupTasks.push(() => target.removeEventListener(type, listener, options));
+ }
+ addCleanupTask(task: () => void): void {
+ this.cleanupTasks.push(task);
+ }
+ cleanup(): void {
+ for (const task of this.cleanupTasks) task();
+ this.cleanupTasks.length = 0;
+ }
}
diff --git a/ui/common/src/notification.ts b/ui/common/src/notification.ts
index c655adaf0484..3f54e34f968c 100644
--- a/ui/common/src/notification.ts
+++ b/ui/common/src/notification.ts
@@ -19,7 +19,7 @@ function notify(msg: string | (() => string)) {
store.set('' + Date.now());
if ($.isFunction(msg)) msg = msg();
const notification = new Notification('lichess.org', {
- icon: site.asset.url('logo/lichess-favicon-256.png', { version: false }),
+ icon: site.asset.url('logo/lichess-favicon-256.png'),
body: msg,
});
notification.onclick = () => window.focus();
diff --git a/ui/common/src/objectStorage.ts b/ui/common/src/objectStorage.ts
index b9f7113cbdb4..089d8081111b 100644
--- a/ui/common/src/objectStorage.ts
+++ b/ui/common/src/objectStorage.ts
@@ -1,33 +1,87 @@
-/*
-usage:
- const store = await objectStorage({store: 'my-store'});
- const value = await store.get(key);
-*/
-
-export interface DbInfo {
- store: string;
- db?: string; // default `${store}--db`
- version?: number; // default 1
- upgrade?: (e: IDBVersionChangeEvent, store?: IDBObjectStore) => void;
-}
-
-export interface ObjectStorage {
- list(): Promise;
- get(key: K): Promise;
- getMany(keys?: IDBKeyRange): Promise;
- put(key: K, value: V): Promise; // returns key
- count(key?: K | IDBKeyRange): Promise;
- remove(key: K | IDBKeyRange): Promise;
- clear(): Promise; // remove all
- cursor(range?: IDBKeyRange, dir?: IDBCursorDirection): Promise;
- txn(mode: IDBTransactionMode): IDBTransaction; // do anything else
-}
-
+/** promisify [indexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) and add nothing
+ * ### basic usage:
+ * ```ts
+ * import { objectStorage } from 'common/objectStorage';
+ *
+ * const store = await objectStorage({ store: 'store' });
+ * const value = await store.get('someKey') ?? 10;
+ * await store.put('someOtherKey', value + 1);
+ * ```
+ * ### cursors/indices:
+ * ```ts
+ * import { objectStorage, range } from 'common/objectStorage';
+ *
+ * const store = await objectStorage({
+ * store: 'store',
+ * indices: [{ name: 'size', keyPath: 'size' }]
+ * });
+ *
+ * await store.readCursor({ index: 'size', query: range({ above: 5 }) }, obj => {
+ * console.log(obj);
+ * });
+ *
+ * await store.writeCursor(
+ * { index: 'size', query: range({ min: 4, max: 12 }) },
+ * async ({ value, update, delete }) => {
+ * if (value.size < 10) await update({ ...value, size: value.size + 1 });
+ * else await delete();
+ * }
+ * );
+ * ```
+ * ### upgrade/migration:
+ * ```ts
+ * import { objectStorage } from 'common/objectStorage';
+ *
+ * const upgradedStore = await objectStorage({
+ * store: 'upgradedStore',
+ * version: 2,
+ * upgrade: (e, store) => {
+ * // raw idb needed here
+ * if (e.oldVersion < 2) store.createIndex('color', 'color'); // manual index creation
+ * const req = store.openCursor();
+ * req.onsuccess = cursorEvent => {
+ * const cursor = (cursorEvent.target as IDBRequest).result;
+ * if (!cursor) return;
+ * cursor.update(transformYourObject(e.oldVersion, cursor.value));
+ * cursor.continue();
+ * };
+ * }
+ * });
+ * ```
+ * other needs can be met by raw idb calls on the `txn` function result
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
+ */
export async function objectStorage(
dbInfo: DbInfo,
): Promise> {
const db = await dbConnect(dbInfo);
+ return {
+ list: () => actionPromise(() => objectStore('readonly').getAllKeys()),
+ get: (key: K) => actionPromise(() => objectStore('readonly').get(key)),
+ getMany: (keys?: IDBKeyRange) => actionPromise(() => objectStore('readonly').getAll(keys)),
+ put: (key: K, value: V) => actionPromise(() => objectStore('readwrite').put(value, key)),
+ count: (key?: K | IDBKeyRange) => actionPromise(() => objectStore('readonly').count(key)),
+ remove: (key: K | IDBKeyRange) => actionPromise(() => objectStore('readwrite').delete(key)),
+ clear: () => actionPromise(() => objectStore('readwrite').clear()),
+ txn: (mode: IDBTransactionMode) => db.transaction(dbInfo.store, mode),
+ cursor,
+ readCursor: async (opts: CursorOpts, it: (v: V) => any): Promise => {
+ for await (const c of cursor(opts, 'readonly')) {
+ await it(c.value);
+ }
+ },
+ writeCursor: async (opts: CursorOpts, it: WriteCursorCallback): Promise => {
+ for await (const c of cursor(opts, 'readwrite')) {
+ await it({
+ value: c.value,
+ update: (v: V) => actionPromise(() => c.update(v)),
+ delete: () => actionPromise(() => c.delete()),
+ });
+ }
+ },
+ };
+
function objectStore(mode: IDBTransactionMode) {
return db.transaction(dbInfo.store, mode).objectStore(dbInfo.store);
}
@@ -40,70 +94,162 @@ export async function objectStorage(
});
}
- return {
- list: () => actionPromise(() => objectStore('readonly').getAllKeys()),
- get: (key: K) => actionPromise(() => objectStore('readonly').get(key)),
- getMany: (keys?: IDBKeyRange) => actionPromise(() => objectStore('readonly').getAll(keys)),
- put: (key: K, value: V) => actionPromise(() => objectStore('readwrite').put(value, key)),
- count: (key?: K | IDBKeyRange) => actionPromise(() => objectStore('readonly').count(key)),
- remove: (key: K | IDBKeyRange) => actionPromise(() => objectStore('readwrite').delete(key)),
- clear: () => actionPromise(() => objectStore('readwrite').clear()),
- cursor: (keys?: IDBKeyRange, dir?: IDBCursorDirection) =>
- actionPromise(() => objectStore('readonly').openCursor(keys, dir)).then(
- cursor => cursor ?? undefined,
- ),
- txn: (mode: IDBTransactionMode) => db.transaction(dbInfo.store, mode),
- };
+ function cursor(opts: CursorOpts = {}, mode: IDBTransactionMode): AsyncGenerator {
+ const store = objectStore(mode);
+ const req = opts.index
+ ? store.index(opts.index).openCursor(opts.query, opts.dir)
+ : store.openCursor(opts.query, opts.dir);
+ return (async function* () {
+ while (true) {
+ const cursor = await actionPromise(() => req);
+ if (!cursor) break;
+ yield cursor;
+ cursor.continue();
+ }
+ })();
+ }
+}
+
+export function range(range: {
+ min?: K; // closed lower bound
+ max?: K; // closed upper bound
+ above?: K; // open lower bound
+ below?: K; // open upper bound
+}): IDBKeyRange | undefined {
+ const lowerOpen = 'above' in range;
+ const upperOpen = 'below' in range;
+ const lower = range.above ?? range.min;
+ const upper = range.below ?? range.max;
+ if (lower !== undefined && upper !== undefined)
+ return IDBKeyRange.bound(lower, upper, lowerOpen, upperOpen);
+ if (lower !== undefined) return IDBKeyRange.lowerBound(lower, lowerOpen);
+ if (upper !== undefined) return IDBKeyRange.upperBound(upper, upperOpen);
+ return undefined;
+}
+
+export async function nonEmptyStore(info: DbInfo): Promise {
+ const dbName = info.db ?? info.store;
+ if (window.indexedDB.databases) {
+ const dbs = await window.indexedDB.databases();
+ if (dbs.every(db => db.name !== dbName)) return false;
+ }
+
+ return new Promise(resolve => {
+ const request = window.indexedDB.open(dbName);
+
+ request.onerror = () => resolve(false);
+ request.onsuccess = (e: Event) => {
+ const db = (e.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains(info.store)) {
+ db.close();
+ resolve(false);
+ }
+ const cursorReq = db.transaction(info.store, 'readonly').objectStore(info.store).openCursor();
+ cursorReq.onsuccess = () => {
+ db.close();
+ resolve(Boolean(cursorReq.result));
+ };
+ cursorReq.onerror = () => {
+ db.close();
+ resolve(false);
+ };
+ };
+ });
+}
+
+export interface DbInfo {
+ /** name of the object store */
+ store: string;
+ /** defaults to store name because you should aim for one store per db to minimize version
+ * upgrade callback complexity. raw idb is best for versioned multi-store dbs */
+ db?: string;
+ /** db version (default: 1), your upgrade callback receives e.oldVersion */
+ version?: number;
+ /** indices for the object store, changes must increment version */
+ indices?: { name: string; keyPath: string | string[]; options?: IDBIndexParameters }[];
+ /** upgrade function to handle schema changes @see objectStorage */
+ upgrade?: (e: IDBVersionChangeEvent, store?: IDBObjectStore) => void;
}
-export async function dbConnect(dbInfo: DbInfo): Promise {
- const dbName = dbInfo?.db || `${dbInfo.store}--db`;
+export type WriteCursorCallback = {
+ (it: {
+ /** just the value */
+ value: V;
+ /** await this to modify the store value */
+ update: (v: V) => Promise;
+ /** await this to delete the entry from the store. iteration is not affected */
+ delete: () => Promise;
+ }): any;
+};
+
+export interface CursorOpts {
+ /** supply an index name to use for the cursor, otherwise iterate the store */
+ index?: string;
+ /** The key range to filter the cursor results */
+ query?: IDBKeyRange | IDBValidKey | null;
+ /** 'prev', 'prevunique', 'next', or 'nextunique' (default is 'next')*/
+ dir?: IDBCursorDirection;
+}
+
+export interface ObjectStorage {
+ /** list all keys in the object store */
+ list(): Promise;
+ /** retrieve a value by key */
+ get(key: K): Promise;
+ /** retrieve multiple values by key range, or all values if omitted */
+ getMany(keys?: IDBKeyRange): Promise;
+ /** put a value into the store under a specific key and return that key */
+ put(key: K, value: V): Promise;
+ /** count the number of entries matching a key or range. Count all values if omitted */
+ count(key?: K | IDBKeyRange): Promise;
+ /** remove value(s) by key or key range */
+ remove(key: K | IDBKeyRange): Promise;
+ /** clear all entries from the object store */
+ clear(): Promise;
+ /** initiate a database transaction */
+ txn(mode: IDBTransactionMode): IDBTransaction;
+ /** create a raw cursor to iterate over an index or store's records */
+ cursor(opts: CursorOpts, mode: IDBTransactionMode): AsyncGenerator;
+ /** read records using an idb cursor via simple value callback. resolves when iteration completes */
+ readCursor(o: CursorOpts, it: (v: V) => any): Promise;
+ /** read, write, or delete records via cursor callback. promise resolves when iteration is done */
+ writeCursor(o: CursorOpts, it: WriteCursorCallback): Promise;
+}
+
+async function dbConnect(info: DbInfo): Promise {
+ const dbName = info.db ?? info.store;
return new Promise((resolve, reject) => {
- const result = window.indexedDB.open(dbName, dbInfo?.version ?? 1);
+ const result = window.indexedDB.open(dbName, info?.version ?? 1);
result.onsuccess = (e: Event) => resolve((e.target as IDBOpenDBRequest).result);
result.onerror = (e: Event) => reject((e.target as IDBOpenDBRequest).error ?? 'IndexedDB Unavailable');
result.onupgradeneeded = (e: IDBVersionChangeEvent) => {
const db = (e.target as IDBOpenDBRequest).result;
const txn = (e.target as IDBOpenDBRequest).transaction;
- const store = db.objectStoreNames.contains(dbInfo.store)
- ? txn!.objectStore(dbInfo.store)
- : db.createObjectStore(dbInfo.store);
+ const store = db.objectStoreNames.contains(info.store)
+ ? txn!.objectStore(info.store)
+ : db.createObjectStore(info.store);
- dbInfo.upgrade?.(e, store);
- };
- });
-}
+ const existing = new Set(store.indexNames);
-export async function nonEmptyStore(info: DbInfo): Promise {
- const dbName = info?.db || `${info.store}--db`;
- return new Promise(resolve => {
- const dbs = window.indexedDB.databases?.(); // not all browsers support
- if (!dbs) storeExists();
- else
- dbs.then(dbList => {
- if (dbList.every(db => db.name !== dbName)) resolve(false);
- else storeExists();
- });
-
- function storeExists() {
- const request = window.indexedDB.open(dbName);
-
- request.onerror = () => resolve(false);
- request.onsuccess = (e: Event) => {
- const db = (e.target as IDBOpenDBRequest).result;
- try {
- const cursor = db.transaction(info.store, 'readonly').objectStore(info.store).openCursor();
- cursor.onsuccess = () => {
- db.close();
- resolve(cursor.result !== null);
- };
- } catch {
- db.close();
- resolve(false);
+ info.indices?.forEach(({ name, keyPath, options }) => {
+ if (!existing.has(name)) store.createIndex(name, keyPath, options);
+ else {
+ const idx = store.index(name);
+ if (
+ idx.keyPath !== keyPath ||
+ idx.unique !== !!options?.unique ||
+ idx.multiEntry !== !!options?.multiEntry
+ ) {
+ store.deleteIndex(name);
+ store.createIndex(name, keyPath, options);
+ }
}
- };
- }
+ existing.delete(name);
+ });
+ existing.forEach(indexName => store.deleteIndex(indexName));
+ info.upgrade?.(e, store);
+ };
});
}
diff --git a/ui/common/src/permalog.ts b/ui/common/src/permalog.ts
index 0035a7ac66d8..a1f119e4ac38 100644
--- a/ui/common/src/permalog.ts
+++ b/ui/common/src/permalog.ts
@@ -1,22 +1,22 @@
import { objectStorage, type ObjectStorage, type DbInfo } from './objectStorage';
-export const log: LichessLog = makeLog();
-
-interface LichessLog {
+export interface PermaLog {
(...args: any[]): Promise;
clear(): Promise;
get(): Promise;
}
-function makeLog(): LichessLog {
- const dbInfo: DbInfo = {
+export const log: PermaLog = makeLog(
+ {
db: 'log--db',
store: 'log',
version: 3,
upgrade: (_: any, store: IDBObjectStore) => store?.clear(), // blow it all away when we rev version
- };
- const defaultLogWindow = 100;
+ },
+ parseInt(localStorage.getItem('log.window') || '100'),
+);
+export function makeLog(dbInfo: DbInfo, windowSize: number): PermaLog {
let store: ObjectStorage;
let resolveReady: () => void;
let lastKey = 0;
@@ -30,23 +30,12 @@ function makeLog(): LichessLog {
objectStorage(dbInfo)
.then(async s => {
- try {
- const keys = await s.list();
- const window = parseInt(localStorage.getItem('log.window') ?? `${defaultLogWindow}`);
- const constrained = window >= 0 && window <= 10000 ? window : defaultLogWindow;
- if (keys.length > constrained) {
- await s.remove(IDBKeyRange.upperBound(keys[keys.length - constrained], true));
- }
- store = s;
- } catch (e) {
- console.error(e);
- s.clear();
- }
+ store = s;
resolveReady();
})
.catch(e => {
console.error(e);
- window.indexedDB.deleteDatabase(dbInfo.db!);
+ window.indexedDB.deleteDatabase(dbInfo.db ?? dbInfo.store);
resolveReady();
});
@@ -54,11 +43,11 @@ function makeLog(): LichessLog {
return !val || typeof val === 'string' ? String(val) : JSON.stringify(val);
}
- const log: LichessLog = (...args: any[]) => {
- console.log(...args);
- const msg = `#${site.info ? `${site.info.commit.substring(0, 7)} - ` : ''}${args
- .map(stringify)
- .join(' ')}`;
+ const log: PermaLog = (...args: any[]) => {
+ if (dbInfo.store === 'log') console.log(...args);
+ const msg =
+ (dbInfo.store === 'log' && site.info ? `#${site.info.commit.substring(0, 7)} - ` : '') +
+ args.map(stringify).join(' ');
let nextKey = Date.now();
if (nextKey === lastKey) {
nextKey += drift;
@@ -79,6 +68,17 @@ function makeLog(): LichessLog {
log.get = async (): Promise => {
await ready;
if (!store) return '';
+ try {
+ const keys = await store.list();
+ if (windowSize >= 0 && keys.length > windowSize) {
+ await store.remove(IDBKeyRange.upperBound(keys[keys.length - windowSize], true));
+ }
+ } catch (e) {
+ console.error(e);
+ store.clear();
+ window.indexedDB.deleteDatabase(dbInfo.db ?? dbInfo.store);
+ return '';
+ }
const [keys, vals] = await Promise.all([store.list(), store.getMany()]);
return keys.map((k, i) => `${new Date(k).toISOString().replace(/[TZ]/g, ' ')}${vals[i]}`).join('\n');
};
diff --git a/ui/dasher/css/_board.scss b/ui/dasher/css/_board.scss
index e68f651f7c7f..9fc994c9e6fb 100644
--- a/ui/dasher/css/_board.scss
+++ b/ui/dasher/css/_board.scss
@@ -86,10 +86,10 @@
$file-name: if($name-override, $name-override, $name);
@if $file-name == 'newspaper' {
- background-image: img-url('board/svg/#{$file-name}.svg');
+ background-image: url(../images/board/svg/#{$file-name}.svg);
background-size: 256px;
} @else {
- background-image: img-url('board/#{$file-name}.thumbnail.#{$file-ext}');
+ background-image: url(../images/board/#{$file-name}.thumbnail.#{$file-ext});
}
}
}
@@ -98,7 +98,7 @@
&.d3 {
@each $name in map-keys($board-themes-3d) {
.#{$name} {
- background-image: img-url('staunton/board/#{$name}.thumbnail.png');
+ background-image: url(../images/staunton/board/#{$name}.thumbnail.png);
}
}
}
diff --git a/ui/dasher/src/background.ts b/ui/dasher/src/background.ts
index 6d4672a8c98e..0d856871ff12 100644
--- a/ui/dasher/src/background.ts
+++ b/ui/dasher/src/background.ts
@@ -86,7 +86,7 @@ export class BackgroundCtrl extends PaneCtrl {
private get = () => this.data.current;
private getImage = () => this.data.image;
private setImage = (i: string) => {
- this.data.image = i;
+ this.data.image = i.startsWith('/assets/') ? i.slice(8) : i;
xhrTextRaw('/pref/bgImg', { body: xhrForm({ bgImg: i }), method: 'post' })
.then(res => (res.ok ? res.text() : Promise.reject(res.text())))
.then(this.reloadAllTheThings, err => err.then(this.announceFail));
@@ -155,7 +155,7 @@ export class BackgroundCtrl extends PaneCtrl {
'div#images-grid',
{ attrs: { style: `background-image: url(${montageUrl});` } },
gallery.images.map(img => {
- const assetUrl = site.asset.url(img, { version: false });
+ const assetUrl = site.asset.url(img);
const divClass = this.data.image.endsWith(assetUrl) ? '.selected' : '';
return h(`div#${urlId(assetUrl)}${divClass}`, { hook: bind('click', () => setImg(assetUrl)) });
}),
diff --git a/ui/editor/css/_spare.scss b/ui/editor/css/_spare.scss
index 7f2c91b127d8..75062eb406c5 100644
--- a/ui/editor/css/_spare.scss
+++ b/ui/editor/css/_spare.scss
@@ -44,7 +44,7 @@
}
piece {
- background-image: img-url('icons/pointer.svg');
+ background-image: url(../images/icons/pointer.svg);
}
}
@@ -60,7 +60,7 @@
}
piece {
- background-image: img-url('icons/trash.svg');
+ background-image: url(../images/icons/trash.svg);
}
}
}
diff --git a/ui/learn/css/_side-home.scss b/ui/learn/css/_side-home.scss
index 255dc50bf419..57455d71ff1f 100644
--- a/ui/learn/css/_side-home.scss
+++ b/ui/learn/css/_side-home.scss
@@ -26,7 +26,7 @@
display: block;
width: 200px;
height: 200px;
- background: img-url('learn/brutal-helm.svg');
+ background: url(../images/learn/brutal-helm.svg);
margin: auto;
opacity: 0.8;
}
@@ -70,7 +70,7 @@
height: 100%;
background: #1566b2;
border-radius: 0 5px 5px 0;
- background-image: img-url('grain.png');
+ background-image: url(../images/grain.png);
transform: translateX(-100px);
animation:
animatedBackground 50s linear infinite,
diff --git a/ui/lobby/css/_setup.scss b/ui/lobby/css/_setup.scss
index 2d1a2e6b36b9..e9723ff45891 100644
--- a/ui/lobby/css/_setup.scss
+++ b/ui/lobby/css/_setup.scss
@@ -111,11 +111,11 @@ $c-slider: $c-setup;
}
&.white i {
- background-image: img-url('../piece/cburnett/wK.svg');
+ background-image: url(../piece/cburnett/wK.svg);
}
&.black i {
- background-image: img-url('../piece/cburnett/bK.svg');
+ background-image: url(../piece/cburnett/bK.svg);
}
&.random {
@@ -124,7 +124,7 @@ $c-slider: $c-setup;
padding: 10px;
i {
- background-image: img-url('wbK.svg');
+ background-image: url(../images/wbK.svg);
background-size: 65px 65px;
width: 65px;
height: 65px;
diff --git a/ui/puz/css/_side.scss b/ui/puz/css/_side.scss
index 978448f28e58..ef1dc7d6e7dd 100644
--- a/ui/puz/css/_side.scss
+++ b/ui/puz/css/_side.scss
@@ -25,7 +25,7 @@
content: ' ';
width: 5rem;
height: 5rem;
- background-image: img-url('icons/tornado.svg');
+ background-image: url(../images/icons/tornado.svg);
background-repeat: no-repeat;
margin-inline-end: 1.5rem;
}
diff --git a/ui/racer/css/_home.scss b/ui/racer/css/_home.scss
index 48fe0f2b8615..bf552f558713 100644
--- a/ui/racer/css/_home.scss
+++ b/ui/racer/css/_home.scss
@@ -1,5 +1,5 @@
.racer-home {
- background-image: img-url('racer/checkered-flag-grey.svg');
+ background-image: url(../images/racer/checkered-flag-grey.svg);
background-size: 200%;
background-position: center;
diff --git a/ui/racer/css/_racer.scss b/ui/racer/css/_racer.scss
index ec5a7255f499..e24b8a83573c 100644
--- a/ui/racer/css/_racer.scss
+++ b/ui/racer/css/_racer.scss
@@ -45,7 +45,7 @@
}
.puz-side__top::before {
- background-image: img-url('racer/checkered-flag.svg');
+ background-image: url(../images/racer/checkered-flag.svg);
}
.puz-side__start {
@@ -68,7 +68,7 @@
}
&::before {
content: '';
- background-image: img-url('racer/gear-stick.svg');
+ background-image: url(../images/racer/gear-stick.svg);
width: 4ch;
height: 4ch;
margin-bottom: 0.6ch;
diff --git a/ui/site/package.json b/ui/site/package.json
index 2e1787a83c88..9a51001c97b5 100644
--- a/ui/site/package.json
+++ b/ui/site/package.json
@@ -26,10 +26,14 @@
"node_modules/dialog-polyfill/dist/dialog-polyfill.esm.js": "/public/npm"
},
"hash": [
- "/public/lifat/background/montage*.webp",
- "/public/npm/*",
+ "/public/font/*.woff2",
+ "/public/images/**",
"/public/javascripts/**",
- "/public/piece-css/*"
+ "/public/lifat/background/**/*.webp",
+ "/public/npm/*",
+ "/public/piece/**",
+ "/public/piece-css/*",
+ "/public/sound/**/*.mp3"
]
}
}
diff --git a/ui/site/src/asset.ts b/ui/site/src/asset.ts
index 2df8be8802ce..06de84453116 100644
--- a/ui/site/src/asset.ts
+++ b/ui/site/src/asset.ts
@@ -7,9 +7,13 @@ const assetVersion = memoize(() => document.body.getAttribute('data-asset-versio
export const url = (path: string, opts: AssetUrlOpts = {}) => {
const base = opts.documentOrigin ? window.location.origin : opts.pathOnly ? '' : baseUrl();
- const version = opts.version === false ? '' : `_${opts.version ?? assetVersion()}/`;
- const hash = opts.version !== false && site.manifest.hashed[path];
- return `${base}/assets/${hash ? asHashed(path, hash) : `${version}${path}`}`;
+ const pathVersion = !opts.pathVersion
+ ? ''
+ : opts.pathVersion === true
+ ? `_${assetVersion()}/`
+ : `_${opts.pathVersion}/`;
+ const hash = !pathVersion && site.manifest.hashed[path];
+ return `${base}/assets/${hash ? asHashed(path, hash) : `${pathVersion}${path}`}`;
};
function asHashed(path: string, hash: string) {
@@ -19,11 +23,11 @@ function asHashed(path: string, hash: string) {
}
// bump flairs version if a flair is changed only (not added or removed)
-export const flairSrc = (flair: Flair) => url(`flair/img/${flair}.webp`, { version: '_____2' });
+export const flairSrc = (flair: Flair) => url(`flair/img/${flair}.webp`, { pathVersion: '_____2' });
export const loadCss = (href: string, key?: string): Promise => {
return new Promise(resolve => {
- href = url(href, { version: false });
+ href = href.startsWith('https:') ? href : url(href);
if (document.head.querySelector(`link[href="${href}"]`)) return resolve();
const el = document.createElement('link');
@@ -58,15 +62,13 @@ export const loadIife = (u: string, opts: AssetUrlOpts = {}): Promise => {
};
export async function loadEsm(name: string, opts: EsmModuleOpts = {}): Promise {
- opts = { ...opts, version: opts.version ?? false };
-
const module = await import(url(opts.npm ? jsModule(name, 'npm/') : jsModule(name), opts));
const initializer = module.initModule ?? module.default;
return opts.npm && !opts.init ? initializer : initializer(opts.init);
}
export const loadEsmPage = async (name: string) => {
- const modulePromise = import(url(jsModule(name), { version: false }));
+ const modulePromise = import(url(jsModule(name)));
const dataScript = document.getElementById('page-init-data');
const opts = dataScript && JSON.parse(dataScript.innerHTML);
dataScript?.remove();
diff --git a/ui/site/src/serviceWorker.ts b/ui/site/src/serviceWorker.ts
index e1e2c08635f9..659b384e447d 100644
--- a/ui/site/src/serviceWorker.ts
+++ b/ui/site/src/serviceWorker.ts
@@ -4,10 +4,7 @@ import { storage } from 'common/storage';
export default async function () {
if (!('serviceWorker' in navigator && 'Notification' in window && 'PushManager' in window)) return;
- const workerUrl = new URL(
- assetUrl(jsModule('serviceWorker'), { pathOnly: true, version: false }),
- self.location.href,
- );
+ const workerUrl = new URL(assetUrl(jsModule('serviceWorker'), { pathOnly: true }), self.location.href);
workerUrl.searchParams.set('asset-url', document.body.getAttribute('data-asset-url')!);
let newSub: PushSubscription | undefined = undefined;
try {
diff --git a/ui/site/src/sound.ts b/ui/site/src/sound.ts
index 2ecbcd272f2d..1eb516a3bb6b 100644
--- a/ui/site/src/sound.ts
+++ b/ui/site/src/sound.ts
@@ -59,7 +59,7 @@ export default new (class implements SoundI {
}
url(name: Name): string {
- return site.asset.url(`sound/${name}`, { version: '_____1' });
+ return site.asset.url(`sound/${name}`); //, { pathVersion: '_____1' });
}
async play(name: Name, volume = 1): Promise {
diff --git a/ui/storm/css/_end.scss b/ui/storm/css/_end.scss
index 838a59b7d507..ebbf66a59ee4 100644
--- a/ui/storm/css/_end.scss
+++ b/ui/storm/css/_end.scss
@@ -48,7 +48,7 @@
width: 7em;
height: 7em;
margin: 3em;
- background-image: img-url('icons/tornado-white.svg');
+ background-image: url(../images/icons/tornado-white.svg);
background-size: cover;
}
}
diff --git a/ui/storm/css/_play-again.scss b/ui/storm/css/_play-again.scss
index ed93a86a2e99..e4905f82aac5 100644
--- a/ui/storm/css/_play-again.scss
+++ b/ui/storm/css/_play-again.scss
@@ -43,7 +43,7 @@
content: ' ';
width: 7rem;
height: 7rem;
- background-image: img-url('icons/tornado-white.svg');
+ background-image: url(../images/icons/tornado-white.svg);
background-size: cover;
opacity: 0.8;
transition: opacity 0.5s;
diff --git a/ui/tournament/css/_leaderboard.scss b/ui/tournament/css/_leaderboard.scss
index de0a2af2bfac..0956652dc23d 100644
--- a/ui/tournament/css/_leaderboard.scss
+++ b/ui/tournament/css/_leaderboard.scss
@@ -63,7 +63,7 @@ $user-list-width: 35ch;
@include inline-start(15px);
width: 67px;
height: 80px;
- background: img-url('trophy/shield-gold.png') no-repeat;
+ background: url(../images/trophy/shield-gold.png) no-repeat;
background-size: contain;
font-family: 'lichess' !important;
font-size: 40px;
diff --git a/ui/voice/package.json b/ui/voice/package.json
index 485f7b84105b..32ed1cd2bec9 100644
--- a/ui/voice/package.json
+++ b/ui/voice/package.json
@@ -19,10 +19,7 @@
"make-grammar": "node --experimental-strip-types --no-warnings makeGrammar.mts"
},
"build": {
- "bundle": [
- "src/**/voice.*.ts",
- "src/move/voice.move.ts"
- ],
+ "bundle": "src/**/voice.*.ts",
"sync": {
"grammar/**": "/public/compiled/grammar"
},
diff --git a/ui/voice/src/mic.ts b/ui/voice/src/mic.ts
index d08525173585..fbc2b6647e31 100644
--- a/ui/voice/src/mic.ts
+++ b/ui/voice/src/mic.ts
@@ -163,7 +163,7 @@ export class Mic implements Microphone {
}
this.broadcast('Loading...');
- const modelUrl = site.asset.url(models.get(this.lang)!, { version: false });
+ const modelUrl = site.asset.url(models.get(this.lang)!);
const downloadAsync = this.downloadModel(`/vosk/${modelUrl.replace(/[\W]/g, '_')}`);
const audioAsync = this.initAudio();
@@ -217,7 +217,7 @@ export class Mic implements Microphone {
if ((await voskStore.count(`${emscriptenPath}/extracted.ok`)) > 0) return;
const modelBlob: ArrayBuffer | undefined = await new Promise((resolve, reject) => {
this.download = new XMLHttpRequest();
- this.download.open('GET', site.asset.url(models.get(this.lang)!, { version: false }), true);
+ this.download.open('GET', site.asset.url(models.get(this.lang)!), true);
this.download.responseType = 'arraybuffer';
this.download.onerror = _ => reject('Failed. See console');
this.download.onabort = _ => reject('Aborted');
@@ -249,7 +249,7 @@ export class Mic implements Microphone {
timestamp: now,
mode: 33188,
});
- voskStore.txn('readwrite').objectStore('FILE_DATA').index('timestamp');
+ voskStore.txn('readwrite').objectStore('FILE_DATA').index('timestamp'); // just to throw on failure
}
private soundListener = (event: 'start' | 'stop') => {