mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-30 19:32:46 +08:00
perf(icons): reduce and guard SVG path precision (#6839)
* perf(react): reduce SVG path precision * fix(react): preserve Sim wordmark precision * fix(icons): preserve Quartr scale * test(icons): ratchet SVG path precision * fix(icons): make precision exceptions local * perf(icons): enforce three-decimal paths
This commit is contained in:
+133
-139
File diff suppressed because one or more lines are too long
+133
-139
File diff suppressed because one or more lines are too long
+3
-1
@@ -14,8 +14,9 @@
|
||||
"dev:sockets": "cd apps/realtime && bun run dev",
|
||||
"dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev\" \"cd apps/realtime && bun run dev\"",
|
||||
"dev:full:capped": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"cd apps/sim && bun run dev:capped\" \"cd apps/realtime && bun run dev\"",
|
||||
"test": "bun run test:setup && turbo run test",
|
||||
"test": "bun run test:setup && bun run test:icon-path-precision && turbo run test",
|
||||
"test:setup": "bun run --cwd packages/sim-setup test",
|
||||
"test:icon-path-precision": "bunx vitest run scripts/check-icon-path-precision.test.ts",
|
||||
"format": "turbo run format",
|
||||
"format:check": "turbo run format:check",
|
||||
"lint": "turbo run lint",
|
||||
@@ -47,6 +48,7 @@
|
||||
"check:canvas-sentences": "bun run apps/sim/scripts/check-canvas-sentences.ts --require-coverage",
|
||||
"check:bare-icons": "bun run scripts/check-bare-icons.ts",
|
||||
"check:icon-paths": "bun run scripts/check-icon-paths.ts",
|
||||
"check:icon-path-precision": "bun run scripts/check-icon-path-precision.ts",
|
||||
"check:migrations": "bun run scripts/check-migrations-safety.ts",
|
||||
"check:native-typecheck": "bun run scripts/check-native-typecheck.ts",
|
||||
"check:source-text": "bun run scripts/check-source-text.ts",
|
||||
|
||||
@@ -21,7 +21,7 @@ export function BubbleChatDelay({ size = 24, width, height, ...props }: IconProp
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M21.5 12C21.5 17.2467 17.2467 21.5 12 21.5C10.3719 21.5 8.8394 21.0904 7.5 20.3687C5.63177 19.362 4.37462 20.2979 3.26592 20.4658C3.09774 20.4913 2.93024 20.4302 2.80997 20.31C2.62741 20.1274 2.59266 19.8451 2.6935 19.6074C3.12865 18.5818 3.5282 16.6382 2.98341 15C2.6698 14.057 2.5 13.0483 2.5 12C2.5 6.75329 6.75329 2.5 12 2.5C17.2467 2.5 21.5 6.75329 21.5 12Z'
|
||||
d='M21.5 12C21.5 17.25 17.25 21.5 12 21.5C10.37 21.5 8.84 21.09 7.5 20.37C5.63 19.36 4.37 20.3 3.27 20.47C3.1 20.49 2.93 20.43 2.81 20.31C2.63 20.13 2.59 19.85 2.69 19.61C3.13 18.58 3.53 16.64 2.98 15C2.67 14.06 2.5 13.05 2.5 12C2.5 6.75 6.75 2.5 12 2.5C17.25 2.5 21.5 6.75 21.5 12Z'
|
||||
stroke='currentColor'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
|
||||
@@ -16,7 +16,7 @@ export function DocumentAttachment(props: SVGProps<SVGSVGElement>) {
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M10.5 5.69V5.1C10.5 3.99 10.499 3.2 10.414 2.61C10.33 2.02 10.18 1.69 9.91 1.44C9.65 1.19 9.28 1.04 8.65 0.96C8.01 0.88 7.17 0.875 6 0.875H5.38C4.2 0.875 3.36 0.88 2.72 0.96C2.09 1.04 1.73 1.19 1.46 1.44C1.2 1.69 1.04 2.02 0.96 2.61C0.88 3.2 0.88 3.99 0.88 5.1V7.44C0.88 8.55 0.88 9.34 0.96 9.94C1.04 10.52 1.2 10.85 1.46 11.1C1.73 11.354 2.09 11.5 2.72 11.58C3.36 11.67 4.2 11.67 5.38 11.67H5.69C5.93 11.67 6.125 11.86 6.125 12.1C6.125 12.35 5.93 12.54 5.69 12.54H5.38C4.23 12.54 3.32 12.54 2.61 12.45C1.9 12.36 1.32 12.17 0.86 11.739C0.4 11.3 0.19 10.75 0.09 10.06C0 9.39 8.49e-07 8.52 8.99e-07 7.44V5.1C9.49e-07 4.02 0 3.16 0.09 2.48C0.19 1.79 0.4 1.24 0.86 0.8C1.32 0.37 1.9 0.18 2.61 0.09C3.32 0 4.23 2.75e-07 5.38 3.23e-07H6C7.15 3.71e-07 8.06 0 8.76 0.09C9.48 0.18 10.06 0.37 10.51 0.8C10.98 1.24 11.18 1.79 11.28 2.48C11.38 3.16 11.375 4.02 11.375 5.1V5.69C11.375 5.93 11.18 6.125 10.94 6.125C10.7 6.125 10.5 5.93 10.5 5.69Z'
|
||||
d='M10.5 5.69V5.1C10.5 3.99 10.499 3.2 10.414 2.61C10.33 2.02 10.18 1.69 9.91 1.44C9.65 1.19 9.28 1.04 8.65 0.96C8.01 0.88 7.17 0.875 6 0.875H5.38C4.2 0.875 3.36 0.88 2.72 0.96C2.09 1.04 1.73 1.19 1.46 1.44C1.2 1.69 1.04 2.02 0.96 2.61C0.88 3.2 0.88 3.99 0.88 5.1V7.44C0.88 8.55 0.88 9.34 0.96 9.94C1.04 10.52 1.2 10.85 1.46 11.1C1.73 11.354 2.09 11.5 2.72 11.58C3.36 11.67 4.2 11.67 5.38 11.67H5.69C5.93 11.67 6.125 11.86 6.125 12.1C6.125 12.35 5.93 12.54 5.69 12.54H5.38C4.23 12.54 3.32 12.54 2.61 12.45C1.9 12.36 1.32 12.17 0.86 11.739C0.4 11.3 0.19 10.75 0.09 10.06C0 9.39 0 8.52 0 7.44V5.1C0 4.02 0 3.16 0.09 2.48C0.19 1.79 0.4 1.24 0.86 0.8C1.32 0.37 1.9 0.18 2.61 0.09C3.32 0 4.23 0 5.38 0H6C7.15 0 8.06 0 8.76 0.09C9.48 0.18 10.06 0.37 10.51 0.8C10.98 1.24 11.18 1.79 11.28 2.48C11.38 3.16 11.375 4.02 11.375 5.1V5.69C11.375 5.93 11.18 6.125 10.94 6.125C10.7 6.125 10.5 5.93 10.5 5.69Z'
|
||||
fill='currentColor'
|
||||
/>
|
||||
<path
|
||||
|
||||
@@ -33,7 +33,7 @@ export function Download({ animate = false, className, ...props }: DownloadProps
|
||||
{...props}
|
||||
>
|
||||
{/* tray — same as Upload */}
|
||||
<path d='M0.75 12.75V16.75C0.75 17.8546 1.64543 18.75 2.75 18.75H17.75C18.8546 18.75 19.75 17.8546 19.75 16.75V12.75' />
|
||||
<path d='M0.75 12.75V16.75C0.75 17.85 1.65 18.75 2.75 18.75H17.75C18.85 18.75 19.75 17.85 19.75 16.75V12.75' />
|
||||
{/* stem — top to tray */}
|
||||
<path d='M10.25 1.75V12.75' />
|
||||
{/* arrowhead pointing down */}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Duplicate(props: SVGProps<SVGSVGElement>) {
|
||||
aria-hidden='true'
|
||||
{...props}
|
||||
>
|
||||
<path d='M14.25 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V14.25' />
|
||||
<path d='M14.25 0.75H2.75C1.65 0.75 0.75 1.65 0.75 2.75V14.25' />
|
||||
<rect x='5.25' y='5.25' width='14' height='14' rx='2' />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ export function Mic(props: SVGProps<SVGSVGElement>) {
|
||||
{...props}
|
||||
>
|
||||
<rect x='7.25' y='0.75' width='6' height='12' rx='3' />
|
||||
<path d='M3.75 9.25V10.25C3.75 13.8399 6.66015 16.75 10.25 16.75C13.8399 16.75 16.75 13.8399 16.75 10.25V9.25' />
|
||||
<path d='M3.75 9.25V10.25C3.75 13.84 6.66 16.75 10.25 16.75C13.84 16.75 16.75 13.84 16.75 10.25V9.25' />
|
||||
<path d='M10.25 16.75V19.25' />
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ export function Upload(props: SVGProps<SVGSVGElement>) {
|
||||
aria-hidden='true'
|
||||
{...props}
|
||||
>
|
||||
<path d='M0.75 12.75V16.75C0.75 17.8546 1.64543 18.75 2.75 18.75H17.75C18.8546 18.75 19.75 17.8546 19.75 16.75V12.75' />
|
||||
<path d='M0.75 12.75V16.75C0.75 17.85 1.65 18.75 2.75 18.75H17.75C18.85 18.75 19.75 17.85 19.75 16.75V12.75' />
|
||||
<path d='M10.25 14.75V1.75' />
|
||||
<path d='M5.25 6.75L10.25 1.75L15.25 6.75' />
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
analyzeIconSource,
|
||||
effectiveFractionDigits,
|
||||
findPrecisionCandidates,
|
||||
} from './check-icon-path-precision'
|
||||
|
||||
const FIXTURE_PATH = '/repo/packages/emcn/src/icons/fixture.tsx'
|
||||
|
||||
describe('icon path precision audit', () => {
|
||||
it('accepts the three-decimal boundary and ignores geometry outside literal paths', () => {
|
||||
const source = `
|
||||
const dynamicPath = 'M0.1234 1'
|
||||
const unrelated = "d='M0.1234 1'"
|
||||
export function SafeIcon() {
|
||||
return (
|
||||
<svg viewBox='0 0 10.1234 10' transform='scale(0.16624)'>
|
||||
<path d='M.5 1.200L1.2e1 2' />
|
||||
<path d={dynamicPath} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
expect(analyzeIconSource(source, FIXTURE_PATH)).toEqual({
|
||||
candidates: [],
|
||||
invalidExceptions: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('finds ordinary decimals and exponents finer than a thousandth', () => {
|
||||
const source = `
|
||||
export function PreciseIcon() {
|
||||
return <svg><path d='M0.1234 1e-4L2.345 5' /></svg>
|
||||
}
|
||||
`
|
||||
|
||||
const candidates = findPrecisionCandidates(source, FIXTURE_PATH)
|
||||
expect(candidates).toHaveLength(1)
|
||||
expect(candidates[0]).toMatchObject({
|
||||
icon: 'PreciseIcon',
|
||||
maxFractionDigits: 4,
|
||||
offendingNumbers: ['0.1234', '1e-4'],
|
||||
})
|
||||
expect(effectiveFractionDigits('2.13949e-05')).toBe(10)
|
||||
})
|
||||
|
||||
it('checks literal JSX expressions and static template literals', () => {
|
||||
const source = `
|
||||
export function ExpressionIcon() {
|
||||
return (
|
||||
<svg>
|
||||
<path d={'M0.1234 1'} />
|
||||
<path d={\`M2.3456 3\`} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
expect(findPrecisionCandidates(source, FIXTURE_PATH)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('accepts a reasoned TSDoc exception on the immediately following path', () => {
|
||||
const source = `
|
||||
export function PreciseBrandIcon() {
|
||||
return (
|
||||
<svg>
|
||||
{/**
|
||||
* svg-path-precision-exception: Rounding visibly distorts the provider-authored mark.
|
||||
*/}
|
||||
<path d='M0.1234 1' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
`
|
||||
|
||||
expect(analyzeIconSource(source, FIXTURE_PATH)).toEqual({
|
||||
candidates: [],
|
||||
invalidExceptions: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects exceptions without a reason and exceptions on already-clean paths', () => {
|
||||
const missingReason = `
|
||||
export function PreciseIcon() {
|
||||
return <svg>{/** svg-path-precision-exception: */}<path d='M0.1234 1' /></svg>
|
||||
}
|
||||
`
|
||||
const unnecessary = `
|
||||
export function CleanIcon() {
|
||||
return <svg>{/** svg-path-precision-exception: Keep detail. */}<path d='M0.12 1' /></svg>
|
||||
}
|
||||
`
|
||||
|
||||
const missingReasonAnalysis = analyzeIconSource(missingReason, FIXTURE_PATH)
|
||||
expect(missingReasonAnalysis.candidates).toHaveLength(1)
|
||||
expect(missingReasonAnalysis.invalidExceptions[0]?.message).toContain('specific reason')
|
||||
|
||||
const unnecessaryAnalysis = analyzeIconSource(unnecessary, FIXTURE_PATH)
|
||||
expect(unnecessaryAnalysis.candidates).toEqual([])
|
||||
expect(unnecessaryAnalysis.invalidExceptions[0]?.message).toContain('unnecessary')
|
||||
})
|
||||
|
||||
it('requires a reasoned exception to remain while its precise path remains', () => {
|
||||
const excepted = `
|
||||
export function PreciseBrandIcon() {
|
||||
return (
|
||||
<svg>
|
||||
{/** svg-path-precision-exception: Rounding visibly distorts the brand mark. */}
|
||||
<path d='M0.1234 1' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
`
|
||||
const exceptionRemoved = excepted.replace(
|
||||
'{/** svg-path-precision-exception: Rounding visibly distorts the brand mark. */}',
|
||||
''
|
||||
)
|
||||
|
||||
expect(findPrecisionCandidates(excepted, FIXTURE_PATH)).toEqual([])
|
||||
expect(findPrecisionCandidates(exceptionRemoved, FIXTURE_PATH)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Prevents overly precise numeric values in literal SVG icon `d` attributes.
|
||||
*
|
||||
* Three decimal places are enough for the reusable icons covered here: additional
|
||||
* digits increase shipped source without a visible benefit. Every path must
|
||||
* satisfy the limit or carry a reasoned local exception.
|
||||
*
|
||||
* Scope is intentionally limited to the shared app/docs icon catalogs and EMCN
|
||||
* icon components. SVG transforms, view boxes, dynamic path expressions, and
|
||||
* page-specific artwork are not inspected because their safe precision depends
|
||||
* on context.
|
||||
*
|
||||
* Run with `bun run check:icon-path-precision`.
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parse } from '@babel/parser'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const EMCN_ICONS_DIRECTORY = path.join(ROOT, 'packages/emcn/src/icons')
|
||||
const STATIC_ICON_FILES = [
|
||||
path.join(ROOT, 'apps/docs/components/icons.tsx'),
|
||||
path.join(ROOT, 'apps/sim/components/icons.tsx'),
|
||||
]
|
||||
const SVG_NUMBER_PATTERN = /[+-]?(?:(?:\d+\.\d*)|(?:\.\d+)|(?:\d+))(?:[eE][+-]?\d+)?/g
|
||||
const PRECISION_EXCEPTION_DIRECTIVE = 'svg-path-precision-exception:'
|
||||
|
||||
export const MAX_ICON_PATH_FRACTION_DIGITS = 3
|
||||
|
||||
interface ParsedPrecisionException {
|
||||
line: number
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
interface LiteralPath {
|
||||
exception: ParsedPrecisionException | null
|
||||
icon: string
|
||||
line: number
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface PrecisionCandidate {
|
||||
file: string
|
||||
icon: string
|
||||
line: number
|
||||
maxFractionDigits: number
|
||||
offendingNumbers: string[]
|
||||
}
|
||||
|
||||
export interface InvalidPrecisionException {
|
||||
file: string
|
||||
line: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface IconPrecisionAnalysis {
|
||||
candidates: PrecisionCandidate[]
|
||||
invalidExceptions: InvalidPrecisionException[]
|
||||
}
|
||||
|
||||
interface ExtractedPaths {
|
||||
paths: LiteralPath[]
|
||||
invalidExceptions: Omit<InvalidPrecisionException, 'file'>[]
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
function jsxStringValue(attribute: Record<string, unknown>): string | null {
|
||||
const value = asRecord(attribute.value)
|
||||
if (!value) return null
|
||||
if (value.type === 'StringLiteral' && typeof value.value === 'string') return value.value
|
||||
if (value.type !== 'JSXExpressionContainer') return null
|
||||
|
||||
const expression = asRecord(value.expression)
|
||||
if (!expression) return null
|
||||
if (expression.type === 'StringLiteral' && typeof expression.value === 'string') {
|
||||
return expression.value
|
||||
}
|
||||
if (expression.type !== 'TemplateLiteral') return null
|
||||
|
||||
const expressions = expression.expressions
|
||||
const quasis = expression.quasis
|
||||
if (!Array.isArray(expressions) || expressions.length > 0 || !Array.isArray(quasis)) return null
|
||||
const quasi = asRecord(quasis[0])
|
||||
const quasiValue = asRecord(quasi?.value)
|
||||
if (!quasiValue) return null
|
||||
if (typeof quasiValue.cooked === 'string') return quasiValue.cooked
|
||||
return typeof quasiValue.raw === 'string' ? quasiValue.raw : null
|
||||
}
|
||||
|
||||
function iconNameAt(source: string, offset: number): string {
|
||||
const before = source.slice(0, offset)
|
||||
const matches = [...before.matchAll(/export (?:function|const) (\w+)\s*[=(]/g)]
|
||||
return matches.length > 0 ? matches[matches.length - 1][1] : '<unknown>'
|
||||
}
|
||||
|
||||
function nodeLine(node: Record<string, unknown>): number {
|
||||
const location = asRecord(node.loc)
|
||||
const start = asRecord(location?.start)
|
||||
return typeof start?.line === 'number' ? start.line : 1
|
||||
}
|
||||
|
||||
function jsxElementName(node: Record<string, unknown>): string | null {
|
||||
const openingElement = asRecord(node.openingElement)
|
||||
const name = asRecord(openingElement?.name)
|
||||
return name?.type === 'JSXIdentifier' && typeof name.name === 'string' ? name.name : null
|
||||
}
|
||||
|
||||
function normalizedComment(comment: Record<string, unknown>): string {
|
||||
if (typeof comment.value !== 'string') return ''
|
||||
return comment.value
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/^\s*\*?\s?/, '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function precisionExceptionFromChild(
|
||||
child: Record<string, unknown>,
|
||||
source: string
|
||||
): ParsedPrecisionException | null {
|
||||
if (child.type !== 'JSXExpressionContainer') return null
|
||||
const expression = asRecord(child.expression)
|
||||
if (expression?.type !== 'JSXEmptyExpression') return null
|
||||
const comments = expression.innerComments
|
||||
if (!Array.isArray(comments)) return null
|
||||
|
||||
for (const value of comments) {
|
||||
const comment = asRecord(value)
|
||||
if (!comment) continue
|
||||
const start = typeof comment.start === 'number' ? comment.start : -1
|
||||
const end = typeof comment.end === 'number' ? comment.end : -1
|
||||
if (start < 0 || end < 0 || !source.slice(start, end).startsWith('/**')) continue
|
||||
const text = normalizedComment(comment)
|
||||
if (!text.startsWith(PRECISION_EXCEPTION_DIRECTIVE)) continue
|
||||
const reason = text.slice(PRECISION_EXCEPTION_DIRECTIVE.length).trim()
|
||||
return { line: nodeLine(comment), reason: reason || null }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractLiteralPaths(source: string, file: string): ExtractedPaths {
|
||||
const syntaxTree = parse(source, {
|
||||
sourceFilename: file,
|
||||
sourceType: 'module',
|
||||
plugins: ['typescript', 'jsx'],
|
||||
})
|
||||
const paths: LiteralPath[] = []
|
||||
const invalidExceptions: Omit<InvalidPrecisionException, 'file'>[] = []
|
||||
|
||||
function invalidate(exception: ParsedPrecisionException, message: string): void {
|
||||
invalidExceptions.push({ line: exception.line, message })
|
||||
}
|
||||
|
||||
function visitChildren(children: unknown): void {
|
||||
if (!Array.isArray(children)) return
|
||||
let pendingException: ParsedPrecisionException | null = null
|
||||
|
||||
for (const value of children) {
|
||||
const child = asRecord(value)
|
||||
if (!child) continue
|
||||
if (child.type === 'JSXText' && typeof child.value === 'string' && !child.value.trim())
|
||||
continue
|
||||
|
||||
const exception = precisionExceptionFromChild(child, source)
|
||||
if (exception) {
|
||||
if (pendingException) {
|
||||
invalidate(pendingException, 'Exception must immediately precede one literal <path>.')
|
||||
}
|
||||
pendingException = exception
|
||||
continue
|
||||
}
|
||||
|
||||
if (pendingException && (child.type !== 'JSXElement' || jsxElementName(child) !== 'path')) {
|
||||
invalidate(pendingException, 'Exception must immediately precede one literal <path>.')
|
||||
pendingException = null
|
||||
}
|
||||
visit(child, pendingException)
|
||||
pendingException = null
|
||||
}
|
||||
|
||||
if (pendingException) {
|
||||
invalidate(pendingException, 'Exception must immediately precede one literal <path>.')
|
||||
}
|
||||
}
|
||||
|
||||
function visit(value: unknown, exception: ParsedPrecisionException | null = null): void {
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) visit(entry)
|
||||
return
|
||||
}
|
||||
const node = asRecord(value)
|
||||
if (!node) return
|
||||
|
||||
if (node.type === 'JSXElement') {
|
||||
const openingElement = asRecord(node.openingElement)
|
||||
if (jsxElementName(node) === 'path' && openingElement) {
|
||||
const attributes = openingElement.attributes
|
||||
const dAttribute = Array.isArray(attributes)
|
||||
? attributes.map(asRecord).find((attribute) => {
|
||||
const name = asRecord(attribute?.name)
|
||||
return name?.type === 'JSXIdentifier' && name.name === 'd'
|
||||
})
|
||||
: null
|
||||
const pathValue = dAttribute ? jsxStringValue(dAttribute) : null
|
||||
if (pathValue !== null) {
|
||||
const start = typeof openingElement.start === 'number' ? openingElement.start : 0
|
||||
paths.push({
|
||||
exception,
|
||||
icon: iconNameAt(source, start),
|
||||
line: nodeLine(dAttribute ?? openingElement),
|
||||
value: pathValue,
|
||||
})
|
||||
} else if (exception) {
|
||||
invalidate(exception, 'Exception applies only to a literal <path d> value.')
|
||||
}
|
||||
} else if (exception) {
|
||||
invalidate(exception, 'Exception must immediately precede one literal <path>.')
|
||||
}
|
||||
visitChildren(node.children)
|
||||
return
|
||||
}
|
||||
|
||||
if (node.type === 'JSXFragment') {
|
||||
if (exception) {
|
||||
invalidate(exception, 'Exception must immediately precede one literal <path>.')
|
||||
}
|
||||
visitChildren(node.children)
|
||||
return
|
||||
}
|
||||
|
||||
for (const [key, child] of Object.entries(node)) {
|
||||
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra') continue
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
|
||||
visit(syntaxTree)
|
||||
return { paths, invalidExceptions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts both digits written after the decimal point and precision introduced
|
||||
* by a negative exponent. This catches values such as `1.234` and `1e-3`.
|
||||
*/
|
||||
export function effectiveFractionDigits(numberLiteral: string): number {
|
||||
const [mantissa, exponentText] = numberLiteral.toLowerCase().split('e')
|
||||
const decimalIndex = mantissa.indexOf('.')
|
||||
const writtenFractionDigits = decimalIndex < 0 ? 0 : mantissa.length - decimalIndex - 1
|
||||
const exponent = exponentText === undefined ? 0 : Number.parseInt(exponentText, 10)
|
||||
const exponentFractionDigits = Math.max(0, writtenFractionDigits - exponent)
|
||||
return Math.max(writtenFractionDigits, exponentFractionDigits)
|
||||
}
|
||||
|
||||
function normalizedRelativePath(file: string): string {
|
||||
return path.relative(ROOT, file).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
export function analyzeIconSource(source: string, file: string): IconPrecisionAnalysis {
|
||||
const extracted = extractLiteralPaths(source, file)
|
||||
const candidates: PrecisionCandidate[] = []
|
||||
const normalizedFile = normalizedRelativePath(file)
|
||||
const invalidExceptions = extracted.invalidExceptions.map((exception) => ({
|
||||
...exception,
|
||||
file: normalizedFile,
|
||||
}))
|
||||
|
||||
for (const literalPath of extracted.paths) {
|
||||
const preciseNumbers = [...literalPath.value.matchAll(SVG_NUMBER_PATTERN)]
|
||||
.map((match) => match[0])
|
||||
.filter(
|
||||
(numberLiteral) => effectiveFractionDigits(numberLiteral) > MAX_ICON_PATH_FRACTION_DIGITS
|
||||
)
|
||||
|
||||
if (literalPath.exception) {
|
||||
if (!literalPath.exception.reason) {
|
||||
invalidExceptions.push({
|
||||
file: normalizedFile,
|
||||
line: literalPath.exception.line,
|
||||
message: 'Exception must include a specific reason after the colon.',
|
||||
})
|
||||
} else if (preciseNumbers.length === 0) {
|
||||
invalidExceptions.push({
|
||||
file: normalizedFile,
|
||||
line: literalPath.exception.line,
|
||||
message: 'Exception is unnecessary because this path uses at most three decimal places.',
|
||||
})
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (preciseNumbers.length === 0) continue
|
||||
candidates.push({
|
||||
file: normalizedFile,
|
||||
icon: literalPath.icon,
|
||||
line: literalPath.line,
|
||||
maxFractionDigits: Math.max(...preciseNumbers.map(effectiveFractionDigits)),
|
||||
offendingNumbers: [...new Set(preciseNumbers)].slice(0, 4),
|
||||
})
|
||||
}
|
||||
|
||||
return { candidates, invalidExceptions }
|
||||
}
|
||||
|
||||
export function findPrecisionCandidates(source: string, file: string): PrecisionCandidate[] {
|
||||
return analyzeIconSource(source, file).candidates
|
||||
}
|
||||
|
||||
async function currentIconFiles(): Promise<string[]> {
|
||||
const emcnIcons = (await readdir(EMCN_ICONS_DIRECTORY))
|
||||
.filter((file) => file.endsWith('.tsx'))
|
||||
.sort()
|
||||
.map((file) => path.join(EMCN_ICONS_DIRECTORY, file))
|
||||
return [...STATIC_ICON_FILES, ...emcnIcons]
|
||||
}
|
||||
|
||||
async function scanCurrentFiles(files: string[]): Promise<IconPrecisionAnalysis> {
|
||||
const candidates: PrecisionCandidate[] = []
|
||||
const invalidExceptions: InvalidPrecisionException[] = []
|
||||
for (const file of files) {
|
||||
const analysis = analyzeIconSource(await readFile(file, 'utf8'), file)
|
||||
candidates.push(...analysis.candidates)
|
||||
invalidExceptions.push(...analysis.invalidExceptions)
|
||||
}
|
||||
return { candidates, invalidExceptions }
|
||||
}
|
||||
|
||||
function printCandidate(candidate: PrecisionCandidate): void {
|
||||
console.error(
|
||||
` ${candidate.file}:${candidate.line} (${candidate.icon}) — ${candidate.maxFractionDigits} fractional digits`
|
||||
)
|
||||
console.error(` values: ${candidate.offendingNumbers.join(', ')}`)
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.argv.length > 2) {
|
||||
console.error('Usage: bun run check:icon-path-precision')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const files = await currentIconFiles()
|
||||
const current = await scanCurrentFiles(files)
|
||||
|
||||
if (current.invalidExceptions.length > 0) {
|
||||
console.error(
|
||||
`\nFound ${current.invalidExceptions.length} invalid SVG precision exception(s):\n`
|
||||
)
|
||||
for (const exception of current.invalidExceptions) {
|
||||
console.error(` ${exception.file}:${exception.line} — ${exception.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (current.candidates.length > 0) {
|
||||
console.error(
|
||||
`\nFound ${current.candidates.length} icon path(s) with more than ${MAX_ICON_PATH_FRACTION_DIGITS} fractional digits:\n`
|
||||
)
|
||||
for (const candidate of current.candidates) printCandidate(candidate)
|
||||
console.error(
|
||||
'\nRound only numeric values inside the literal d attribute to at most three decimal places.'
|
||||
)
|
||||
console.error(
|
||||
'If extra precision is visibly necessary, place this reasoned exception immediately before that path:'
|
||||
)
|
||||
console.error(`{/**
|
||||
* ${PRECISION_EXCEPTION_DIRECTIVE} Explain why rounding changes this geometry.
|
||||
*/}`)
|
||||
console.error(
|
||||
'Do not round transform or viewBox values automatically; verify those geometry changes separately.'
|
||||
)
|
||||
}
|
||||
|
||||
if (current.invalidExceptions.length > 0 || current.candidates.length > 0) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✓ All literal icon paths use at most three decimal places (${files.length} files checked).`
|
||||
)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
main().catch((error: unknown) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user