d25d482dc2
CI / Migrate DB (push) Blocked by required conditions
CI / Migrate Dev DB (push) Waiting to run
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Blocked by required conditions
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Blocked by required conditions
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Blocked by required conditions
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Blocked by required conditions
CI / Deploy Trigger.dev (Dev) (push) Blocked by required conditions
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Blocked by required conditions
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Blocked by required conditions
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Blocked by required conditions
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Blocked by required conditions
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Blocked by required conditions
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Blocked by required conditions
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Blocked by required conditions
CI / Check Docs Changes (push) Waiting to run
CI / Process Docs (push) Blocked by required conditions
CI / Create GitHub Release (push) Blocked by required conditions
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Test and Build (push) Waiting to run
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Blocked by required conditions
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Blocked by required conditions
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Blocked by required conditions
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Blocked by required conditions
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Blocked by required conditions
CI / Detect Version (push) Waiting to run
230 lines
7.0 KiB
TypeScript
230 lines
7.0 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Validates that every `<path d='…'>` in `apps/sim/components/icons.tsx` is
|
|
* syntactically well-formed SVG path data.
|
|
*
|
|
* Malformed path data does not crash the build or fail TypeScript — the browser
|
|
* silently drops the bad segment and logs `<path> attribute d: Expected number`
|
|
* / `Expected arc flag` to the console. A bulk icon reformat once corrupted
|
|
* several brand icons this way (dropped arc-flag digits, mangled cubic operands
|
|
* like `c00,00,00`), flooding the integrations page with dozens of console
|
|
* errors that no existing check caught. This script is that missing gate.
|
|
*
|
|
* It walks each `d` string against the SVG path grammar and fails if any
|
|
* command has the wrong operand count or an arc flag that is not `0`/`1`.
|
|
* Number scanning handles the compact forms the spec allows (packed decimals
|
|
* `.5.5`, sign-delimited `1-2`, exponents, and arc flags packed against
|
|
* neighbours like `001.39`), so valid minified paths pass unflagged.
|
|
*
|
|
* Run: `bun run scripts/check-icon-paths.ts`
|
|
*/
|
|
import { readFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..')
|
|
/** Defaults to the shared icon module; overridable via argv for testing. */
|
|
const ICONS_FILE = process.argv[2]
|
|
? path.resolve(process.argv[2])
|
|
: path.join(ROOT, 'apps/sim/components/icons.tsx')
|
|
|
|
/** Operand count per path command; arc (`a`) is handled specially for flags. */
|
|
const OPERANDS: Record<string, number> = {
|
|
m: 2,
|
|
l: 2,
|
|
h: 1,
|
|
v: 1,
|
|
c: 6,
|
|
s: 4,
|
|
q: 4,
|
|
t: 2,
|
|
a: 7,
|
|
z: 0,
|
|
}
|
|
|
|
const isWsp = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\f'
|
|
|
|
/** Skip whitespace and comma separators; returns the next significant index. */
|
|
function skipSep(d: string, i: number): number {
|
|
while (i < d.length && (isWsp(d[i]) || d[i] === ',')) i++
|
|
return i
|
|
}
|
|
|
|
/**
|
|
* Scan one number starting at `i` (after separators are skipped). Returns the
|
|
* index past the number, or `-1` if no valid number begins here. Mirrors the
|
|
* SVG number grammar: optional sign, integer/fraction, optional exponent.
|
|
*/
|
|
function scanNumber(d: string, i: number): number {
|
|
const start = i
|
|
if (d[i] === '+' || d[i] === '-') i++
|
|
let digits = 0
|
|
while (i < d.length && d[i] >= '0' && d[i] <= '9') {
|
|
i++
|
|
digits++
|
|
}
|
|
if (d[i] === '.') {
|
|
i++
|
|
while (i < d.length && d[i] >= '0' && d[i] <= '9') {
|
|
i++
|
|
digits++
|
|
}
|
|
}
|
|
if (digits === 0) return -1
|
|
if (d[i] === 'e' || d[i] === 'E') {
|
|
let j = i + 1
|
|
if (d[j] === '+' || d[j] === '-') j++
|
|
let expDigits = 0
|
|
while (j < d.length && d[j] >= '0' && d[j] <= '9') {
|
|
j++
|
|
expDigits++
|
|
}
|
|
if (expDigits > 0) i = j
|
|
}
|
|
return i > start ? i : -1
|
|
}
|
|
|
|
interface PathError {
|
|
reason: string
|
|
/** 0-based offset into the `d` string where parsing failed. */
|
|
offset: number
|
|
}
|
|
|
|
/**
|
|
* Validate a single `d` string. Returns the first structural error, or `null`
|
|
* when the path is well-formed.
|
|
*/
|
|
function validatePath(d: string): PathError | null {
|
|
let i = skipSep(d, 0)
|
|
if (i >= d.length) return { reason: 'empty path data', offset: 0 }
|
|
|
|
let cmd = ''
|
|
// The first command must be a moveto.
|
|
if (d[i] !== 'M' && d[i] !== 'm') return { reason: 'path must start with M/m', offset: i }
|
|
|
|
while (i < d.length) {
|
|
i = skipSep(d, i)
|
|
if (i >= d.length) break
|
|
|
|
const ch = d[i]
|
|
if (/[a-zA-Z]/.test(ch)) {
|
|
if (!(ch.toLowerCase() in OPERANDS)) {
|
|
return { reason: `unknown command '${ch}'`, offset: i }
|
|
}
|
|
cmd = ch
|
|
i++
|
|
if (cmd === 'z' || cmd === 'Z') continue
|
|
} else if (cmd === '' || cmd === 'z' || cmd === 'Z') {
|
|
return { reason: `expected a command, found '${ch}'`, offset: i }
|
|
}
|
|
|
|
// After an explicit M/m, repeated operand groups are implicit L/l.
|
|
const effective = cmd === 'M' ? 'L' : cmd === 'm' ? 'l' : cmd
|
|
const key = effective.toLowerCase()
|
|
|
|
if (key === 'a') {
|
|
const err = scanArcGroup(d, i)
|
|
if (typeof err === 'string') return { reason: err, offset: i }
|
|
i = err
|
|
} else {
|
|
const count = OPERANDS[key]
|
|
for (let n = 0; n < count; n++) {
|
|
const before = skipSep(d, i)
|
|
const next = scanNumber(d, before)
|
|
if (next < 0) {
|
|
return { reason: `expected number for '${cmd}' command`, offset: before }
|
|
}
|
|
i = next
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Scan one 7-operand arc group: rx ry x-axis-rotation large-arc-flag
|
|
* sweep-flag x y. Flags are a single `0`/`1` that may be packed against the
|
|
* next token (e.g. `001.39`). Returns the next index, or an error string.
|
|
*/
|
|
function scanArcGroup(d: string, i: number): number | string {
|
|
for (let n = 0; n < 3; n++) {
|
|
const before = skipSep(d, i)
|
|
const next = scanNumber(d, before)
|
|
if (next < 0) return `expected number in arc command`
|
|
i = next
|
|
}
|
|
for (let n = 0; n < 2; n++) {
|
|
const before = skipSep(d, i)
|
|
if (d[before] !== '0' && d[before] !== '1') {
|
|
return `expected arc flag ('0' or '1')`
|
|
}
|
|
i = before + 1
|
|
}
|
|
for (let n = 0; n < 2; n++) {
|
|
const before = skipSep(d, i)
|
|
const next = scanNumber(d, before)
|
|
if (next < 0) return `expected number in arc command`
|
|
i = next
|
|
}
|
|
return i
|
|
}
|
|
|
|
interface Finding {
|
|
icon: string
|
|
line: number
|
|
reason: string
|
|
snippet: string
|
|
}
|
|
|
|
/**
|
|
* Find the nearest preceding icon export for a source offset. Matches both
|
|
* `export function XxxIcon` and `export const XxxIcon =` forms so arrow-function
|
|
* icons are attributed correctly.
|
|
*/
|
|
function iconNameAt(src: string, offset: number): string {
|
|
const before = src.slice(0, offset)
|
|
const matches = [...before.matchAll(/export (?:function|const) (\w+)\s*[=(]/g)]
|
|
return matches.length ? matches[matches.length - 1][1] : '<unknown>'
|
|
}
|
|
|
|
async function main() {
|
|
const src = await readFile(ICONS_FILE, 'utf8')
|
|
const findings: Finding[] = []
|
|
|
|
for (const m of src.matchAll(/\bd=(?:'([^']*)'|"([^"]*)")/g)) {
|
|
const d = m[1] ?? m[2] ?? ''
|
|
if (!d.trim()) continue
|
|
const err = validatePath(d)
|
|
if (!err) continue
|
|
|
|
// Offset of the `d` value within the file → the corrupt char.
|
|
const valueStart = m.index + m[0].indexOf(d)
|
|
const fileOffset = valueStart + err.offset
|
|
const line = src.slice(0, fileOffset).split('\n').length
|
|
const around = d.slice(Math.max(0, err.offset - 20), err.offset + 20)
|
|
findings.push({
|
|
icon: iconNameAt(src, m.index),
|
|
line,
|
|
reason: err.reason,
|
|
snippet: `…${around}…`,
|
|
})
|
|
}
|
|
|
|
if (findings.length === 0) {
|
|
console.log('✓ All icon <path> data in components/icons.tsx is valid SVG.')
|
|
process.exit(0)
|
|
}
|
|
|
|
console.error(`\nFound ${findings.length} malformed icon path(s) in components/icons.tsx:\n`)
|
|
for (const f of findings) {
|
|
console.error(` ${f.icon} (icons.tsx:${f.line}) — ${f.reason}`)
|
|
console.error(` near: ${f.snippet}`)
|
|
console.error(
|
|
' Malformed path data renders nothing and floods the console with SVG parse errors.'
|
|
)
|
|
console.error(' Fix the d attribute (correct operand counts; arc flags must be 0 or 1).\n')
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
main()
|