diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2025-05-22 22:56:38 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-05-22 22:56:38 +0900 |
| commit | e6e8bfa591b28de29709139d4d238205d7a7e171 (patch) | |
| tree | 87091efbe1f4662171deabda8068183f85c1123c /packages | |
| parent | fix(deps): update [backend] update dependencies (#15911) (diff) | |
| download | misskey-e6e8bfa591b28de29709139d4d238205d7a7e171.tar.gz misskey-e6e8bfa591b28de29709139d4d238205d7a7e171.tar.bz2 misskey-e6e8bfa591b28de29709139d4d238205d7a7e171.zip | |
feat(frontend): tabler-iconsのサブセット化 (#15340)
* feat(frontend): tabler-iconsの使用されていないアイコンを削除するように
* fix
* fix
* fix
* fix
* fix
* Update Changelog
* enhance: tablerのCSSを使用されているクラスのみに限定
* 使用するアイコンパッケージをそろえる
* Update CONTRIBUTING.md
* Update CONTRIBUTING.md
* spdx
* typo
* fix: サブセットから除外される書き方をしている部分を修正
* fix: 同じunicodeに複数のアイコンclassが割り当てられている場合に対応
* remove debug code
* Update CHANGELOG.md
* fix merge error
* setup renovate
* fix: woff2ではなくwoffに変換していたのを修正
* update deps
* update changelog
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/frontend-embed/package.json | 3 | ||||
| -rw-r--r-- | packages/frontend-embed/src/boot.ts | 6 | ||||
| -rw-r--r-- | packages/frontend/package.json | 3 | ||||
| -rw-r--r-- | packages/frontend/src/_boot_.ts | 6 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkDrive.folder.vue | 8 | ||||
| -rw-r--r-- | packages/icons-subsetter/README.md | 15 | ||||
| -rw-r--r-- | packages/icons-subsetter/eslint.config.js | 18 | ||||
| -rw-r--r-- | packages/icons-subsetter/package.json | 30 | ||||
| -rw-r--r-- | packages/icons-subsetter/src/generator.ts | 141 | ||||
| -rw-r--r-- | packages/icons-subsetter/src/subsetter.ts | 81 | ||||
| -rw-r--r-- | packages/icons-subsetter/tsconfig.json | 20 |
11 files changed, 322 insertions, 9 deletions
diff --git a/packages/frontend-embed/package.json b/packages/frontend-embed/package.json index 026ecd96de..440aaf860b 100644 --- a/packages/frontend-embed/package.json +++ b/packages/frontend-embed/package.json @@ -14,13 +14,13 @@ "@rollup/plugin-json": "6.1.0", "@rollup/plugin-replace": "6.0.2", "@rollup/pluginutils": "5.1.4", - "@tabler/icons-webfont": "3.33.0", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.2.4", "@vue/compiler-sfc": "3.5.14", "astring": "1.9.0", "buraha": "0.0.1", "estree-walker": "3.0.3", + "icons-subsetter": "workspace:*", "frontend-shared": "workspace:*", "json5": "2.2.3", "mfm-js": "0.24.0", @@ -39,6 +39,7 @@ }, "devDependencies": { "@misskey-dev/summaly": "5.2.1", + "@tabler/icons-webfont": "3.33.0", "@testing-library/vue": "8.1.0", "@types/estree": "1.0.7", "@types/micromatch": "4.0.9", diff --git a/packages/frontend-embed/src/boot.ts b/packages/frontend-embed/src/boot.ts index c1b2b58beb..459b283e23 100644 --- a/packages/frontend-embed/src/boot.ts +++ b/packages/frontend-embed/src/boot.ts @@ -6,7 +6,11 @@ // https://vitejs.dev/config/build-options.html#build-modulepreload import 'vite/modulepreload-polyfill'; -import '@tabler/icons-webfont/dist/tabler-icons.scss'; +if (import.meta.env.DEV) { + await import('@tabler/icons-webfont/dist/tabler-icons.scss'); +} else { + await import('icons-subsetter/built/tabler-icons-frontendEmbed.css'); +} import '@/style.scss'; import { createApp, defineAsyncComponent } from 'vue'; diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 2dcda56ceb..c7b32b5f2d 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -26,7 +26,6 @@ "@rollup/pluginutils": "5.1.4", "@sentry/vue": "9.22.0", "@syuilo/aiscript": "0.19.0", - "@tabler/icons-webfont": "3.33.0", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.2.4", "@vue/compiler-sfc": "3.5.14", @@ -48,6 +47,7 @@ "estree-walker": "3.0.3", "eventemitter3": "5.0.1", "frontend-shared": "workspace:*", + "icons-subsetter": "workspace:*", "idb-keyval": "6.2.2", "insert-text-at-cursor": "0.3.0", "is-file-animated": "1.0.2", @@ -99,6 +99,7 @@ "@storybook/types": "8.6.14", "@storybook/vue3": "8.6.14", "@storybook/vue3-vite": "8.6.14", + "@tabler/icons-webfont": "3.33.0", "@testing-library/vue": "8.1.0", "@types/canvas-confetti": "1.9.0", "@types/estree": "1.0.7", diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 3241f2dc92..354fb95544 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -6,7 +6,11 @@ // https://vitejs.dev/config/build-options.html#build-modulepreload import 'vite/modulepreload-polyfill'; -import '@tabler/icons-webfont/dist/tabler-icons.scss'; +if (import.meta.env.DEV) { + await import('@tabler/icons-webfont/dist/tabler-icons.scss'); +} else { + await import('icons-subsetter/built/tabler-icons-frontend.css'); +} import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue index 83472eec3d..8ba7520f35 100644 --- a/packages/frontend/src/components/MkDrive.folder.vue +++ b/packages/frontend/src/components/MkDrive.folder.vue @@ -26,7 +26,7 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.uploadFolder }} </div> <button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked"> - <div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div> + <div :class="[$style.checkbox, { [$style.checked]: isSelected, 'ti ti-check': isSelected }]"></div> </button> </div> </template> @@ -368,16 +368,14 @@ function onContextmenu(ev: MouseEvent) { border-color: var(--MI_THEME-accent); background: var(--MI_THEME-accent); - &::after { - content: "\ea5e"; - font-family: 'tabler-icons'; + &::before { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #fff; font-size: 12px; - line-height: 22px; + line-height: 18px; } } } diff --git a/packages/icons-subsetter/README.md b/packages/icons-subsetter/README.md new file mode 100644 index 0000000000..1249d65644 --- /dev/null +++ b/packages/icons-subsetter/README.md @@ -0,0 +1,15 @@ +## これは何 + +フロントエンドの各パッケージで使用されているtabler iconsのclassをスキャンし、使用されているiconのみを抽出するツールです。 + +なお、サブセット版に無いアイコンが呼び出された場合は本物のtabler icons フォントにフォールバックするようになっています。 + +このツールは本番ビルド時にのみ使用されます(開発モードでも最初の1回だけビルドが走りますが、これは型エラーを抑制するためにファイルを置いておく用の措置です) + +現時点では `src/generator.ts` の `filesToScan` にスキャン対象のファイルが書かれています。もしこれに当てはまらないファイルをサブセットのスキャン対象とする場合はこの部分を適宜修正してください。 + +## 使い方 + +```bash +pnpm build +``` diff --git a/packages/icons-subsetter/eslint.config.js b/packages/icons-subsetter/eslint.config.js new file mode 100644 index 0000000000..957100fd8c --- /dev/null +++ b/packages/icons-subsetter/eslint.config.js @@ -0,0 +1,18 @@ +import tsParser from '@typescript-eslint/parser'; +import sharedConfig from '../shared/eslint.config.js'; + +// eslint-disable-next-line import/no-default-export +export default [ + ...sharedConfig, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + parser: tsParser, + project: ['./tsconfig.json'], + sourceType: 'module', + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +]; diff --git a/packages/icons-subsetter/package.json b/packages/icons-subsetter/package.json new file mode 100644 index 0000000000..0a28f9e038 --- /dev/null +++ b/packages/icons-subsetter/package.json @@ -0,0 +1,30 @@ +{ + "name": "icons-subsetter", + "version": "0.0.0", + "private": true, + "description": "Subset tabler-icons webfont", + "type": "module", + "scripts": { + "build": "tsx src/generator.ts", + "eslint": "eslint src/**/*.ts", + "typecheck": "tsc --noEmit", + "lint": "pnpm typecheck && pnpm eslint" + }, + "devDependencies": { + "@types/node": "22.15.21", + "@types/wawoff2": "1.0.2", + "@typescript-eslint/eslint-plugin": "8.32.1", + "@typescript-eslint/parser": "8.32.1" + }, + "dependencies": { + "@tabler/icons-webfont": "3.33.0", + "harfbuzzjs": "0.4.7", + "tiny-glob": "0.2.9", + "tsx": "4.19.4", + "typescript": "5.8.3", + "wawoff2": "2.0.1" + }, + "files": [ + "built" + ] +} diff --git a/packages/icons-subsetter/src/generator.ts b/packages/icons-subsetter/src/generator.ts new file mode 100644 index 0000000000..1a9e3d8fd2 --- /dev/null +++ b/packages/icons-subsetter/src/generator.ts @@ -0,0 +1,141 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fsp, existsSync } from 'fs'; +import path from 'path'; +import glob from 'tiny-glob'; +import { generateSubsettedFont } from './subsetter.js'; + +const filesToScan = { + frontend: 'packages/frontend/src/**/*.{ts,vue}', + //frontendShared: 'packages/frontend-shared/js/**/*.{ts}', // 現時点では該当がないのでスキップ。ここをコメントアウトするときは、各フロントエンドにこのチャンクのCSSのimportを追加すること + frontendEmbed: 'packages/frontend-embed/src/**/*.{ts,vue}', +}; + +async function main() { + const start = performance.now(); + + // 1. ビルドディレクトリを削除 + if (existsSync('./built')) { + await fsp.rm('./built', { recursive: true }); + } + await fsp.mkdir('./built'); + + // 2. tabler-icons.min.cssから、class名とUnicodeのマッピングを抽出 + const css = await fsp.readFile('node_modules/@tabler/icons-webfont/dist/tabler-icons.min.css', 'utf-8'); + const cssRegex = /\.(ti-[a-z0-9-]+)::?before\s*{\n?\s*content:\s*["']\\([a-fA-F0-9]+)["'];?\n?\s*}/g; + const rgMap = new Map<string, string>(); + let matches: RegExpExecArray | null; + while ((matches = cssRegex.exec(css)) !== null) { + rgMap.set(matches[1], matches[2]); + } + + // 3. tabler-icons-classes.cssから、.tiのルールを抽出 + const classTiBaseRule = css.match(/\.ti\s*{[^}]*}/)![0]; + + // 4. フォールバック用のtabler-icons.woff2をコピー + const fontPath = 'node_modules/@tabler/icons-webfont/dist/fonts/'; + await fsp.copyFile(fontPath + 'tabler-icons.woff2', './built/tabler-icons.woff2'); + + // 5. 各チャンクごとにファイルをスキャンして、使用されているアイコンを抽出 + const unicodeRangeValues = new Map<string, number[]>(); + for (const [key, dir] of Object.entries(filesToScan)) { + console.log(`Scanning ${key}...`); + + const iconsToPack = new Set<string>(); + + const cwd = path.resolve(process.cwd(), '../../'); + const files = await glob(dir, { cwd }); + for (const file of files) { + //console.log(`Scanning ${file}`); + const content = await fsp.readFile(path.resolve(cwd, file), 'utf-8'); + const classRegex = /ti-[a-z0-9-]+/g; + let matches: RegExpExecArray | null; + while ((matches = classRegex.exec(content)) !== null) { + const icon = matches[0]; + if (rgMap.has(icon)) { + iconsToPack.add(icon); + } + } + } + + // 6. チャンク内で使用されているアイコンのUnicodeの配列を生成 + const unicodeValues = Array.from(iconsToPack).map((icon) => parseInt(rgMap.get(icon)!, 16)); + unicodeRangeValues.set(key, unicodeValues); + } + + // 7. Tabler Iconフォントをサブセット化 + const subsettedFonts = await generateSubsettedFont(fontPath + 'tabler-icons.ttf', unicodeRangeValues); + + // 8. サブセット化したフォント・CSSを書き出し + await Promise.allSettled(Array.from(subsettedFonts.entries()).map(async ([key, buffer]) => { + const cssRules = [`@font-face { + font-family: "tabler-icons"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("./tabler-icons.woff2") format("woff2"); +}`]; + + // サブセット化したフォントの中身がある(=unicodeRangeValuesの配列が空ではない)場合のみ、サブセットしたものに関する情報を追記 + if (unicodeRangeValues.get(key)!.length > 0) { + await fsp.writeFile(`./built/tabler-icons-${key}.woff2`, buffer); + + const unicodeRangeString = (() => { + const values = unicodeRangeValues.get(key)!.sort((a, b) => a - b); + const ranges = []; + + for (let i = 0; i < values.length; i++) { + const start = values[i]; + let end = values[i]; + while (values[i + 1] === end + 1) { + end = values[i + 1]; + i++; + } + if (start === end) { + ranges.push(`U+${start.toString(16)}`); + } else if (start + 1 === end) { + ranges.push(`U+${start.toString(16)}`, `U+${end.toString(16)}`); + } else { + ranges.push(`U+${start.toString(16)}-${end.toString(16)}`); + } + } + + return ranges.join(', '); + })(); + + cssRules.push(`@font-face { + font-family: "tabler-icons"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("./tabler-icons-${key}.woff2") format("woff2"); + unicode-range: ${unicodeRangeString}; +}`); + + cssRules.push(classTiBaseRule); + + // 使用されているアイコンのclassとの対応を追記 + for (const icon of unicodeRangeValues.get(key)!) { + const iconClasses = Array.from(rgMap.entries()).filter(([_, unicode]) => parseInt(unicode, 16) === icon); + if (iconClasses.length > 1) { + console.warn(`[WARN] Multiple classes for the same unicode: ${iconClasses.map(([cls]) => cls).join(', ')}. Maybe it's deprecated?`); + } + const iconSelector = iconClasses.map(([className]) => `.${className}::before`).join(', '); + cssRules.push(`${iconSelector} { content: "\\${icon.toString(16)}"; }`); + } + } + + await fsp.writeFile(`./built/tabler-icons-${key}.css`, cssRules.join('\n') + '\n'); + })); + + const end = performance.now(); + console.log(`Done in ${Math.round((end - start) * 100) / 100}ms`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/icons-subsetter/src/subsetter.ts b/packages/icons-subsetter/src/subsetter.ts new file mode 100644 index 0000000000..cd1aed2890 --- /dev/null +++ b/packages/icons-subsetter/src/subsetter.ts @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { promises as fsp } from 'fs'; +import { compress } from 'wawoff2'; + +export async function generateSubsettedFont(ttfPath: string, unicodeRangeValues: Map<string, number[]>) { + const ttf = await fsp.readFile(ttfPath); + + const { + instance: { exports: harfbuzzWasm }, + }: any = await WebAssembly.instantiate(await fsp.readFile('./node_modules/harfbuzzjs/hb-subset.wasm')); + + const heapu8 = new Uint8Array(harfbuzzWasm.memory.buffer); + + const subsetFonts = new Map<string, Buffer>(); + + let i = 0; + for (const [key, unicodeValues] of unicodeRangeValues) { + i++; + console.log(`Generating subset ${i} of ${unicodeRangeValues.size}...`); + + // サブセット入力を作成 + const input = harfbuzzWasm.hb_subset_input_create_or_fail(); + if (input === 0) { + throw new Error('hb_subset_input_create_or_fail (harfbuzz) returned zero'); + } + + // フォントバッファにフォントデータをセット + const fontBuffer = harfbuzzWasm.malloc(ttf.byteLength); + heapu8.set(new Uint8Array(ttf), fontBuffer); + + // フォントフェイスを作成 + const blob = harfbuzzWasm.hb_blob_create(fontBuffer, ttf.byteLength, 2, 0, 0); + const face = harfbuzzWasm.hb_face_create(blob, 0); + harfbuzzWasm.hb_blob_destroy(blob); + + // Unicodeセットに指定されたUnicodeポイントを追加 + const inputUnicodes = harfbuzzWasm.hb_subset_input_unicode_set(input); + for (const unicode of unicodeValues) { + harfbuzzWasm.hb_set_add(inputUnicodes, unicode); + } + + // サブセットを作成 + let subset; + try { + subset = harfbuzzWasm.hb_subset_or_fail(face, input); + if (subset === 0) { + harfbuzzWasm.hb_face_destroy(face); + harfbuzzWasm.free(fontBuffer); + throw new Error('hb_subset_or_fail (harfbuzz) returned zero'); + } + } finally { + harfbuzzWasm.hb_subset_input_destroy(input); + } + + // サブセットフォントデータを取得 + const result = harfbuzzWasm.hb_face_reference_blob(subset); + const offset = harfbuzzWasm.hb_blob_get_data(result, 0); + const subsetByteLength = harfbuzzWasm.hb_blob_get_length(result); + if (subsetByteLength === 0) { + harfbuzzWasm.hb_face_destroy(face); + harfbuzzWasm.hb_blob_destroy(result); + harfbuzzWasm.free(fontBuffer); + throw new Error('hb_blob_get_length (harfbuzz) returned zero'); + } + + // サブセットフォントをバッファに格納 + subsetFonts.set(key, Buffer.from(await compress(heapu8.slice(offset, offset + subsetByteLength)))); + + // メモリを解放 + harfbuzzWasm.hb_blob_destroy(result); + harfbuzzWasm.hb_face_destroy(subset); + harfbuzzWasm.hb_face_destroy(face); + harfbuzzWasm.free(fontBuffer); + } + + return subsetFonts; +} diff --git a/packages/icons-subsetter/tsconfig.json b/packages/icons-subsetter/tsconfig.json new file mode 100644 index 0000000000..08315a91cf --- /dev/null +++ b/packages/icons-subsetter/tsconfig.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "esModuleInterop": true, + "lib": [ + "esnext", + "dom" + ] + }, + "include": [ + "src/**/*.ts" + ], + "exclude": [] +} |