diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 8580bb9ea85..4628c9425c7 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -50,39 +50,39 @@ export function AgentPhoneIcon(props: SVGProps) { @@ -675,11 +675,11 @@ export function SlackIcon(props: SVGProps) { fill='#E01E5A' /> ) { - - + + @@ -915,7 +915,7 @@ export function ConnectIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -969,7 +969,7 @@ export function PersonaIcon(props: SVGProps) { {...props} > @@ -1513,7 +1513,7 @@ export function InputIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -1562,23 +1562,23 @@ export function ProspeoIcon(props: SVGProps) { @@ -1721,7 +1721,7 @@ export function OpenAIIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -1753,32 +1753,29 @@ export function RB2BIcon(props: SVGProps) { return ( - - - - + + + + + + - + + + + - - - - - - + @@ -1896,11 +1893,11 @@ export function GoogleAppsheetIcon(props: SVGProps) { - - + + ) @@ -2267,11 +2264,11 @@ export function AtlassianIcon(props: SVGProps) { ) @@ -2355,15 +2352,15 @@ export function ConvexIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -2386,11 +2383,11 @@ export function SendblueIcon(props: SVGProps) { ) @@ -2529,15 +2526,15 @@ export function MintlifyIcon(props: SVGProps) { return ( @@ -2757,7 +2754,7 @@ export function ExtendIcon(props: SVGProps) { return ( ) { /> @@ -2799,13 +2796,13 @@ export function FindymailIcon(props: SVGProps) { @@ -2842,19 +2839,19 @@ export function ZeroBounceIcon(props: SVGProps) { @@ -3057,7 +3054,7 @@ export function LinearIcon(props: React.SVGProps) { > ) @@ -3183,7 +3180,7 @@ export function ThriveIcon(props: SVGProps) { return ( @@ -4515,18 +4512,15 @@ export function QuartrIcon(props: SVGProps) { - + - + ) @@ -4793,47 +4787,47 @@ export function MicrosoftOneDriveIcon(props: SVGProps) { ) @@ -5859,23 +5853,23 @@ export function PosthogIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -6035,7 +6029,7 @@ export function ZendeskIcon(props: SVGProps) { > @@ -6065,17 +6059,17 @@ export function ZoomInfoIcon(props: SVGProps) { > - + @@ -6290,27 +6284,27 @@ export function DynatraceIcon(props: SVGProps) { ) @@ -6565,7 +6559,7 @@ export function CodePipelineIcon(props: SVGProps) { transform='translate(40, 40) scale(1.25) translate(-40, -40)' > @@ -6757,11 +6751,11 @@ export function GitlabIcon(props: SVGProps) { ) { /> ) @@ -6885,25 +6879,25 @@ export function DaytonaIcon(props: SVGProps) { ) { y='12.9094' width='20.6556' height='8.54718' - transform='rotate(90 22.1582 12.9094)' + transform='rotate(90 22.16 12.91)' fill='currentColor' /> ) { y='42.825' width='25.6415' height='8.54718' - transform='rotate(90 52.0732 42.825)' + transform='rotate(90 52.07 42.83)' fill='currentColor' /> @@ -7196,15 +7190,15 @@ export function DowndetectorIcon(props: SVGProps) { @@ -7302,7 +7296,7 @@ export function GranolaIcon(props: SVGProps) { > ) @@ -7355,19 +7349,19 @@ export function GreptileIcon(props: SVGProps) { return ( @@ -8550,7 +8544,7 @@ export function VantaIcon(props: SVGProps) { ) { @@ -8921,31 +8915,31 @@ export function LeadMagicIcon(props: SVGProps) { @@ -8975,11 +8969,11 @@ export function IcypeasIcon(props: SVGProps) { @@ -9123,7 +9117,7 @@ export function RetoolIcon(props: SVGProps) { ) @@ -9188,51 +9182,51 @@ export function JupyterIcon(props: SVGProps) { @@ -9258,13 +9252,13 @@ export function RocketlaneIcon(props: SVGProps) { @@ -9285,7 +9279,7 @@ export function LogfireIcon(props: SVGProps) { role='img' xmlns='http://www.w3.org/2000/svg' > - + ) } @@ -9318,21 +9312,21 @@ export function SmartleadIcon(props: SVGProps) { > diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 8580bb9ea85..4628c9425c7 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -50,39 +50,39 @@ export function AgentPhoneIcon(props: SVGProps) { @@ -675,11 +675,11 @@ export function SlackIcon(props: SVGProps) { fill='#E01E5A' /> ) { - - + + @@ -915,7 +915,7 @@ export function ConnectIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -969,7 +969,7 @@ export function PersonaIcon(props: SVGProps) { {...props} > @@ -1513,7 +1513,7 @@ export function InputIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -1562,23 +1562,23 @@ export function ProspeoIcon(props: SVGProps) { @@ -1721,7 +1721,7 @@ export function OpenAIIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -1753,32 +1753,29 @@ export function RB2BIcon(props: SVGProps) { return ( - - - - + + + + + + - + + + + - - - - - - + @@ -1896,11 +1893,11 @@ export function GoogleAppsheetIcon(props: SVGProps) { - - + + ) @@ -2267,11 +2264,11 @@ export function AtlassianIcon(props: SVGProps) { ) @@ -2355,15 +2352,15 @@ export function ConvexIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -2386,11 +2383,11 @@ export function SendblueIcon(props: SVGProps) { ) @@ -2529,15 +2526,15 @@ export function MintlifyIcon(props: SVGProps) { return ( @@ -2757,7 +2754,7 @@ export function ExtendIcon(props: SVGProps) { return ( ) { /> @@ -2799,13 +2796,13 @@ export function FindymailIcon(props: SVGProps) { @@ -2842,19 +2839,19 @@ export function ZeroBounceIcon(props: SVGProps) { @@ -3057,7 +3054,7 @@ export function LinearIcon(props: React.SVGProps) { > ) @@ -3183,7 +3180,7 @@ export function ThriveIcon(props: SVGProps) { return ( @@ -4515,18 +4512,15 @@ export function QuartrIcon(props: SVGProps) { - + - + ) @@ -4793,47 +4787,47 @@ export function MicrosoftOneDriveIcon(props: SVGProps) { ) @@ -5859,23 +5853,23 @@ export function PosthogIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' > @@ -6035,7 +6029,7 @@ export function ZendeskIcon(props: SVGProps) { > @@ -6065,17 +6059,17 @@ export function ZoomInfoIcon(props: SVGProps) { > - + @@ -6290,27 +6284,27 @@ export function DynatraceIcon(props: SVGProps) { ) @@ -6565,7 +6559,7 @@ export function CodePipelineIcon(props: SVGProps) { transform='translate(40, 40) scale(1.25) translate(-40, -40)' > @@ -6757,11 +6751,11 @@ export function GitlabIcon(props: SVGProps) { ) { /> ) @@ -6885,25 +6879,25 @@ export function DaytonaIcon(props: SVGProps) { ) { y='12.9094' width='20.6556' height='8.54718' - transform='rotate(90 22.1582 12.9094)' + transform='rotate(90 22.16 12.91)' fill='currentColor' /> ) { y='42.825' width='25.6415' height='8.54718' - transform='rotate(90 52.0732 42.825)' + transform='rotate(90 52.07 42.83)' fill='currentColor' /> @@ -7196,15 +7190,15 @@ export function DowndetectorIcon(props: SVGProps) { @@ -7302,7 +7296,7 @@ export function GranolaIcon(props: SVGProps) { > ) @@ -7355,19 +7349,19 @@ export function GreptileIcon(props: SVGProps) { return ( @@ -8550,7 +8544,7 @@ export function VantaIcon(props: SVGProps) { ) { @@ -8921,31 +8915,31 @@ export function LeadMagicIcon(props: SVGProps) { @@ -8975,11 +8969,11 @@ export function IcypeasIcon(props: SVGProps) { @@ -9123,7 +9117,7 @@ export function RetoolIcon(props: SVGProps) { ) @@ -9188,51 +9182,51 @@ export function JupyterIcon(props: SVGProps) { @@ -9258,13 +9252,13 @@ export function RocketlaneIcon(props: SVGProps) { @@ -9285,7 +9279,7 @@ export function LogfireIcon(props: SVGProps) { role='img' xmlns='http://www.w3.org/2000/svg' > - + ) } @@ -9318,21 +9312,21 @@ export function SmartleadIcon(props: SVGProps) { > diff --git a/package.json b/package.json index 587ef6854bc..217404b4cee 100644 --- a/package.json +++ b/package.json @@ -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", @@ -46,6 +47,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", diff --git a/packages/emcn/src/icons/bubble-chat-delay.tsx b/packages/emcn/src/icons/bubble-chat-delay.tsx index 4ac022f6be7..5b8c96f31bf 100644 --- a/packages/emcn/src/icons/bubble-chat-delay.tsx +++ b/packages/emcn/src/icons/bubble-chat-delay.tsx @@ -21,7 +21,7 @@ export function BubbleChatDelay({ size = 24, width, height, ...props }: IconProp {...props} > ) { {...props} > {/* tray — same as Upload */} - + {/* stem — top to tray */} {/* arrowhead pointing down */} diff --git a/packages/emcn/src/icons/duplicate.tsx b/packages/emcn/src/icons/duplicate.tsx index 36e235f3fde..5a756a3bdfe 100644 --- a/packages/emcn/src/icons/duplicate.tsx +++ b/packages/emcn/src/icons/duplicate.tsx @@ -19,7 +19,7 @@ export function Duplicate(props: SVGProps) { aria-hidden='true' {...props} > - + ) diff --git a/packages/emcn/src/icons/mic.tsx b/packages/emcn/src/icons/mic.tsx index 4a59eabd042..735f069566e 100644 --- a/packages/emcn/src/icons/mic.tsx +++ b/packages/emcn/src/icons/mic.tsx @@ -20,7 +20,7 @@ export function Mic(props: SVGProps) { {...props} > - + ) diff --git a/packages/emcn/src/icons/upload.tsx b/packages/emcn/src/icons/upload.tsx index d1ac11b4a05..157b25128d8 100644 --- a/packages/emcn/src/icons/upload.tsx +++ b/packages/emcn/src/icons/upload.tsx @@ -19,7 +19,7 @@ export function Upload(props: SVGProps) { aria-hidden='true' {...props} > - + diff --git a/scripts/check-icon-path-precision.test.ts b/scripts/check-icon-path-precision.test.ts new file mode 100644 index 00000000000..460da11fd19 --- /dev/null +++ b/scripts/check-icon-path-precision.test.ts @@ -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 ( + + + + + ) + } + ` + + expect(analyzeIconSource(source, FIXTURE_PATH)).toEqual({ + candidates: [], + invalidExceptions: [], + }) + }) + + it('finds ordinary decimals and exponents finer than a thousandth', () => { + const source = ` + export function PreciseIcon() { + return + } + ` + + 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 ( + + + + + ) + } + ` + + 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-path-precision-exception: Rounding visibly distorts the provider-authored mark. + */} + + + ) + } + ` + + 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-path-precision-exception: */} + } + ` + const unnecessary = ` + export function CleanIcon() { + return {/** svg-path-precision-exception: Keep detail. */} + } + ` + + 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-path-precision-exception: Rounding visibly distorts the brand mark. */} + + + ) + } + ` + 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) + }) +}) diff --git a/scripts/check-icon-path-precision.ts b/scripts/check-icon-path-precision.ts new file mode 100644 index 00000000000..3df32cb4fd9 --- /dev/null +++ b/scripts/check-icon-path-precision.ts @@ -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[] +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' ? (value as Record) : null +} + +function jsxStringValue(attribute: Record): 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] : '' +} + +function nodeLine(node: Record): number { + const location = asRecord(node.loc) + const start = asRecord(location?.start) + return typeof start?.line === 'number' ? start.line : 1 +} + +function jsxElementName(node: Record): 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 { + 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, + 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[] = [] + + 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 .') + } + pendingException = exception + continue + } + + if (pendingException && (child.type !== 'JSXElement' || jsxElementName(child) !== 'path')) { + invalidate(pendingException, 'Exception must immediately precede one literal .') + pendingException = null + } + visit(child, pendingException) + pendingException = null + } + + if (pendingException) { + invalidate(pendingException, 'Exception must immediately precede one literal .') + } + } + + 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 value.') + } + } else if (exception) { + invalidate(exception, 'Exception must immediately precede one literal .') + } + visitChildren(node.children) + return + } + + if (node.type === 'JSXFragment') { + if (exception) { + invalidate(exception, 'Exception must immediately precede one literal .') + } + 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 { + 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 { + 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 { + 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) + }) +}