forked from mirrors/misskey
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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
15
packages/icons-subsetter/README.md
Normal file
15
packages/icons-subsetter/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## これは何
|
||||
|
||||
フロントエンドの各パッケージで使用されているtabler iconsのclassをスキャンし、使用されているiconのみを抽出するツールです。
|
||||
|
||||
なお、サブセット版に無いアイコンが呼び出された場合は本物のtabler icons フォントにフォールバックするようになっています。
|
||||
|
||||
このツールは本番ビルド時にのみ使用されます(開発モードでも最初の1回だけビルドが走りますが、これは型エラーを抑制するためにファイルを置いておく用の措置です)
|
||||
|
||||
現時点では `src/generator.ts` の `filesToScan` にスキャン対象のファイルが書かれています。もしこれに当てはまらないファイルをサブセットのスキャン対象とする場合はこの部分を適宜修正してください。
|
||||
|
||||
## 使い方
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
18
packages/icons-subsetter/eslint.config.js
Normal file
18
packages/icons-subsetter/eslint.config.js
Normal file
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
30
packages/icons-subsetter/package.json
Normal file
30
packages/icons-subsetter/package.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
141
packages/icons-subsetter/src/generator.ts
Normal file
141
packages/icons-subsetter/src/generator.ts
Normal file
@@ -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);
|
||||
});
|
||||
81
packages/icons-subsetter/src/subsetter.ts
Normal file
81
packages/icons-subsetter/src/subsetter.ts
Normal file
@@ -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;
|
||||
}
|
||||
20
packages/icons-subsetter/tsconfig.json
Normal file
20
packages/icons-subsetter/tsconfig.json
Normal file
@@ -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": []
|
||||
}
|
||||
Reference in New Issue
Block a user