perf: subset tabler-icons font/CSS to used icons only (801KB -> 18KB woff2, 217KB -> 4KB CSS)
- Remove unused .ttf and .woff font files - Fix preload URL mismatch causing double font download - Remove content-visibility: auto (Firefox freeze on tab hover) - Add font-display: swap for non-blocking font loading - Add tools/subset-icons.py for future icon subsetting
This commit is contained in:
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tabler Icons Subset Tool for Keywarden
|
||||||
|
=======================================
|
||||||
|
Scans all HTML templates for used ti-* icon classes, then generates:
|
||||||
|
1. A subsetted woff2 font with only the needed glyphs
|
||||||
|
2. A minimal CSS file with only the matching icon rules
|
||||||
|
|
||||||
|
Prerequisites (one-time):
|
||||||
|
pip install fonttools brotli
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python tools/subset-icons.py
|
||||||
|
|
||||||
|
Source files (full Tabler Icons 3.6.0) are stored in tools/tabler-icons-full/.
|
||||||
|
Output goes directly to web/static/css/ and web/static/css/fonts/.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ── Paths (relative to project root) ──
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PROJECT_ROOT = os.path.dirname(SCRIPT_DIR)
|
||||||
|
|
||||||
|
TEMPLATE_DIR = os.path.join(PROJECT_ROOT, "web", "templates")
|
||||||
|
FULL_CSS = os.path.join(SCRIPT_DIR, "tabler-icons-full", "tabler-icons.min.css")
|
||||||
|
FULL_FONT = os.path.join(SCRIPT_DIR, "tabler-icons-full", "tabler-icons.woff2")
|
||||||
|
|
||||||
|
OUT_CSS = os.path.join(PROJECT_ROOT, "web", "static", "css", "tabler-icons.min.css")
|
||||||
|
OUT_FONT = os.path.join(PROJECT_ROOT, "web", "static", "css", "fonts", "tabler-icons.woff2")
|
||||||
|
|
||||||
|
|
||||||
|
def find_used_icons():
|
||||||
|
"""Scan all .html templates for ti-* class names."""
|
||||||
|
icons = set()
|
||||||
|
pattern = re.compile(r"ti-[a-z][a-z0-9-]+")
|
||||||
|
for root, _, files in os.walk(TEMPLATE_DIR):
|
||||||
|
for f in files:
|
||||||
|
if not f.endswith(".html"):
|
||||||
|
continue
|
||||||
|
with open(os.path.join(root, f), encoding="utf-8") as fh:
|
||||||
|
for match in pattern.finditer(fh.read()):
|
||||||
|
icons.add(match.group(0))
|
||||||
|
# ti-spin is a CSS animation class, not an icon glyph
|
||||||
|
icons.discard("ti-spin")
|
||||||
|
return sorted(icons)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_codepoints(css_text, icons):
|
||||||
|
"""Extract Unicode codepoints from the full CSS for each icon."""
|
||||||
|
codepoints = []
|
||||||
|
missing = []
|
||||||
|
for icon in icons:
|
||||||
|
pat = re.escape("." + icon) + r':before\{content:"\\([0-9a-f]+)"\}'
|
||||||
|
m = re.search(pat, css_text)
|
||||||
|
if m:
|
||||||
|
codepoints.append(m.group(1))
|
||||||
|
else:
|
||||||
|
missing.append(icon)
|
||||||
|
return codepoints, missing
|
||||||
|
|
||||||
|
|
||||||
|
def build_subset_css(css_text, icons):
|
||||||
|
"""Build a minimal CSS containing only the @font-face, .ti base rule,
|
||||||
|
and the individual icon rules for the used icons."""
|
||||||
|
# Extract header comment + @font-face + .ti base rule
|
||||||
|
m = re.match(r'(/\*[\s\S]*?\*/)(@font-face\{[^}]+\})(\.ti\{[^}]+\})', css_text)
|
||||||
|
if not m:
|
||||||
|
print("ERROR: Could not parse base CSS rules from full source")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Patch @font-face: keep only woff2 and add font-display:swap
|
||||||
|
font_face = m.group(2)
|
||||||
|
# Remove woff and truetype sources, keep only woff2
|
||||||
|
font_face = re.sub(
|
||||||
|
r',url\("[^"]*\.woff\?[^"]*"\)\s*format\("woff"\)', '', font_face
|
||||||
|
)
|
||||||
|
font_face = re.sub(
|
||||||
|
r',url\("[^"]*\.ttf[^"]*"\)\s*format\("truetype"\)', '', font_face
|
||||||
|
)
|
||||||
|
# Add font-display:swap if not present
|
||||||
|
if "font-display" not in font_face:
|
||||||
|
font_face = font_face.replace(
|
||||||
|
"font-weight:400;",
|
||||||
|
"font-weight:400;font-display:swap;"
|
||||||
|
)
|
||||||
|
|
||||||
|
header = m.group(1) + font_face + m.group(3)
|
||||||
|
|
||||||
|
# Extract individual icon rules
|
||||||
|
rules = []
|
||||||
|
for icon in icons:
|
||||||
|
pat = re.escape("." + icon) + r':before\{content:"\\[0-9a-f]+"\}'
|
||||||
|
match = re.search(pat, css_text)
|
||||||
|
if match:
|
||||||
|
rules.append(match.group(0))
|
||||||
|
|
||||||
|
# Keep .ti-spin animation if present
|
||||||
|
result = header + "".join(rules)
|
||||||
|
spin_kf = re.search(r'@keyframes\s+spin\{[^}]+\{[^}]+\}\}', css_text)
|
||||||
|
spin_cls = re.search(r'\.ti-spin\{[^}]+\}', css_text)
|
||||||
|
if spin_kf:
|
||||||
|
result += spin_kf.group(0)
|
||||||
|
if spin_cls:
|
||||||
|
result += spin_cls.group(0)
|
||||||
|
|
||||||
|
return result, len(rules)
|
||||||
|
|
||||||
|
|
||||||
|
def subset_font(codepoints):
|
||||||
|
"""Run pyftsubset to create a woff2 with only the needed glyphs."""
|
||||||
|
unicodes = ",".join(f"U+{cp}" for cp in codepoints)
|
||||||
|
cmd = [
|
||||||
|
sys.executable, "-m", "fontTools.subset",
|
||||||
|
FULL_FONT,
|
||||||
|
f"--output-file={OUT_FONT}",
|
||||||
|
"--flavor=woff2",
|
||||||
|
"--no-layout-closure",
|
||||||
|
"--drop-tables+=GSUB,GPOS,GDEF",
|
||||||
|
f"--unicodes={unicodes}",
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("ERROR: pyftsubset failed:")
|
||||||
|
print(result.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Verify source files exist
|
||||||
|
for path in (FULL_CSS, FULL_FONT):
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"ERROR: Source file missing: {path}")
|
||||||
|
print("These should be in tools/tabler-icons-full/")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Try importing fonttools
|
||||||
|
try:
|
||||||
|
import fontTools # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
print("ERROR: fonttools not installed. Run: pip install fonttools brotli")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print("Scanning templates for icon usage...")
|
||||||
|
icons = find_used_icons()
|
||||||
|
print(f" Found {len(icons)} unique icons")
|
||||||
|
|
||||||
|
print("Reading full CSS source...")
|
||||||
|
with open(FULL_CSS, encoding="utf-8") as f:
|
||||||
|
full_css = f.read()
|
||||||
|
|
||||||
|
print("Extracting Unicode codepoints...")
|
||||||
|
codepoints, missing = extract_codepoints(full_css, icons)
|
||||||
|
if missing:
|
||||||
|
print(f" WARNING: No codepoint found for: {', '.join(missing)}")
|
||||||
|
print(f" Mapped {len(codepoints)} codepoints")
|
||||||
|
|
||||||
|
print("Subsetting font...")
|
||||||
|
subset_font(codepoints)
|
||||||
|
orig_size = os.path.getsize(FULL_FONT)
|
||||||
|
new_size = os.path.getsize(OUT_FONT)
|
||||||
|
print(f" {orig_size//1024} KB -> {new_size//1024} KB ({100-round(new_size/orig_size*100,1)}% smaller)")
|
||||||
|
|
||||||
|
print("Building subset CSS...")
|
||||||
|
css_out, rule_count = build_subset_css(full_css, icons)
|
||||||
|
with open(OUT_CSS, "w", encoding="utf-8") as f:
|
||||||
|
f.write(css_out)
|
||||||
|
orig_css_size = os.path.getsize(FULL_CSS)
|
||||||
|
new_css_size = os.path.getsize(OUT_CSS)
|
||||||
|
print(f" {rule_count} icon rules, {orig_css_size//1024} KB -> {new_css_size//1024} KB")
|
||||||
|
|
||||||
|
print("\nDone! Subsetted files written to:")
|
||||||
|
print(f" Font: {os.path.relpath(OUT_FONT, PROJECT_ROOT)}")
|
||||||
|
print(f" CSS: {os.path.relpath(OUT_CSS, PROJECT_ROOT)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -8,7 +8,7 @@
|
|||||||
<title>{{.Title}} - {{appName}}</title>
|
<title>{{.Title}} - {{appName}}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
<!-- Preload icon font to prevent re-decode lag on tab restore -->
|
<!-- Preload icon font to prevent re-decode lag on tab restore -->
|
||||||
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="/static/css/fonts/tabler-icons.woff2?v3.6.0" as="font" type="font/woff2" crossorigin>
|
||||||
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
|
<!-- Resolve theme BEFORE loading CSS to prevent FOUC (flash of unstyled content) -->
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
[data-bs-theme="dark"] .page-body { background: #0F1829; }
|
[data-bs-theme="dark"] .page-body { background: #0F1829; }
|
||||||
html[data-bs-theme="dark"],
|
html[data-bs-theme="dark"],
|
||||||
html[data-bs-theme="dark"] body { background-color: #0F1829 !important; }
|
html[data-bs-theme="dark"] body { background-color: #0F1829 !important; }
|
||||||
.page-body { content-visibility: auto; contain-intrinsic-size: auto 500px; }
|
/* content-visibility removed – causes Firefox to freeze/re-layout on tab hover */
|
||||||
|
|
||||||
/* ── Narrower dashboard stat cards ── */
|
/* ── Narrower dashboard stat cards ── */
|
||||||
.stat-card-narrow { max-width: 220px; }
|
.stat-card-narrow { max-width: 220px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user