Files
web/scripts/material-symbols-update.mts
Linus Flood 28974b979d Merged in feat/sw-3713-svg-optimize (pull request #3516)
feat(SW-3713): Optimize all SVGs

* feat(SW-3713): Optimize all SVGs


Approved-by: Anton Gunnarsson
2026-01-30 07:00:09 +00:00

391 lines
7.6 KiB
TypeScript

import {
writeFile,
readFile,
mkdir,
rmdir,
readdir,
rm,
} from "node:fs/promises"
import { resolve, join } from "node:path"
import { existsSync } from "node:fs"
import { optimize } from "svgo"
// Your full list of Material Symbol icon names
const ICONS = [
"accessibility",
"accessible",
"acute",
"add_circle",
"add",
"air_purifier_gen",
"air",
"airline_seat_recline_normal",
"airplane_ticket",
"apartment",
"apparel",
"arrow_back_ios",
"arrow_back",
"arrow_forward_ios",
"arrow_forward",
"arrow_right",
"arrow_upward",
"assistant_navigation",
"asterisk",
"attractions",
"award_star",
"bakery_dining",
"balcony",
"bathroom",
"bathtub",
"beach_access",
"bed",
"bedroom_parent",
"box",
"brunch_dining",
"business_center",
"calendar_add_on",
"calendar_clock",
"calendar_month",
"calendar_today",
"call_quality",
"call",
"camera",
"cancel",
"chair",
"mobile_charge",
"check_box",
"check_circle",
"check",
"checked_bag",
"checkroom",
"chevron_left",
"chevron_right",
"close",
"coffee_maker",
"coffee",
"compare_arrows",
"computer",
"concierge",
"confirmation_number",
"connected_tv",
"content_copy",
"contract",
"cool_to_dry",
"countertops",
"credit_card_heart",
"credit_card",
"credit_score",
"curtains_closed",
"curtains",
"dangerous",
"deck",
"delete",
"desk",
"device_thermostat",
"diamond",
"dining",
"directions_run",
"directions_subway",
"directions",
"downhill_skiing",
"download",
"dresser",
"edit_calendar",
"edit_square",
"edit",
"electric_bike",
"electric_car",
"elevator",
"emoji_transportation",
"error",
"exercise",
"family_restroom",
"fastfood",
"favorite",
"fax",
"featured_seasonal_and_gifts",
"festival",
"filter_alt",
"filter",
"format_list_bulleted",
"floor_lamp",
"forest",
"garage",
"globe",
"golf_course",
"groups",
"health_and_beauty",
"heat",
"hiking",
"home",
"hot_tub",
"houseboat",
"hvac",
"id_card",
"imagesmode",
"info",
"iron",
"kayaking",
"kettle",
"keyboard_arrow_down",
"keyboard_arrow_right",
"keyboard_arrow_up",
"king_bed",
"kitchen",
"landscape",
"laundry",
"link",
"liquor",
"live_tv",
"local_bar",
"local_cafe",
"local_convenience_store",
"local_drink",
"local_laundry_service",
"local_parking",
"location_city",
"location_on",
"lock",
"lock_clock",
"loyalty",
"luggage",
"mail",
"map",
"meeting_room",
"microwave",
"mode_fan",
"museum",
"music_cast",
"music_note",
"nature",
"night_shelter",
"nightlife",
"open_in_new",
"pan_zoom",
"panorama",
"pause",
"pedal_bike",
"person",
"pets",
"phone_enabled",
"photo_camera",
"play_arrow",
"pool",
"print",
"radio",
"recommend",
"redeem",
"refresh",
"remove",
"restaurant",
"room_service",
"router",
"sailing",
"sauna",
"scene",
"search",
"sell",
"shopping_bag",
"shower",
"single_bed",
"skateboarding",
"smoke_free",
"smoking_rooms",
"spa",
"sports_esports",
"sports_golf",
"sports_handball",
"sports_tennis",
"stairs",
"star",
"sticky_note_2",
"straighten",
"styler",
"support_agent",
"swipe",
"sync_saved_locally",
"table_bar",
"theater_comedy",
"things_to_do",
"train",
"tram",
"transit_ticket",
"travel_luggage_and_bags",
"travel",
"trophy",
"tv_guide",
"tv_remote",
"upload",
"visibility_off",
"visibility",
"volume_off",
"volume_up",
"ward",
"warning",
"water_full",
"wifi",
"yard",
].sort()
const STYLES = ["outlined"] as const
const OUT = resolve(
__dirname,
"../packages/design-system/lib/components/Icons/MaterialIcon/generated.tsx"
)
const REACT_OUT = resolve(
__dirname,
"../packages/design-system/lib/components/Icons/MaterialIcon/generated"
)
const PACKAGE_BASE = resolve(
__dirname,
"../node_modules/@material-symbols/svg-400"
)
function camelCase(str: string) {
return str.replace(/[-_](\w)/g, (_, c: string) => c.toUpperCase())
}
function pascalCase(str: string) {
const camel = camelCase(str)
return camel.charAt(0).toUpperCase() + camel.slice(1)
}
async function main() {
await rm(REACT_OUT, { recursive: true, force: true })
await mkdir(REACT_OUT, { recursive: true })
const imports: string[] = []
const registryEntries: string[] = []
const missing: string[] = []
for (const icon of ICONS) {
const styleEntries: string[] = []
for (const style of STYLES) {
const parts: string[] = []
const variants = [
{
suffix: "",
varNameSuffix: "Outlined",
path: resolve(PACKAGE_BASE, style, `${icon}.svg`),
key: "outlined",
},
{
suffix: "-fill",
varNameSuffix: "Filled",
path: resolve(PACKAGE_BASE, style, `${icon}-fill.svg`),
key: "filled",
},
]
for (const variant of variants) {
if (existsSync(variant.path)) {
const rawContent = await readFile(variant.path, "utf8")
// Optimize SVG with SVGO
const optimized = optimize(rawContent, {
multipass: true,
})
const content = optimized.data
const svgMatch = content.match(
/<svg[^>]*viewBox="([^"]*)"[^>]*>([\s\S]*?)<\/svg>/
)
if (svgMatch) {
const viewBox = svgMatch[1]
const inner = svgMatch[2]
const componentName = `${pascalCase(icon)}${variant.varNameSuffix}`
const fileName = `${componentName}.tsx`
const filePath = join(REACT_OUT, fileName)
const componentContent =
`
/* AUTO-GENERATED — DO NOT EDIT */
import type { SVGProps } from "react"
const ${componentName} = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox="${viewBox}" {...props}>
${inner.trim()}
</svg>
)
export default ${componentName}
`.trim() + "\n"
await writeFile(filePath, componentContent, "utf8")
imports.push(
`import ${componentName} from "./generated/${componentName}"`
)
parts.push(`${variant.key}: ${componentName}`)
} else {
console.warn(`Could not parse SVG for ${variant.path}`)
}
} else {
if (variant.key === "outlined") {
missing.push(`${style}/${icon}.svg`)
}
}
}
if (parts.length) {
styleEntries.push(`${style}: { ${parts.join(", ")} }`)
}
}
// ALWAYS emit the icon key (even if partial)
if (styleEntries.length) {
registryEntries.push(`"${icon}": { ${styleEntries.join(", ")} },`)
} else {
missing.push(`❌ no variants for "${icon}"`)
}
}
const content =
`
/* AUTO-GENERATED — DO NOT EDIT */
import type { FunctionComponent, SVGProps } from "react"
${imports.join("\n")}
type SvgIcon = FunctionComponent<SVGProps<SVGSVGElement>>
type IconMap = Record<
string,
Partial<{
outlined: { outlined: SvgIcon; filled?: SvgIcon }
rounded: { outlined: SvgIcon; filled?: SvgIcon }
sharp: { outlined: SvgIcon; filled?: SvgIcon }
}>
>
const _materialIcons = {
${registryEntries.join("\n")}
} satisfies IconMap
export const materialIcons = _materialIcons as IconMap
export type MaterialIconName = keyof typeof _materialIcons
`.trim() + "\n"
await writeFile(OUT, content, "utf8")
console.log(`✅ Generated ${registryEntries.length} icons`)
if (missing.length) {
console.warn("⚠️ Missing SVGs:")
missing.slice(0, 20).forEach((m) => console.warn(" ", m))
if (missing.length > 20) {
console.warn(` …and ${missing.length - 20} more`)
}
}
}
main().catch(console.error)