diff --git a/.github/workflows/coverage_test.yml b/.github/workflows/coverage_test.yml
index f0019ee7e..bbbf1e403 100644
--- a/.github/workflows/coverage_test.yml
+++ b/.github/workflows/coverage_test.yml
@@ -40,6 +40,7 @@ jobs:
pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG
- name: Upload results to Codecov
+ if: github.repository == 'AstrBotDevs/AstrBot'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml
index b6cd9aa2c..916f43f97 100644
--- a/.github/workflows/dashboard_ci.yml
+++ b/.github/workflows/dashboard_ci.yml
@@ -8,6 +8,7 @@ on:
jobs:
build:
+ if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index ccf560435..f80e8588f 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -11,7 +11,7 @@ on:
jobs:
build-nightly-image:
- if: github.event_name == 'schedule'
+ if: github.repository == 'AstrBotDevs/AstrBot' && github.event_name == 'schedule'
runs-on: ubuntu-latest
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -109,7 +109,7 @@ jobs:
run: echo "Test Docker image has been built and pushed successfully"
build-release-image:
- if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
+ if: github.repository == 'AstrBotDevs/AstrBot' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')))
runs-on: ubuntu-latest
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index ff7e79e61..d5faf88a5 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -6,6 +6,7 @@ on:
jobs:
title-format:
+ if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
pull-requests: write
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0cfe18261..09f3c7e27 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -20,6 +20,7 @@ permissions:
jobs:
build-dashboard:
name: Build Dashboard
+ if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-24.04
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
@@ -104,6 +105,7 @@ jobs:
publish-release:
name: Publish GitHub Release
+ if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-24.04
needs:
- build-dashboard
@@ -183,6 +185,7 @@ jobs:
publish-pypi:
name: Publish PyPI
+ if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-24.04
needs:
- publish-release
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index c6c41a890..c0ca4978a 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -18,6 +18,7 @@ on:
jobs:
stale:
+ if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
issues: write
diff --git a/.github/workflows/sync-wiki.yml b/.github/workflows/sync-wiki.yml
index 2fe0d3153..78052c2ed 100644
--- a/.github/workflows/sync-wiki.yml
+++ b/.github/workflows/sync-wiki.yml
@@ -18,6 +18,7 @@ concurrency:
jobs:
sync:
+ if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
contents: read
diff --git a/astrbot/core/computer/tools/fs.py b/astrbot/core/computer/tools/fs.py
index 3863d6f29..b5082b470 100644
--- a/astrbot/core/computer/tools/fs.py
+++ b/astrbot/core/computer/tools/fs.py
@@ -82,14 +82,19 @@ from .permissions import check_admin_permission
@dataclass
class FileUploadTool(FunctionTool):
name: str = "astrbot_upload_file"
- description: str = "Upload a local file to the sandbox. The file must exist on the local filesystem."
+ description: str = (
+ "Transfer a file FROM the host machine INTO the sandbox so that sandbox "
+ "code can access it. Use this when the user sends/attaches a file and you "
+ "need to process it inside the sandbox. The local_path must point to an "
+ "existing file on the host filesystem."
+ )
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"local_path": {
"type": "string",
- "description": "The local file path to upload. This must be an absolute path to an existing file on the local filesystem.",
+ "description": "Absolute path to the file on the host filesystem that will be copied into the sandbox.",
},
# "remote_path": {
# "type": "string",
@@ -143,14 +148,18 @@ class FileUploadTool(FunctionTool):
@dataclass
class FileDownloadTool(FunctionTool):
name: str = "astrbot_download_file"
- description: str = "Download a file from the sandbox. Only call this when user explicitly need you to download a file."
+ description: str = (
+ "Transfer a file FROM the sandbox OUT to the host and optionally send it "
+ "to the user. Use this ONLY when the user asks to retrieve/export a file "
+ "that was created or modified inside the sandbox."
+ )
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"remote_path": {
"type": "string",
- "description": "The path of the file in the sandbox to download.",
+ "description": "Path of the file inside the sandbox to copy out to the host.",
},
"also_send_to_user": {
"type": "boolean",
diff --git a/astrbot/core/platform/sources/kook/kook_types.py b/astrbot/core/platform/sources/kook/kook_types.py
index 7256fbbd4..5efaf2a14 100644
--- a/astrbot/core/platform/sources/kook/kook_types.py
+++ b/astrbot/core/platform/sources/kook/kook_types.py
@@ -1,5 +1,5 @@
import json
-from enum import IntEnum, StrEnum
+from enum import Enum, IntEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -36,7 +36,7 @@ class KookMessageType(IntEnum):
SYSTEM = 255
-class KookModuleType(StrEnum):
+class KookModuleType(str, Enum):
PLAIN_TEXT = "plain-text"
KMARKDOWN = "kmarkdown"
IMAGE = "image"
@@ -311,7 +311,7 @@ class KookMessageSignal(IntEnum):
"""server->client resume ack"""
-class KookChannelType(StrEnum):
+class KookChannelType(str, Enum):
GROUP = "GROUP"
PERSON = "PERSON"
BROADCAST = "BROADCAST"
diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py
index 95df1a647..c5379644a 100644
--- a/astrbot/core/star/star_manager.py
+++ b/astrbot/core/star/star_manager.py
@@ -5,6 +5,7 @@ import contextlib
import functools
import inspect
import json
+import keyword
import logging
import os
import sys
@@ -423,6 +424,43 @@ class PluginManager:
return metadata
+ @staticmethod
+ def _normalize_plugin_dir_name(plugin_name: str) -> str:
+ return plugin_name.strip()
+
+ @staticmethod
+ def _validate_importable_name(plugin_name: str) -> None:
+ if "/" in plugin_name or "\\" in plugin_name:
+ raise ValueError(
+ "metadata.yaml 中 name 含有路径分隔符,不可用于 importlib 加载。"
+ )
+ if not plugin_name.isidentifier() or keyword.iskeyword(plugin_name):
+ raise Exception(
+ "metadata.yaml 中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。"
+ )
+
+ @staticmethod
+ def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str:
+ metadata_path = os.path.join(plugin_path, "metadata.yaml")
+ if not os.path.exists(metadata_path):
+ raise Exception("未找到 metadata.yaml,无法获取插件目录名。")
+
+ with open(metadata_path, encoding="utf-8") as f:
+ metadata = yaml.safe_load(f)
+
+ if not isinstance(metadata, dict):
+ raise Exception("metadata.yaml 格式错误。")
+
+ plugin_name = metadata.get("name")
+ if not isinstance(plugin_name, str) or not plugin_name.strip():
+ raise Exception("metadata.yaml 中缺少 name 字段。")
+
+ plugin_dir_name = PluginManager._normalize_plugin_dir_name(plugin_name)
+ if not plugin_dir_name:
+ raise Exception("metadata.yaml 中 name 字段内容非法。")
+ PluginManager._validate_importable_name(plugin_dir_name)
+ return plugin_dir_name
+
@staticmethod
def _validate_astrbot_version_specifier(
version_spec: str | None,
@@ -1205,10 +1243,30 @@ class PluginManager:
plugin_path = ""
dir_name = ""
try:
+ _, repo_name, _ = self.updator.parse_github_url(repo_url)
+ repo_name = self.updator.format_name(repo_name)
+ plugin_path = os.path.join(self.plugin_store_path, repo_name)
+ if os.path.exists(plugin_path):
+ raise Exception(
+ f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。"
+ )
plugin_path = await self.updator.install(repo_url, proxy)
# reload the plugin
dir_name = os.path.basename(plugin_path)
+ metadata_dir_name = self._get_plugin_dir_name_from_metadata(plugin_path)
+ target_plugin_path = os.path.join(
+ self.plugin_store_path,
+ metadata_dir_name,
+ )
+ if target_plugin_path != plugin_path and os.path.exists(
+ target_plugin_path
+ ):
+ raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
+ if target_plugin_path != plugin_path:
+ os.rename(plugin_path, target_plugin_path)
+ plugin_path = target_plugin_path
+ dir_name = metadata_dir_name
await self._ensure_plugin_requirements(
plugin_path,
dir_name,
@@ -1578,52 +1636,25 @@ class PluginManager:
async def install_plugin_from_file(
self, zip_file_path: str, ignore_version_check: bool = False
):
- dir_name = os.path.basename(zip_file_path).replace(".zip", "")
- dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
- desti_dir = os.path.join(self.plugin_store_path, dir_name)
-
- # 第一步:检查是否已安装同目录名的插件,先终止旧插件
- existing_plugin = None
- for star in self.context.get_all_stars():
- if star.root_dir_name == dir_name:
- existing_plugin = star
- break
-
- if existing_plugin:
- logger.info(f"检测到插件 {existing_plugin.name} 已安装,正在终止旧插件...")
- try:
- await self._terminate_plugin(existing_plugin)
- except Exception:
- logger.warning(traceback.format_exc())
- if existing_plugin.name and existing_plugin.module_path:
- await self._unbind_plugin(
- existing_plugin.name, existing_plugin.module_path
- )
+ dir_name = os.path.splitext(os.path.basename(zip_file_path))[0]
+ desti_dir = tempfile.mkdtemp(
+ dir=self.plugin_store_path, prefix="plugin_upload_"
+ )
+ temp_desti_dir = desti_dir
try:
self.updator.unzip_file(zip_file_path, desti_dir)
-
- # 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件
- try:
- new_metadata = self._load_plugin_metadata(desti_dir)
- if new_metadata and new_metadata.name:
- for star in self.context.get_all_stars():
- if (
- star.name == new_metadata.name
- and star.root_dir_name != dir_name
- ):
- logger.warning(
- f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..."
- )
- try:
- await self._terminate_plugin(star)
- except Exception:
- logger.warning(traceback.format_exc())
- if star.name and star.module_path:
- await self._unbind_plugin(star.name, star.module_path)
- break # 只处理第一个匹配的
- except Exception as e:
- logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}")
+ metadata_dir_name = self._get_plugin_dir_name_from_metadata(desti_dir)
+ target_plugin_path = os.path.join(
+ self.plugin_store_path,
+ metadata_dir_name,
+ )
+ if target_plugin_path != desti_dir and os.path.exists(target_plugin_path):
+ raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
+ if target_plugin_path != desti_dir:
+ os.rename(desti_dir, target_plugin_path)
+ dir_name = metadata_dir_name
+ desti_dir = target_plugin_path
# remove the zip
try:
@@ -1692,3 +1723,11 @@ class PluginManager:
f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}",
)
raise
+ finally:
+ if temp_desti_dir != desti_dir and os.path.isdir(temp_desti_dir):
+ try:
+ remove_dir(temp_desti_dir)
+ except Exception as e:
+ logger.warning(
+ f"清理临时插件解压目录失败: {temp_desti_dir},原因: {e!s}",
+ )
diff --git a/dashboard/package.json b/dashboard/package.json
index efba4c080..61e6d169e 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -5,6 +5,7 @@
"author": "CodedThemes",
"scripts": {
"dev": "vite --host",
+ "subset-icons": "node scripts/subset-mdi-font.mjs",
"build": "vue-tsc --noEmit && vite build",
"build-stage": "vue-tsc --noEmit && vite build --base=/vue/free/stage/",
"build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/",
@@ -33,7 +34,6 @@
"monaco-editor": "^0.52.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
- "remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"vee-validate": "4.11.3",
@@ -60,12 +60,17 @@
"eslint-plugin-vue": "9.17.0",
"prettier": "3.0.2",
"sass": "1.66.1",
+ "sass-loader": "13.3.2",
+ "subset-font": "^2.4.0",
"typescript": "5.1.6",
"vite": "6.4.1",
- "vue-tsc": "1.8.27"
+ "vite-plugin-webfont-dl": "^3.12.0",
+ "vue-cli-plugin-vuetify": "2.5.8",
+ "vue-tsc": "1.8.8",
+ "vuetify-loader": "^2.0.0-alpha.9"
},
"overrides": {
"immutable": "4.3.8",
"lodash-es": "4.17.23"
}
-}
+}
\ No newline at end of file
diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml
index 7ddb4610e..e0cdf744c 100644
--- a/dashboard/pnpm-lock.yaml
+++ b/dashboard/pnpm-lock.yaml
@@ -68,9 +68,6 @@ importers:
pinyin-pro:
specifier: ^3.26.0
version: 3.28.0
- remixicon:
- specifier: 3.5.0
- version: 3.5.0
shiki:
specifier: ^3.20.0
version: 3.22.0
@@ -144,12 +141,24 @@ importers:
sass:
specifier: 1.66.1
version: 1.66.1
+ sass-loader:
+ specifier: 13.3.2
+ version: 13.3.2(sass@1.66.1)(webpack@5.105.0)
+ subset-font:
+ specifier: ^2.4.0
+ version: 2.4.0
typescript:
specifier: 5.1.6
version: 5.1.6
vite:
specifier: 6.4.1
version: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
+ vite-plugin-webfont-dl:
+ specifier: ^3.12.0
+ version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))
+ vue-cli-plugin-vuetify:
+ specifier: 2.5.8
+ version: 2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0)
vue-tsc:
specifier: 1.8.27
version: 1.8.27(typescript@5.1.6)
@@ -183,6 +192,12 @@ packages:
'@braintree/sanitize-url@7.1.2':
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
+ '@cacheable/memory@2.0.8':
+ resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==}
+
+ '@cacheable/utils@2.4.0':
+ resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==}
+
'@chevrotain/cst-dts-gen@11.0.3':
resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==}
@@ -438,6 +453,15 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@keyv/bigmap@1.3.1':
+ resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
+ engines: {node: '>= 18'}
+ peerDependencies:
+ keyv: ^5.6.0
+
+ '@keyv/serialize@1.1.1':
+ resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
+
'@mdi/font@7.2.96':
resolution: {integrity: sha512-e//lmkmpFUMZKhmCY9zdjRe4zNXfbOIJnn6xveHbaV2kSw5aJ5dLXUxcRt1Gxfi7ZYpFLUWlkG2MGSFAiqAu7w==}
@@ -503,79 +527,66 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1129,6 +1140,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ cacheable@2.3.3:
+ resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -1165,6 +1179,14 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
+ chrome-trace-event@1.0.4:
+ resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
+ engines: {node: '>=6.0'}
+
+ clean-css@5.3.3:
+ resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
+ engines: {node: '>= 10.0'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1579,6 +1601,9 @@ packages:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0}
+ flat-cache@6.1.20:
+ resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==}
+
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
@@ -1591,6 +1616,9 @@ packages:
debug:
optional: true
+ fontverter@2.0.0:
+ resolution: {integrity: sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==}
+
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -1644,6 +1672,9 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
+ harfbuzzjs@0.4.15:
+ resolution: {integrity: sha512-p1edvnlc+vpRe2kz7OKzcscf0gyFiDZpco+miDxAiiZ67cu1oNlbuOkmP/A/i1l/w938VrkF2FdZ8scNcnkPrQ==}
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -1656,6 +1687,10 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
+ hashery@1.5.0:
+ resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==}
+ engines: {node: '>=20'}
+
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -1674,6 +1709,9 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
+ hookified@1.15.1:
+ resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
+
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -1760,6 +1798,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ keyv@5.6.0:
+ resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
+
khroma@2.1.0:
resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
@@ -1968,6 +2009,9 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2128,6 +2172,15 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
+ qified@0.6.0:
+ resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==}
+ engines: {node: '>=20'}
+
+ querystring@0.2.1:
+ resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==}
+ engines: {node: '>=0.4.x'}
+ deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2144,8 +2197,9 @@ packages:
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
- remixicon@3.5.0:
- resolution: {integrity: sha512-wNzWGKf4frb3tEmgvP5shry0n1OdTjjEk9RHLuRuAhfA50bvEdpKH1XWNUYrHUSjAQQkkdyIm+lf4mOuysIKTQ==}
+ require-from-string@2.0.2:
+ resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
+ engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
@@ -2253,6 +2307,9 @@ packages:
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
+ subset-font@2.4.0:
+ resolution: {integrity: sha512-DA/45nIj4NiseVdfHxVdVGL7hvNo3Ol6HjEm3KSYtPyDcsr6jh8Q37vSgz+A722wMfUd6nL8kgsi7uGv9DExXQ==}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2415,6 +2472,11 @@ packages:
vue: ^3.0.0
vuetify: '>=3'
+ vite-plugin-webfont-dl@3.12.0:
+ resolution: {integrity: sha512-0jxsr8ycuoK/uV5Y3ytttTRhgvfZo8v3O4JZBlVc4C7QWIws/vCLVR4B3ag+TGVkLNQya6hXfY3UnZge3M8vmA==}
+ peerDependencies:
+ vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8
+
vite@6.4.1:
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -2543,11 +2605,36 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+ watchpack@2.5.1:
+ resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
+ engines: {node: '>=10.13.0'}
+
+ wawoff2@2.0.1:
+ resolution: {integrity: sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==}
+ hasBin: true
+
+ webpack-sources@3.3.4:
+ resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==}
+ engines: {node: '>=10.13.0'}
+
+ webpack@5.105.0:
+ resolution: {integrity: sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==}
+ engines: {node: '>=10.13.0'}
+ hasBin: true
+ peerDependencies:
+ webpack-cli: '*'
+ peerDependenciesMeta:
+ webpack-cli:
+ optional: true
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
+ woff2sfnt-sfnt2woff@1.0.0:
+ resolution: {integrity: sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==}
+
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -2593,6 +2680,18 @@ snapshots:
'@braintree/sanitize-url@7.1.2': {}
+ '@cacheable/memory@2.0.8':
+ dependencies:
+ '@cacheable/utils': 2.4.0
+ '@keyv/bigmap': 1.3.1(keyv@5.6.0)
+ hookified: 1.15.1
+ keyv: 5.6.0
+
+ '@cacheable/utils@2.4.0':
+ dependencies:
+ hashery: 1.5.0
+ keyv: 5.6.0
+
'@chevrotain/cst-dts-gen@11.0.3':
dependencies:
'@chevrotain/gast': 11.0.3
@@ -2784,6 +2883,14 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5
optional: true
+ '@keyv/bigmap@1.3.1(keyv@5.6.0)':
+ dependencies:
+ hashery: 1.5.0
+ hookified: 1.15.1
+ keyv: 5.6.0
+
+ '@keyv/serialize@1.1.1': {}
+
'@mdi/font@7.2.96': {}
'@mermaid-js/parser@0.6.3':
@@ -3543,6 +3650,14 @@ snapshots:
buffer-from@1.1.2:
optional: true
+ cacheable@2.3.3:
+ dependencies:
+ '@cacheable/memory': 2.0.8
+ '@cacheable/utils': 2.4.0
+ hookified: 1.15.1
+ keyv: 5.6.0
+ qified: 0.6.0
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -3589,6 +3704,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ chrome-trace-event@1.0.4: {}
+
+ clean-css@5.3.3:
+ dependencies:
+ source-map: 0.6.1
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -4061,10 +4182,21 @@ snapshots:
keyv: 4.5.4
rimraf: 3.0.2
+ flat-cache@6.1.20:
+ dependencies:
+ cacheable: 2.3.3
+ flatted: 3.3.3
+ hookified: 1.15.1
+
flatted@3.3.3: {}
follow-redirects@1.15.11: {}
+ fontverter@2.0.0:
+ dependencies:
+ wawoff2: 2.0.1
+ woff2sfnt-sfnt2woff: 1.0.0
+
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -4134,6 +4266,8 @@ snapshots:
hachure-fill@0.5.2: {}
+ harfbuzzjs@0.4.15: {}
+
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
@@ -4142,6 +4276,10 @@ snapshots:
dependencies:
has-symbols: 1.1.0
+ hashery@1.5.0:
+ dependencies:
+ hookified: 1.15.1
+
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -4168,6 +4306,8 @@ snapshots:
highlight.js@11.11.1: {}
+ hookified@1.15.1: {}
+
html-void-elements@3.0.0: {}
iconv-lite@0.6.3:
@@ -4234,6 +4374,10 @@ snapshots:
dependencies:
json-buffer: 3.0.1
+ keyv@5.6.0:
+ dependencies:
+ '@keyv/serialize': 1.1.1
+
khroma@2.1.0: {}
langium@3.3.1:
@@ -4454,6 +4598,8 @@ snapshots:
package-manager-detector@1.6.0: {}
+ pako@1.0.11: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -4633,6 +4779,12 @@ snapshots:
punycode@2.3.1: {}
+ qified@0.6.0:
+ dependencies:
+ hookified: 1.15.1
+
+ querystring@0.2.1: {}
+
queue-microtask@1.2.3: {}
readdirp@3.6.0:
@@ -4649,7 +4801,7 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
- remixicon@3.5.0: {}
+ require-from-string@2.0.2: {}
resolve-from@4.0.0: {}
@@ -4787,6 +4939,13 @@ snapshots:
stylis@4.3.6: {}
+ subset-font@2.4.0:
+ dependencies:
+ fontverter: 2.0.0
+ harfbuzzjs: 0.4.15
+ lodash: 4.17.23
+ p-limit: 3.1.0
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -4944,6 +5103,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)):
+ dependencies:
+ axios: 1.13.5
+ clean-css: 5.3.3
+ flat-cache: 6.1.20
+ picocolors: 1.1.1
+ vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
+ transitivePeerDependencies:
+ - debug
+
vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0):
dependencies:
esbuild: 0.25.12
@@ -5042,10 +5211,57 @@ snapshots:
w3c-keyname@2.2.8: {}
+ watchpack@2.5.1:
+ dependencies:
+ glob-to-regexp: 0.4.1
+ graceful-fs: 4.2.11
+
+ wawoff2@2.0.1:
+ dependencies:
+ argparse: 2.0.1
+
+ webpack-sources@3.3.4: {}
+
+ webpack@5.105.0:
+ dependencies:
+ '@types/eslint-scope': 3.7.7
+ '@types/estree': 1.0.8
+ '@types/json-schema': 7.0.15
+ '@webassemblyjs/ast': 1.14.1
+ '@webassemblyjs/wasm-edit': 1.14.1
+ '@webassemblyjs/wasm-parser': 1.14.1
+ acorn: 8.16.0
+ acorn-import-phases: 1.0.4(acorn@8.16.0)
+ browserslist: 4.28.1
+ chrome-trace-event: 1.0.4
+ enhanced-resolve: 5.20.0
+ es-module-lexer: 2.0.0
+ eslint-scope: 5.1.1
+ events: 3.3.0
+ glob-to-regexp: 0.4.1
+ graceful-fs: 4.2.11
+ json-parse-even-better-errors: 2.3.1
+ loader-runner: 4.3.1
+ mime-types: 2.1.35
+ neo-async: 2.6.2
+ schema-utils: 4.3.3
+ tapable: 2.3.0
+ terser-webpack-plugin: 5.4.0(webpack@5.105.0)
+ watchpack: 2.5.1
+ webpack-sources: 3.3.4
+ transitivePeerDependencies:
+ - '@swc/core'
+ - esbuild
+ - uglify-js
+
which@2.0.2:
dependencies:
isexe: 2.0.0
+ woff2sfnt-sfnt2woff@1.0.0:
+ dependencies:
+ pako: 1.0.11
+
word-wrap@1.2.5: {}
wrappy@1.0.2: {}
diff --git a/dashboard/scripts/subset-mdi-font.mjs b/dashboard/scripts/subset-mdi-font.mjs
new file mode 100644
index 000000000..ee8ca831f
--- /dev/null
+++ b/dashboard/scripts/subset-mdi-font.mjs
@@ -0,0 +1,291 @@
+/**
+ * subset-mdi-font.mjs
+ *
+ * Build script that:
+ * 1. Scans src/ for all mdi-* icon names used in .vue/.ts files
+ * 2. Resolves their Unicode codepoints from @mdi/font CSS
+ * 3. Subsets the MDI font to include only those glyphs (via subset-font, pure JS)
+ * 4. Generates a minimal CSS file with only the needed icon classes
+ * 5. Outputs to src/assets/mdi-subset/
+ *
+ * Fallback: if any step fails, copies the original full @mdi/font CSS and fonts
+ * so the build never breaks.
+ */
+import { readFileSync, writeFileSync, copyFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs";
+import { join, resolve, extname } from "path";
+import { fileURLToPath } from "url";
+
+// Derive __dirname portably from import.meta.url (works across all Node ESM versions)
+const __dirname = fileURLToPath(new URL(".", import.meta.url));
+const ROOT = resolve(__dirname, "..");
+const SRC = join(ROOT, "src");
+const MDI_CSS_PATH = join(ROOT, "node_modules/@mdi/font/css/materialdesignicons.css");
+const MDI_TTF_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf");
+const MDI_WOFF2_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2");
+const MDI_WOFF_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff");
+const OUT_DIR = join(ROOT, "src/assets/mdi-subset");
+
+// Utility classes that should not be treated as icon names
+const UTILITY_CLASSES = new Set([
+ "mdi-set", "mdi-spin", "mdi-rotate-45", "mdi-rotate-90", "mdi-rotate-135",
+ "mdi-rotate-180", "mdi-rotate-225", "mdi-rotate-270", "mdi-rotate-315",
+ "mdi-flip-h", "mdi-flip-v", "mdi-light", "mdi-dark", "mdi-inactive",
+ "mdi-18px", "mdi-24px", "mdi-36px", "mdi-48px",
+]);
+
+// Regex to match individual icon class definitions in MDI CSS
+export const ICON_CLASS_PATTERN = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"\s*;?\s*}/g;
+
+// ── Helper functions ────────────────────────────────────────────────────────
+
+/** Recursively collect files with given extensions, skipping node_modules. */
+export function* collectFiles(dir, exts) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory() && entry.name !== "node_modules") {
+ yield* collectFiles(full, exts);
+ } else if (exts.includes(extname(entry.name))) {
+ yield full;
+ }
+ }
+}
+
+/** Scan source files and return a Set of used mdi-* icon names. */
+export function scanUsedIcons(sourceFiles) {
+ const iconPattern = /mdi-[a-z][a-z0-9-]*/g;
+ const usedIcons = new Set();
+ for (const file of sourceFiles) {
+ const content = readFileSync(file, "utf-8");
+ for (const match of content.matchAll(iconPattern)) {
+ if (!UTILITY_CLASSES.has(match[0])) {
+ usedIcons.add(match[0]);
+ }
+ }
+ }
+ return usedIcons;
+}
+
+/** Parse @mdi/font CSS and return a Map of icon-name → hex codepoint. */
+export function parseIconCodepoints(mdiCSS) {
+ const iconMap = new Map();
+ for (const match of mdiCSS.matchAll(ICON_CLASS_PATTERN)) {
+ iconMap.set(match[1], match[2]);
+ }
+ return iconMap;
+}
+
+/** Resolve used icons against the codepoint map, returning resolved/missing/subsetChars. */
+export function resolveUsedIcons(usedIcons, iconMap) {
+ const resolvedIcons = [];
+ const missingIcons = [];
+ const subsetChars = [];
+ for (const icon of usedIcons) {
+ const cp = iconMap.get(icon);
+ if (cp) {
+ resolvedIcons.push(icon);
+ subsetChars.push(String.fromCodePoint(parseInt(cp, 16)));
+ } else {
+ missingIcons.push(icon);
+ }
+ }
+ return { resolvedIcons, missingIcons, subsetChars };
+}
+
+/**
+ * Extract utility CSS rules (size, rotation, flip, spin, etc.) from the original MDI CSS.
+ * Uses a subtraction approach: removes the parts we regenerate (icon definitions,
+ * @font-face, base .mdi rules) and keeps everything else. This is more robust than
+ * relying on a fixed start marker, as it tolerates CSS reordering in future versions.
+ */
+export function extractUtilityCss(mdiCSS, iconClassPattern) {
+ let utilityCss = mdiCSS
+ .replace(iconClassPattern, "") // Remove icon definitions
+ .replace(/@font-face\s*\{[\s\S]*?}/g, "") // Remove @font-face
+ .replace(/\.mdi:before,\s*\.mdi-set\s*\{[\s\S]*?}/g, "") // Remove base rules
+ .replace(/\/\*# sourceMappingURL=.*\*\//, "") // Remove source map
+ .trim();
+
+ // Clean up excess blank lines left after removals
+ utilityCss = utilityCss.replace(/(\r\n|\n){3,}/g, "\n\n");
+
+ return utilityCss;
+}
+
+/** Build a fallback CSS that rewrites font URLs to use subset filenames. */
+function buildFallbackCss() {
+ const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
+ return mdiCSS
+ // Rewrite woff/woff2 URLs to point at subset filenames
+ .replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g,
+ (_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`)
+ // Remove legacy eot/ttf sources
+ .replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, "")
+ // Clean up dangling commas/separators
+ .replace(/src:\s*,/g, "src:")
+ .replace(/,\s*;/g, ";");
+}
+
+// ── Fallback: copy original full MDI font if subsetting fails ───────────────
+function fallbackToFullFont(reason) {
+ console.warn(`\n⚠️ Subsetting failed: ${reason}`);
+ console.warn(`⚠️ Falling back to full @mdi/font (build will not break)\n`);
+
+ // Copy original font files
+ if (existsSync(MDI_WOFF2_PATH)) {
+ copyFileSync(MDI_WOFF2_PATH, join(OUT_DIR, "materialdesignicons-webfont-subset.woff2"));
+ }
+ if (existsSync(MDI_WOFF_PATH)) {
+ copyFileSync(MDI_WOFF_PATH, join(OUT_DIR, "materialdesignicons-webfont-subset.woff"));
+ }
+
+ writeFileSync(join(OUT_DIR, "materialdesignicons-subset.css"), buildFallbackCss());
+
+ const size = existsSync(MDI_WOFF2_PATH) ? statSync(MDI_WOFF2_PATH).size : 0;
+ console.warn(`⚠️ Fallback complete: using full font (${(size / 1024).toFixed(1)} KB woff2)`);
+}
+
+// ── Exported entry point ────────────────────────────────────────────────────
+
+export async function runMdiSubset() {
+ mkdirSync(OUT_DIR, { recursive: true });
+
+ try {
+ // Pre-checks
+ if (!existsSync(MDI_CSS_PATH)) {
+ throw new Error(`@mdi/font CSS not found at ${MDI_CSS_PATH}. Run 'pnpm install' first.`);
+ }
+ if (!existsSync(MDI_TTF_PATH)) {
+ throw new Error(`@mdi/font TTF not found at ${MDI_TTF_PATH}. Run 'pnpm install' first.`);
+ }
+
+ // Dynamic import subset-font (may not be installed in all environments)
+ let subsetFont;
+ try {
+ subsetFont = (await import("subset-font")).default;
+ } catch (e) {
+ throw new Error(`subset-font package not available: ${e.message}. Run 'pnpm install' first.`);
+ }
+
+ // Step 1: Scan source files for mdi-* icon names
+ const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]);
+ const usedIcons = scanUsedIcons(sourceFiles);
+ if (usedIcons.size === 0) {
+ throw new Error("No mdi-* icons found in source files. Something is wrong with scanning.");
+ }
+ console.log(`✅ Found ${usedIcons.size} unique mdi-* icons in source files`);
+
+ // Step 2: Parse @mdi/font CSS to get codepoints for each icon
+ const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
+ const iconMap = parseIconCodepoints(mdiCSS);
+ if (iconMap.size === 0) {
+ throw new Error("Could not parse any icon definitions from @mdi/font CSS. Format may have changed.");
+ }
+ console.log(`📦 MDI font CSS contains ${iconMap.size} icon definitions`);
+
+ // Step 3: Resolve codepoints for used icons
+ const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap);
+ if (missingIcons.length > 0) {
+ console.warn(`⚠️ ${missingIcons.length} icons not found in MDI CSS:`, missingIcons.join(", "));
+ }
+ if (resolvedIcons.length === 0) {
+ throw new Error("No icon codepoints could be resolved. Icon name format may have changed.");
+ }
+ console.log(`🔍 Resolved ${resolvedIcons.length} codepoints for subsetting`);
+
+ // Add space character
+ subsetChars.push(" ");
+ const subsetText = subsetChars.join("");
+
+ // Step 4: Subset font with subset-font (pure JS/WASM)
+ const fontBuffer = readFileSync(MDI_TTF_PATH);
+
+ console.log(`🔧 Subsetting font to woff2...`);
+ const woff2Buffer = await subsetFont(fontBuffer, subsetText, {
+ targetFormat: "woff2",
+ });
+
+ console.log(`🔧 Subsetting font to woff...`);
+ const woffBuffer = await subsetFont(fontBuffer, subsetText, {
+ targetFormat: "woff",
+ });
+
+ if (woff2Buffer.length === 0 || woffBuffer.length === 0) {
+ throw new Error("subset-font produced empty output. Font file may be corrupted.");
+ }
+
+ const outWoff2 = join(OUT_DIR, "materialdesignicons-webfont-subset.woff2");
+ const outWoff = join(OUT_DIR, "materialdesignicons-webfont-subset.woff");
+ writeFileSync(outWoff2, woff2Buffer);
+ writeFileSync(outWoff, woffBuffer);
+
+ // Step 5: Generate subset CSS
+ let css = `/* Auto-generated MDI subset – ${resolvedIcons.length} icons */
+/* Do not edit manually. Run: pnpm run subset-icons */
+
+@font-face {
+ font-family: "Material Design Icons";
+ src: url("./materialdesignicons-webfont-subset.woff2") format("woff2"),
+ url("./materialdesignicons-webfont-subset.woff") format("woff");
+ font-weight: normal;
+ font-style: normal;
+}
+
+.mdi:before,
+.mdi-set {
+ display: inline-block;
+ font: normal normal normal 24px/1 "Material Design Icons";
+ font-size: inherit;
+ text-rendering: auto;
+ line-height: inherit;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+`;
+
+ for (const icon of resolvedIcons.sort()) {
+ const cp = iconMap.get(icon);
+ css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`;
+ }
+
+ const utilityCss = extractUtilityCss(mdiCSS, ICON_CLASS_PATTERN);
+ if (utilityCss) {
+ css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`;
+ } else {
+ console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping");
+ }
+
+ const outCSS = join(OUT_DIR, "materialdesignicons-subset.css");
+ writeFileSync(outCSS, css);
+
+ // Report
+ const origSize = statSync(MDI_TTF_PATH).size;
+ const subsetWoff2Size = woff2Buffer.length;
+ console.log(`\n📊 Results:`);
+ console.log(` Original TTF font: ${(origSize / 1024).toFixed(1)} KB`);
+ console.log(` Subset WOFF2: ${(subsetWoff2Size / 1024).toFixed(1)} KB`);
+ console.log(` Reduction: ${((1 - subsetWoff2Size / origSize) * 100).toFixed(1)}%`);
+ console.log(` Icons included: ${resolvedIcons.length}`);
+ console.log(` CSS file: ${outCSS}`);
+ console.log(`\n✅ MDI font subset generated successfully!`);
+
+ } catch (err) {
+ // Fallback: any failure → use original full font so build never breaks
+ try {
+ fallbackToFullFont(err.message);
+ } catch (fallbackErr) {
+ console.error(`❌ Fallback also failed: ${fallbackErr.message}`);
+ console.error(`❌ Please ensure @mdi/font is installed: pnpm install`);
+ throw fallbackErr;
+ }
+ }
+}
+
+// ── CLI entry point: allows running directly via `node scripts/subset-mdi-font.mjs` ──
+
+if (import.meta.url.startsWith('file:') && process.argv[1] === fileURLToPath(import.meta.url)) {
+ runMdiSubset().catch(err => {
+ console.error(err);
+ process.exit(1);
+ });
+}
diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
new file mode 100644
index 000000000..7f734b049
--- /dev/null
+++ b/dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
@@ -0,0 +1,1193 @@
+/* Auto-generated MDI subset – 229 icons */
+/* Do not edit manually. Run: pnpm run subset-icons */
+
+@font-face {
+ font-family: "Material Design Icons";
+ src: url("./materialdesignicons-webfont-subset.woff2") format("woff2"),
+ url("./materialdesignicons-webfont-subset.woff") format("woff");
+ font-weight: normal;
+ font-style: normal;
+}
+
+.mdi:before,
+.mdi-set {
+ display: inline-block;
+ font: normal normal normal 24px/1 "Material Design Icons";
+ font-size: inherit;
+ text-rendering: auto;
+ line-height: inherit;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.mdi-account::before {
+ content: "\F0004";
+}
+
+.mdi-account-circle::before {
+ content: "\F0009";
+}
+
+.mdi-account-edit-outline::before {
+ content: "\F0FFB";
+}
+
+.mdi-account-heart::before {
+ content: "\F0899";
+}
+
+.mdi-account-voice::before {
+ content: "\F05CB";
+}
+
+.mdi-alert::before {
+ content: "\F0026";
+}
+
+.mdi-alert-circle::before {
+ content: "\F0028";
+}
+
+.mdi-alert-circle-outline::before {
+ content: "\F05D6";
+}
+
+.mdi-alert-outline::before {
+ content: "\F002A";
+}
+
+.mdi-api-off::before {
+ content: "\F1257";
+}
+
+.mdi-arrow-down::before {
+ content: "\F0045";
+}
+
+.mdi-arrow-down-thin::before {
+ content: "\F19B3";
+}
+
+.mdi-arrow-left::before {
+ content: "\F004D";
+}
+
+.mdi-arrow-right::before {
+ content: "\F0054";
+}
+
+.mdi-arrow-top-right-thick::before {
+ content: "\F09C6";
+}
+
+.mdi-arrow-up::before {
+ content: "\F005D";
+}
+
+.mdi-arrow-up-bold::before {
+ content: "\F0737";
+}
+
+.mdi-arrow-up-circle::before {
+ content: "\F0CE1";
+}
+
+.mdi-arrow-up-thin::before {
+ content: "\F19B2";
+}
+
+.mdi-backup-restore::before {
+ content: "\F006F";
+}
+
+.mdi-book-open-page-variant::before {
+ content: "\F05DA";
+}
+
+.mdi-book-open-variant::before {
+ content: "\F14F7";
+}
+
+.mdi-brain::before {
+ content: "\F09D1";
+}
+
+.mdi-bug::before {
+ content: "\F00E4";
+}
+
+.mdi-calendar::before {
+ content: "\F00ED";
+}
+
+.mdi-calendar-edit::before {
+ content: "\F08A7";
+}
+
+.mdi-calendar-plus::before {
+ content: "\F00F3";
+}
+
+.mdi-calendar-range::before {
+ content: "\F0679";
+}
+
+.mdi-chat::before {
+ content: "\F0B79";
+}
+
+.mdi-chat-processing::before {
+ content: "\F0B7B";
+}
+
+.mdi-chat-remove::before {
+ content: "\F1411";
+}
+
+.mdi-check::before {
+ content: "\F012C";
+}
+
+.mdi-check-all::before {
+ content: "\F012D";
+}
+
+.mdi-check-circle::before {
+ content: "\F05E0";
+}
+
+.mdi-check-circle-outline::before {
+ content: "\F05E1";
+}
+
+.mdi-checkbox-blank-outline::before {
+ content: "\F0131";
+}
+
+.mdi-checkbox-marked::before {
+ content: "\F0132";
+}
+
+.mdi-checkbox-multiple-marked-outline::before {
+ content: "\F0139";
+}
+
+.mdi-chevron-double-left::before {
+ content: "\F013D";
+}
+
+.mdi-chevron-double-right::before {
+ content: "\F013E";
+}
+
+.mdi-chevron-down::before {
+ content: "\F0140";
+}
+
+.mdi-chevron-left::before {
+ content: "\F0141";
+}
+
+.mdi-chevron-right::before {
+ content: "\F0142";
+}
+
+.mdi-chevron-up::before {
+ content: "\F0143";
+}
+
+.mdi-circle::before {
+ content: "\F0765";
+}
+
+.mdi-circle-small::before {
+ content: "\F09DF";
+}
+
+.mdi-clock-outline::before {
+ content: "\F0150";
+}
+
+.mdi-close::before {
+ content: "\F0156";
+}
+
+.mdi-close-circle::before {
+ content: "\F0159";
+}
+
+.mdi-close-circle-outline::before {
+ content: "\F015A";
+}
+
+.mdi-cloud-upload::before {
+ content: "\F0167";
+}
+
+.mdi-code-json::before {
+ content: "\F0626";
+}
+
+.mdi-code-tags::before {
+ content: "\F0174";
+}
+
+.mdi-code-tags-check::before {
+ content: "\F0694";
+}
+
+.mdi-cog::before {
+ content: "\F0493";
+}
+
+.mdi-cog-outline::before {
+ content: "\F08BB";
+}
+
+.mdi-cogs::before {
+ content: "\F08D6";
+}
+
+.mdi-comment-question::before {
+ content: "\F0817";
+}
+
+.mdi-compare-vertical::before {
+ content: "\F1493";
+}
+
+.mdi-connection::before {
+ content: "\F1616";
+}
+
+.mdi-console::before {
+ content: "\F018D";
+}
+
+.mdi-console-line::before {
+ content: "\F07B7";
+}
+
+.mdi-content-copy::before {
+ content: "\F018F";
+}
+
+.mdi-content-save::before {
+ content: "\F0193";
+}
+
+.mdi-creation::before {
+ content: "\F0674";
+}
+
+.mdi-cursor-default-click::before {
+ content: "\F0CFD";
+}
+
+.mdi-cursor-move::before {
+ content: "\F01BE";
+}
+
+.mdi-database::before {
+ content: "\F01BC";
+}
+
+.mdi-database-cog::before {
+ content: "\F164B";
+}
+
+.mdi-database-off::before {
+ content: "\F1640";
+}
+
+.mdi-delete::before {
+ content: "\F01B4";
+}
+
+.mdi-delete-outline::before {
+ content: "\F09E7";
+}
+
+.mdi-dots-hexagon::before {
+ content: "\F15FF";
+}
+
+.mdi-dots-horizontal::before {
+ content: "\F01D8";
+}
+
+.mdi-dots-vertical::before {
+ content: "\F01D9";
+}
+
+.mdi-download::before {
+ content: "\F01DA";
+}
+
+.mdi-emoticon::before {
+ content: "\F0C68";
+}
+
+.mdi-emoticon-confused::before {
+ content: "\F10DE";
+}
+
+.mdi-emoticon-confused-outline::before {
+ content: "\F10DF";
+}
+
+.mdi-export::before {
+ content: "\F0207";
+}
+
+.mdi-eye::before {
+ content: "\F0208";
+}
+
+.mdi-eye-off::before {
+ content: "\F0209";
+}
+
+.mdi-eye-outline::before {
+ content: "\F06D0";
+}
+
+.mdi-file::before {
+ content: "\F0214";
+}
+
+.mdi-file-chart::before {
+ content: "\F0215";
+}
+
+.mdi-file-code::before {
+ content: "\F022E";
+}
+
+.mdi-file-document::before {
+ content: "\F0219";
+}
+
+.mdi-file-document-edit-outline::before {
+ content: "\F0DC9";
+}
+
+.mdi-file-document-multiple::before {
+ content: "\F1517";
+}
+
+.mdi-file-document-outline::before {
+ content: "\F09EE";
+}
+
+.mdi-file-excel-box::before {
+ content: "\F021C";
+}
+
+.mdi-file-outline::before {
+ content: "\F0224";
+}
+
+.mdi-file-pdf-box::before {
+ content: "\F0226";
+}
+
+.mdi-file-powerpoint-box::before {
+ content: "\F0228";
+}
+
+.mdi-file-question-outline::before {
+ content: "\F1036";
+}
+
+.mdi-file-upload::before {
+ content: "\F0A4D";
+}
+
+.mdi-file-upload-outline::before {
+ content: "\F0A4E";
+}
+
+.mdi-file-word-box::before {
+ content: "\F022D";
+}
+
+.mdi-filter-remove::before {
+ content: "\F0234";
+}
+
+.mdi-filter-variant::before {
+ content: "\F0236";
+}
+
+.mdi-flash::before {
+ content: "\F0241";
+}
+
+.mdi-flash-off::before {
+ content: "\F0243";
+}
+
+.mdi-folder::before {
+ content: "\F024B";
+}
+
+.mdi-folder-move::before {
+ content: "\F0252";
+}
+
+.mdi-folder-multiple::before {
+ content: "\F0253";
+}
+
+.mdi-folder-open::before {
+ content: "\F0770";
+}
+
+.mdi-folder-open-outline::before {
+ content: "\F0DCF";
+}
+
+.mdi-folder-outline::before {
+ content: "\F0256";
+}
+
+.mdi-folder-plus::before {
+ content: "\F0257";
+}
+
+.mdi-folder-zip-outline::before {
+ content: "\F07B9";
+}
+
+.mdi-format-list-bulleted::before {
+ content: "\F0279";
+}
+
+.mdi-frequently-asked-questions::before {
+ content: "\F0EB4";
+}
+
+.mdi-fullscreen::before {
+ content: "\F0293";
+}
+
+.mdi-fullscreen-exit::before {
+ content: "\F0294";
+}
+
+.mdi-function-variant::before {
+ content: "\F0871";
+}
+
+.mdi-github::before {
+ content: "\F02A4";
+}
+
+.mdi-grain::before {
+ content: "\F0D7C";
+}
+
+.mdi-hand-heart::before {
+ content: "\F10F1";
+}
+
+.mdi-hand-wave-outline::before {
+ content: "\F1822";
+}
+
+.mdi-heart::before {
+ content: "\F02D1";
+}
+
+.mdi-help-circle::before {
+ content: "\F02D7";
+}
+
+.mdi-help-circle-outline::before {
+ content: "\F0625";
+}
+
+.mdi-home::before {
+ content: "\F02DC";
+}
+
+.mdi-identifier::before {
+ content: "\F0EFE";
+}
+
+.mdi-import::before {
+ content: "\F02FA";
+}
+
+.mdi-information::before {
+ content: "\F02FC";
+}
+
+.mdi-information-outline::before {
+ content: "\F02FD";
+}
+
+.mdi-key::before {
+ content: "\F0306";
+}
+
+.mdi-key-outline::before {
+ content: "\F0DD6";
+}
+
+.mdi-key-plus::before {
+ content: "\F0309";
+}
+
+.mdi-keyboard-outline::before {
+ content: "\F097B";
+}
+
+.mdi-label::before {
+ content: "\F0315";
+}
+
+.mdi-lan-connect::before {
+ content: "\F0318";
+}
+
+.mdi-language-markdown::before {
+ content: "\F0354";
+}
+
+.mdi-layers-outline::before {
+ content: "\F09FE";
+}
+
+.mdi-lightbulb-outline::before {
+ content: "\F0336";
+}
+
+.mdi-lightning-bolt::before {
+ content: "\F140B";
+}
+
+.mdi-link::before {
+ content: "\F0337";
+}
+
+.mdi-link-variant::before {
+ content: "\F0339";
+}
+
+.mdi-loading::before {
+ content: "\F0772";
+}
+
+.mdi-lock::before {
+ content: "\F033E";
+}
+
+.mdi-lock-check-outline::before {
+ content: "\F16A8";
+}
+
+.mdi-lock-outline::before {
+ content: "\F0341";
+}
+
+.mdi-lock-plus-outline::before {
+ content: "\F16B2";
+}
+
+.mdi-magnify::before {
+ content: "\F0349";
+}
+
+.mdi-memory::before {
+ content: "\F035B";
+}
+
+.mdi-menu::before {
+ content: "\F035C";
+}
+
+.mdi-message-off-outline::before {
+ content: "\F164E";
+}
+
+.mdi-message-text::before {
+ content: "\F0369";
+}
+
+.mdi-message-text-outline::before {
+ content: "\F036A";
+}
+
+.mdi-microphone::before {
+ content: "\F036C";
+}
+
+.mdi-microphone-message::before {
+ content: "\F050A";
+}
+
+.mdi-minus::before {
+ content: "\F0374";
+}
+
+.mdi-note-text-outline::before {
+ content: "\F11D7";
+}
+
+.mdi-numeric-1::before {
+ content: "\F0B3A";
+}
+
+.mdi-numeric-1-circle::before {
+ content: "\F0CA0";
+}
+
+.mdi-numeric-2::before {
+ content: "\F0B3B";
+}
+
+.mdi-numeric-2-circle::before {
+ content: "\F0CA2";
+}
+
+.mdi-numeric-3::before {
+ content: "\F0B3C";
+}
+
+.mdi-open-in-new::before {
+ content: "\F03CC";
+}
+
+.mdi-package-variant::before {
+ content: "\F03D6";
+}
+
+.mdi-pause::before {
+ content: "\F03E4";
+}
+
+.mdi-pause-circle-outline::before {
+ content: "\F03E6";
+}
+
+.mdi-pencil::before {
+ content: "\F03EB";
+}
+
+.mdi-pencil-outline::before {
+ content: "\F0CB6";
+}
+
+.mdi-pencil-plus::before {
+ content: "\F0DEB";
+}
+
+.mdi-pencil-ruler::before {
+ content: "\F1353";
+}
+
+.mdi-phone-in-talk::before {
+ content: "\F03F6";
+}
+
+.mdi-play::before {
+ content: "\F040A";
+}
+
+.mdi-play-circle-outline::before {
+ content: "\F040D";
+}
+
+.mdi-plus::before {
+ content: "\F0415";
+}
+
+.mdi-pound::before {
+ content: "\F0423";
+}
+
+.mdi-progress-check::before {
+ content: "\F0995";
+}
+
+.mdi-puzzle::before {
+ content: "\F0431";
+}
+
+.mdi-puzzle-outline::before {
+ content: "\F0A66";
+}
+
+.mdi-refresh::before {
+ content: "\F0450";
+}
+
+.mdi-rename-box::before {
+ content: "\F0455";
+}
+
+.mdi-reply::before {
+ content: "\F045A";
+}
+
+.mdi-reply-outline::before {
+ content: "\F0F20";
+}
+
+.mdi-restart::before {
+ content: "\F0709";
+}
+
+.mdi-restore::before {
+ content: "\F099B";
+}
+
+.mdi-robot::before {
+ content: "\F06A9";
+}
+
+.mdi-robot-off::before {
+ content: "\F16A7";
+}
+
+.mdi-send::before {
+ content: "\F048A";
+}
+
+.mdi-server::before {
+ content: "\F048B";
+}
+
+.mdi-server-network::before {
+ content: "\F048D";
+}
+
+.mdi-server-off::before {
+ content: "\F048F";
+}
+
+.mdi-shape-outline::before {
+ content: "\F0832";
+}
+
+.mdi-shield-check::before {
+ content: "\F0565";
+}
+
+.mdi-shield-check-outline::before {
+ content: "\F0CC8";
+}
+
+.mdi-shuffle-variant::before {
+ content: "\F049F";
+}
+
+.mdi-skip-next-circle-outline::before {
+ content: "\F0662";
+}
+
+.mdi-sort::before {
+ content: "\F04BA";
+}
+
+.mdi-sort-ascending::before {
+ content: "\F04BC";
+}
+
+.mdi-sort-variant::before {
+ content: "\F04BF";
+}
+
+.mdi-source-branch::before {
+ content: "\F062C";
+}
+
+.mdi-square-edit-outline::before {
+ content: "\F090C";
+}
+
+.mdi-star::before {
+ content: "\F04CE";
+}
+
+.mdi-star-four-points-small::before {
+ content: "\F1C55";
+}
+
+.mdi-stop::before {
+ content: "\F04DB";
+}
+
+.mdi-stop-circle::before {
+ content: "\F0666";
+}
+
+.mdi-store::before {
+ content: "\F04DC";
+}
+
+.mdi-subdirectory-arrow-right::before {
+ content: "\F060D";
+}
+
+.mdi-text::before {
+ content: "\F09A8";
+}
+
+.mdi-text-box::before {
+ content: "\F021A";
+}
+
+.mdi-text-box-outline::before {
+ content: "\F09ED";
+}
+
+.mdi-text-box-search::before {
+ content: "\F0EAE";
+}
+
+.mdi-text-box-search-outline::before {
+ content: "\F0EAF";
+}
+
+.mdi-text-search::before {
+ content: "\F13B8";
+}
+
+.mdi-timeline-text-outline::before {
+ content: "\F0BD4";
+}
+
+.mdi-tools::before {
+ content: "\F1064";
+}
+
+.mdi-translate::before {
+ content: "\F05CA";
+}
+
+.mdi-trash-can-outline::before {
+ content: "\F0A7A";
+}
+
+.mdi-update::before {
+ content: "\F06B0";
+}
+
+.mdi-upload::before {
+ content: "\F0552";
+}
+
+.mdi-vector-intersection::before {
+ content: "\F055D";
+}
+
+.mdi-vector-link::before {
+ content: "\F0FE8";
+}
+
+.mdi-vector-point::before {
+ content: "\F01C4";
+}
+
+.mdi-view-dashboard::before {
+ content: "\F056E";
+}
+
+.mdi-view-grid::before {
+ content: "\F0570";
+}
+
+.mdi-view-list::before {
+ content: "\F0572";
+}
+
+.mdi-volume-high::before {
+ content: "\F057E";
+}
+
+.mdi-weather-night::before {
+ content: "\F0594";
+}
+
+.mdi-web::before {
+ content: "\F059F";
+}
+
+.mdi-webhook::before {
+ content: "\F062F";
+}
+
+.mdi-white-balance-sunny::before {
+ content: "\F05A8";
+}
+
+.mdi-wrench::before {
+ content: "\F05B7";
+}
+
+.mdi-wrench-outline::before {
+ content: "\F0BE0";
+}
+
+.mdi-zip-box::before {
+ content: "\F05C4";
+}
+
+/* Utility classes (extracted from @mdi/font) */
+/* MaterialDesignIcons.com */
+
+.mdi-blank::before {
+ content: "\F68C";
+ visibility: hidden;
+}
+
+.mdi-18px.mdi-set, .mdi-18px.mdi:before {
+ font-size: 18px;
+}
+
+.mdi-24px.mdi-set, .mdi-24px.mdi:before {
+ font-size: 24px;
+}
+
+.mdi-36px.mdi-set, .mdi-36px.mdi:before {
+ font-size: 36px;
+}
+
+.mdi-48px.mdi-set, .mdi-48px.mdi:before {
+ font-size: 48px;
+}
+
+.mdi-dark:before {
+ color: rgba(0, 0, 0, 0.54);
+}
+
+.mdi-dark.mdi-inactive:before {
+ color: rgba(0, 0, 0, 0.26);
+}
+
+.mdi-light:before {
+ color: white;
+}
+
+.mdi-light.mdi-inactive:before {
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.mdi-rotate-45 {
+ /*
+ // Not included in production
+ &.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1) rotate(45deg);
+ transform: scaleX(-1) rotate(45deg);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+ }
+ &.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1) rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: scaleY(-1) rotate(45deg);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+ }
+ */
+}
+
+.mdi-rotate-45:before {
+ -webkit-transform: rotate(45deg);
+ -ms-transform: rotate(45deg);
+ transform: rotate(45deg);
+}
+
+.mdi-rotate-90 {
+ /*
+ // Not included in production
+ &.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1) rotate(90deg);
+ transform: scaleX(-1) rotate(90deg);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+ }
+ &.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1) rotate(90deg);
+ -ms-transform: rotate(90deg);
+ transform: scaleY(-1) rotate(90deg);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+ }
+ */
+}
+
+.mdi-rotate-90:before {
+ -webkit-transform: rotate(90deg);
+ -ms-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.mdi-rotate-135 {
+ /*
+ // Not included in production
+ &.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1) rotate(135deg);
+ transform: scaleX(-1) rotate(135deg);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+ }
+ &.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1) rotate(135deg);
+ -ms-transform: rotate(135deg);
+ transform: scaleY(-1) rotate(135deg);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+ }
+ */
+}
+
+.mdi-rotate-135:before {
+ -webkit-transform: rotate(135deg);
+ -ms-transform: rotate(135deg);
+ transform: rotate(135deg);
+}
+
+.mdi-rotate-180 {
+ /*
+ // Not included in production
+ &.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1) rotate(180deg);
+ transform: scaleX(-1) rotate(180deg);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+ }
+ &.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1) rotate(180deg);
+ -ms-transform: rotate(180deg);
+ transform: scaleY(-1) rotate(180deg);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+ }
+ */
+}
+
+.mdi-rotate-180:before {
+ -webkit-transform: rotate(180deg);
+ -ms-transform: rotate(180deg);
+ transform: rotate(180deg);
+}
+
+.mdi-rotate-225 {
+ /*
+ // Not included in production
+ &.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1) rotate(225deg);
+ transform: scaleX(-1) rotate(225deg);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+ }
+ &.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1) rotate(225deg);
+ -ms-transform: rotate(225deg);
+ transform: scaleY(-1) rotate(225deg);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+ }
+ */
+}
+
+.mdi-rotate-225:before {
+ -webkit-transform: rotate(225deg);
+ -ms-transform: rotate(225deg);
+ transform: rotate(225deg);
+}
+
+.mdi-rotate-270 {
+ /*
+ // Not included in production
+ &.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1) rotate(270deg);
+ transform: scaleX(-1) rotate(270deg);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+ }
+ &.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1) rotate(270deg);
+ -ms-transform: rotate(270deg);
+ transform: scaleY(-1) rotate(270deg);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+ }
+ */
+}
+
+.mdi-rotate-270:before {
+ -webkit-transform: rotate(270deg);
+ -ms-transform: rotate(270deg);
+ transform: rotate(270deg);
+}
+
+.mdi-rotate-315 {
+ /*
+ // Not included in production
+ &.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1) rotate(315deg);
+ transform: scaleX(-1) rotate(315deg);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+ }
+ &.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1) rotate(315deg);
+ -ms-transform: rotate(315deg);
+ transform: scaleY(-1) rotate(315deg);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+ }
+ */
+}
+
+.mdi-rotate-315:before {
+ -webkit-transform: rotate(315deg);
+ -ms-transform: rotate(315deg);
+ transform: rotate(315deg);
+}
+
+.mdi-flip-h:before {
+ -webkit-transform: scaleX(-1);
+ transform: scaleX(-1);
+ filter: FlipH;
+ -ms-filter: "FlipH";
+}
+
+.mdi-flip-v:before {
+ -webkit-transform: scaleY(-1);
+ transform: scaleY(-1);
+ filter: FlipV;
+ -ms-filter: "FlipV";
+}
+
+.mdi-spin:before {
+ -webkit-animation: mdi-spin 2s infinite linear;
+ animation: mdi-spin 2s infinite linear;
+}
+
+@-webkit-keyframes mdi-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
+
+@keyframes mdi-spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+}
diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff
new file mode 100644
index 000000000..20cd8f5c8
Binary files /dev/null and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff differ
diff --git a/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2
new file mode 100644
index 000000000..5ddf299e2
Binary files /dev/null and b/dashboard/src/assets/mdi-subset/materialdesignicons-webfont-subset.woff2 differ
diff --git a/dashboard/src/plugins/vuetify.ts b/dashboard/src/plugins/vuetify.ts
index d21fe2939..d13e35591 100644
--- a/dashboard/src/plugins/vuetify.ts
+++ b/dashboard/src/plugins/vuetify.ts
@@ -1,8 +1,8 @@
-import { createVuetify } from "vuetify";
-import "@mdi/font/css/materialdesignicons.css";
-import * as components from "vuetify/components";
-import * as directives from "vuetify/directives";
-import { PurpleTheme } from "@/theme/LightTheme";
+import { createVuetify } from 'vuetify';
+import '@/assets/mdi-subset/materialdesignicons-subset.css';
+import * as components from 'vuetify/components';
+import * as directives from 'vuetify/directives';
+import { PurpleTheme } from '@/theme/LightTheme';
import { PurpleThemeDark } from "@/theme/DarkTheme";
import { LIGHT_THEME_NAME, DARK_THEME_NAME } from "@/theme/constants";
diff --git a/dashboard/tests/subsetMdiFont.test.mjs b/dashboard/tests/subsetMdiFont.test.mjs
new file mode 100644
index 000000000..85b0ad0ba
--- /dev/null
+++ b/dashboard/tests/subsetMdiFont.test.mjs
@@ -0,0 +1,250 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdirSync, writeFileSync, rmSync } from 'fs';
+import { join } from 'path';
+import { tmpdir } from 'os';
+
+import {
+ collectFiles,
+ scanUsedIcons,
+ parseIconCodepoints,
+ resolveUsedIcons,
+ extractUtilityCss,
+ ICON_CLASS_PATTERN,
+} from '../scripts/subset-mdi-font.mjs';
+
+// ── Helper: create a temporary directory tree for file-system tests ─────────
+
+function makeTmpDir() {
+ const base = join(tmpdir(), `mdi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
+ mkdirSync(base, { recursive: true });
+ return base;
+}
+
+// ── collectFiles ────────────────────────────────────────────────────────────
+
+test('collectFiles yields files matching given extensions', () => {
+ const tmp = makeTmpDir();
+ writeFileSync(join(tmp, 'a.vue'), '');
+ writeFileSync(join(tmp, 'b.ts'), '');
+ writeFileSync(join(tmp, 'c.txt'), '');
+
+ const files = [...collectFiles(tmp, ['.vue', '.ts'])];
+ assert.equal(files.length, 2);
+ assert.ok(files.some(f => f.endsWith('a.vue')));
+ assert.ok(files.some(f => f.endsWith('b.ts')));
+
+ rmSync(tmp, { recursive: true });
+});
+
+test('collectFiles recurses into subdirectories', () => {
+ const tmp = makeTmpDir();
+ const sub = join(tmp, 'sub');
+ mkdirSync(sub);
+ writeFileSync(join(sub, 'deep.vue'), '');
+
+ const files = [...collectFiles(tmp, ['.vue'])];
+ assert.equal(files.length, 1);
+ assert.ok(files[0].endsWith('deep.vue'));
+
+ rmSync(tmp, { recursive: true });
+});
+
+test('collectFiles skips node_modules directories', () => {
+ const tmp = makeTmpDir();
+ const nm = join(tmp, 'node_modules');
+ mkdirSync(nm);
+ writeFileSync(join(nm, 'pkg.vue'), '');
+ writeFileSync(join(tmp, 'app.vue'), '');
+
+ const files = [...collectFiles(tmp, ['.vue'])];
+ assert.equal(files.length, 1);
+ assert.ok(files[0].endsWith('app.vue'));
+
+ rmSync(tmp, { recursive: true });
+});
+
+test('collectFiles yields nothing for empty directory', () => {
+ const tmp = makeTmpDir();
+ const files = [...collectFiles(tmp, ['.vue'])];
+ assert.equal(files.length, 0);
+
+ rmSync(tmp, { recursive: true });
+});
+
+// ── scanUsedIcons ───────────────────────────────────────────────────────────
+
+test('scanUsedIcons extracts mdi-* icon names from files', () => {
+ const tmp = makeTmpDir();
+ writeFileSync(join(tmp, 'A.vue'), '