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') => {