Add custom JS lint rules, replace inline types with named imports

Three oxlint JS plugin rules in lint/plugin.js:

- no-inline-named-types: configurable shape→name map catches inline
  { x: number; y: number } etc. and suggests Vector, Color, GUID,
  Rect, Matrix from @open-pencil/core
- no-structuredclone-scene-arrays: flags structuredClone on
  fills/strokes/effects — use typed copy helpers instead
- no-math-random: bans Math.random() in favor of crypto

Fixed 40 violations across 13 files: replaced inline type literals
with named imports, switched figma-api.ts structuredClone calls to
copyFills/copyStrokes/copyEffects.
This commit is contained in:
Danila Poyarkov 2026-03-09 09:41:28 +03:00
parent 4969447669
commit cd614ef8f7
14 changed files with 198 additions and 63 deletions

132
lint/plugin.js Normal file
View file

@ -0,0 +1,132 @@
const noInlineNamedTypes = {
meta: {
docs: {
description: 'Disallow inline type literals that duplicate a named type',
},
schema: [
{
type: 'object',
additionalProperties: {
type: 'string',
},
},
],
},
create(context) {
const typesOption = context.options[0]
if (!typesOption || typeof typesOption !== 'object') return {}
const shapeToName = new Map()
for (const [name, shape] of Object.entries(typesOption)) {
shapeToName.set(shape, name)
}
return {
TSTypeLiteral(node) {
const props = node.members?.filter(
(m) => m.type === 'TSPropertySignature' && m.key?.type === 'Identifier',
)
if (!props || props.length < 2) return
if (props.some((m) => m.optional)) return
const shape = props
.map((m) => {
const typeNode = m.typeAnnotation?.typeAnnotation
let typeName = 'unknown'
if (typeNode) {
switch (typeNode.type) {
case 'TSNumberKeyword': typeName = 'number'; break
case 'TSStringKeyword': typeName = 'string'; break
case 'TSBooleanKeyword': typeName = 'boolean'; break
}
}
return `${m.key.name}:${typeName}`
})
.sort()
.join(',')
const namedType = shapeToName.get(shape)
if (namedType) {
context.report({
node,
message: `Use '${namedType}' instead of inline type literal. Import from '@open-pencil/core'.`,
})
}
},
}
},
}
const noStructuredCloneSceneArrays = {
meta: {
docs: {
description:
'Disallow structuredClone on fills/strokes/effects — use typed copy helpers from copy.ts',
},
schema: [
{
type: 'array',
items: { type: 'string' },
description: 'Property names that should use typed copy helpers',
},
],
},
create(context) {
const props = new Set(context.options[0] ?? [
'fills', 'strokes', 'effects', 'styleRuns', 'fillGeometry', 'strokeGeometry',
])
return {
CallExpression(node) {
if (node.callee?.type !== 'Identifier' || node.callee.name !== 'structuredClone') return
if (node.arguments?.length !== 1) return
const arg = node.arguments[0]
if (arg.type === 'MemberExpression' && arg.property?.type === 'Identifier') {
if (props.has(arg.property.name)) {
context.report({
node,
message: `Use the typed copy helper instead of structuredClone for '${arg.property.name}'. Import from '@open-pencil/core'.`,
})
}
}
},
}
},
}
const noMathRandom = {
meta: {
docs: {
description: 'Disallow Math.random() — use crypto.getRandomValues() instead',
},
},
create(context) {
return {
CallExpression(node) {
if (
node.callee?.type === 'MemberExpression' &&
node.callee.object?.type === 'Identifier' &&
node.callee.object.name === 'Math' &&
node.callee.property?.type === 'Identifier' &&
node.callee.property.name === 'random'
) {
context.report({
node,
message: 'Use crypto.getRandomValues() instead of Math.random().',
})
}
},
}
},
}
const plugin = {
meta: { name: 'open-pencil' },
rules: {
'no-inline-named-types': noInlineNamedTypes,
'no-structuredclone-scene-arrays': noStructuredCloneSceneArrays,
'no-math-random': noMathRandom,
},
}
export default plugin

View file

@ -1,6 +1,7 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn", "vue"],
"jsPlugins": ["./lint/plugin.js"],
"env": {
"browser": true,
"es2024": true
@ -55,7 +56,17 @@
"vue/no-multiple-slot-args": "error",
"vue/define-emits-declaration": "error",
"vue/define-props-destructuring": "error",
"vue/require-typed-ref": "error"
"vue/require-typed-ref": "error",
"open-pencil/no-inline-named-types": ["error", {
"Vector": "x:number,y:number",
"Color": "a:number,b:number,g:number,r:number",
"GUID": "localID:number,sessionID:number",
"Rect": "height:number,width:number,x:number,y:number",
"Matrix": "m00:number,m01:number,m02:number,m10:number,m11:number,m12:number"
}],
"open-pencil/no-structuredclone-scene-arrays": "error",
"open-pencil/no-math-random": "error"
},
"overrides": [
{

View file

@ -10,6 +10,7 @@ import type { NodeChange } from './kiwi/codec'
import type { SkiaRenderer } from './renderer'
import type { SceneGraph, VariableValue } from './scene-graph'
import type { CanvasKit } from 'canvaskit-wasm'
import type { GUID } from './types'
const THUMBNAIL_1X1 = Uint8Array.from(
atob(
@ -81,9 +82,9 @@ export async function exportFigFile(
const blobs: Uint8Array[] = []
const pages = graph.getPages(true)
const nodeIdToGuid = new Map<string, { sessionID: number; localID: number }>()
const nodeIdToGuid = new Map<string, GUID>()
const fontDigestMap = await buildFontDigestMap(graph)
let internalCanvasGuid: { sessionID: number; localID: number } | null = null
let internalCanvasGuid: GUID | null = null
for (let p = 0; p < pages.length; p++) {
const page = pages[p]

View file

@ -13,6 +13,7 @@ import type {
VariableValue
} from './scene-graph'
import type { Rect, Vector } from './types'
import { copyFills, copyStrokes, copyEffects } from './copy'
const MIXED = Symbol('mixed')
@ -159,7 +160,7 @@ class FigmaNodeProxy {
// --- Visual ---
get fills(): readonly Fill[] {
return Object.freeze(structuredClone(this._raw().fills))
return Object.freeze(copyFills(this._raw().fills))
}
set fills(v: readonly Fill[]) {
@ -167,7 +168,7 @@ class FigmaNodeProxy {
}
get strokes(): readonly Stroke[] {
return Object.freeze(structuredClone(this._raw().strokes))
return Object.freeze(copyStrokes(this._raw().strokes))
}
set strokes(v: readonly Stroke[]) {
@ -175,7 +176,7 @@ class FigmaNodeProxy {
}
get effects(): readonly Effect[] {
return Object.freeze(structuredClone(this._raw().effects))
return Object.freeze(copyEffects(this._raw().effects))
}
set effects(v: readonly Effect[]) {
@ -304,7 +305,7 @@ class FigmaNodeProxy {
set strokeWeight(v: number) {
const n = this._raw()
if (n.strokes.length > 0) {
const strokes = structuredClone(n.strokes)
const strokes = copyStrokes(n.strokes)
strokes[0].weight = v
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes })
}
@ -318,7 +319,7 @@ class FigmaNodeProxy {
set strokeAlign(v: string) {
const n = this._raw()
if (n.strokes.length > 0) {
const strokes = structuredClone(n.strokes)
const strokes = copyStrokes(n.strokes)
strokes[0].align = v as Stroke['align']
this[INTERNAL_GRAPH].updateNode(this[INTERNAL_ID], { strokes })
}
@ -1119,9 +1120,9 @@ export class FigmaAPI {
height: raw.height,
x: raw.x,
y: raw.y,
fills: structuredClone(raw.fills),
strokes: structuredClone(raw.strokes),
effects: structuredClone(raw.effects),
fills: copyFills(raw.fills),
strokes: copyStrokes(raw.strokes),
effects: copyEffects(raw.effects),
cornerRadius: raw.cornerRadius,
topLeftRadius: raw.topLeftRadius,
topRightRadius: raw.topRightRadius,
@ -1239,7 +1240,7 @@ export class FigmaAPI {
y: first.y,
width: first.width,
height: first.height,
fills: structuredClone(first.fills)
fills: copyFills(first.fills)
})
for (const id of nodeIds) {
this.graph.deleteNode(id)

View file

@ -8,6 +8,7 @@ import { stringToGuid, VARIABLE_BINDING_FIELDS } from './kiwi/kiwi-convert'
import type { NodeChange, Paint, VariableConsumptionEntry } from './kiwi/codec'
import type { SceneGraph, SceneNode, CharacterStyleOverride } from './scene-graph'
import type { GUID } from './types'
const fontDigestCache = new Map<string, Uint8Array>()
@ -248,12 +249,12 @@ function exportTextData(node: SceneNode): NodeChange['textData'] {
export function sceneNodeToKiwi(
node: SceneNode,
parentGuid: { sessionID: number; localID: number },
parentGuid: GUID,
childIndex: number,
localIdCounter: { value: number },
graph: SceneGraph,
blobs: Uint8Array[],
nodeIdToGuid?: Map<string, { sessionID: number; localID: number }>,
nodeIdToGuid?: Map<string, GUID>,
fontDigestMap?: Map<string, Uint8Array>
): KiwiNodeChange[] {
const localID = localIdCounter.value++

View file

@ -193,7 +193,7 @@ export interface Paint {
visible?: boolean
blendMode?: string
stops?: { color: Color; position: number }[]
transform?: { m00: number; m01: number; m02: number; m10: number; m11: number; m12: number }
transform?: Matrix
image?: { hash: string }
imageScaleMode?: string
colorVariableBinding?: VariableBinding
@ -296,7 +296,7 @@ export interface NodeChange {
styleOverrideTable?: NodeChange[]
}
derivedTextData?: {
layoutSize?: { x: number; y: number }
layoutSize?: Vector
fontMetaData?: Array<{
key: { family: string; style: string; postscript?: string }
fontLineHeight: number
@ -504,7 +504,7 @@ export function encodePaintWithVariableBinding(
* Parse a variable ID string (e.g., "VariableID:38448:122296")
* Returns sessionID and localID
*/
export function parseVariableId(variableId: string): { sessionID: number; localID: number } | null {
export function parseVariableId(variableId: string): GUID | null {
const match = variableId.match(/VariableID:(\d+):(\d+)/)
if (!match) return null
return {
@ -567,7 +567,7 @@ export function encodeNodeChangeWithVariables(nodeChange: NodeChange): Uint8Arra
function injectVariableBinding(
hex: string,
marker: string,
binding: { variableID: { sessionID: number; localID: number } }
binding: { variableID: GUID }
): string {
const markerIdx = hex.indexOf(marker)
if (markerIdx === -1) return hex

View file

@ -2,6 +2,7 @@ import type { SceneGraph, SceneNode, GeometryPath } from '../scene-graph'
import { guidToString, convertOverrideToProps, resolveGeometryPaths } from './kiwi-convert'
import { copyFills, copyStrokes, copyEffects, copyStyleRuns, copyGeometryPaths } from '../copy'
import type { GUID } from './codec'
import type { Matrix, Vector } from '../types'
interface SymbolOverride {
guidPath?: { guids?: GUID[] }
@ -27,8 +28,8 @@ interface ComponentPropAssignment {
interface DerivedSymbolOverride {
guidPath?: { guids?: GUID[] }
size?: { x: number; y: number }
transform?: { m00: number; m01: number; m02: number; m10: number; m11: number; m12: number }
size?: Vector
transform?: Matrix
fillGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
strokeGeometry?: Array<{ windingRule?: string; commandsBlob?: number }>
}

View file

@ -2,6 +2,7 @@ import { normalizeColor } from '../color'
import { DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from '../constants'
import { styleToWeight } from '../fonts'
import { decodeVectorNetworkBlob } from '../vector'
import type { Matrix, Vector } from '../types'
import type {
SceneNode,
@ -79,14 +80,7 @@ function imageHashToString(hash: Record<string, number>): string {
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('')
}
function convertGradientTransform(t?: {
m00: number
m01: number
m02: number
m10: number
m11: number
m12: number
}): GradientTransform | undefined {
function convertGradientTransform(t?: Matrix): GradientTransform | undefined {
if (!t) return undefined
return { m00: t.m00, m01: t.m01, m02: t.m02, m10: t.m10, m11: t.m11, m12: t.m12 }
}
@ -386,7 +380,7 @@ function resolveVectorNetwork(
const vectorData = nc.vectorData as
| {
vectorNetworkBlob?: number
normalizedSize?: { x: number; y: number }
normalizedSize?: Vector
styleOverrideTable?: Array<{ styleID: number; handleMirroring?: string }>
}
| undefined

View file

@ -29,7 +29,7 @@ import {
import type { SceneNode, SceneGraph } from '../scene-graph'
import type { SnapGuide } from '../snap'
import type { TextEditor } from '../text-editor'
import type { Rect } from '../types'
import type { Rect, Vector } from '../types'
import type { Canvas } from 'canvaskit-wasm'
import type { SkiaRenderer } from './renderer'
import type { RenderOverlays } from './renderer'
@ -358,8 +358,8 @@ export function drawGroupBounds(
export function getRotatedCorners(
r: SkiaRenderer,
n: SceneNode,
abs: { x: number; y: number }
): Array<{ x: number; y: number }> {
abs: Vector
): Vector[] {
const cx = (abs.x + n.width / 2) * r.zoom + r.panX
const cy = (abs.y + n.height / 2) * r.zoom + r.panY
const hw = (n.width / 2) * r.zoom

View file

@ -31,8 +31,7 @@ import { RenderProfiler } from '../profiler'
import type { SceneNode, SceneGraph, Fill, Stroke } from '../scene-graph'
import type { SnapGuide } from '../snap'
import type { TextEditor } from '../text-editor'
import type { Color } from '../types'
import type { Rect } from '../types'
import type { Color, Rect, Vector } from '../types'
import type { Image as CKImage, Path } from 'canvaskit-wasm'
import type {
CanvasKit,
@ -128,14 +127,14 @@ export interface RenderOverlays {
direction: 'HORIZONTAL' | 'VERTICAL'
} | null
penState?: {
vertices: Array<{ x: number; y: number }>
vertices: Vector[]
segments: Array<{
start: number
end: number
tangentStart: { x: number; y: number }
tangentEnd: { x: number; y: number }
tangentStart: Vector
tangentEnd: Vector
}>
dragTangent: { x: number; y: number } | null
dragTangent: Vector | null
closingToFirst: boolean
cursorX?: number
cursorY?: number
@ -884,7 +883,7 @@ export class SkiaRenderer {
return stroke.color
}
screenToCanvas(sx: number, sy: number): { x: number; y: number } {
screenToCanvas(sx: number, sy: number): Vector {
return {
x: (sx - this.panX) / this.zoom,
y: (sy - this.panY) / this.zoom
@ -970,7 +969,7 @@ export class SkiaRenderer {
drawGroupBoundsFn(this, canvas, nodes, graph)
}
getRotatedCorners(n: SceneNode, abs: { x: number; y: number }): Array<{ x: number; y: number }> {
getRotatedCorners(n: SceneNode, abs: Vector): Vector[] {
return getRotatedCornersFn(this, n, abs)
}

View file

@ -2,6 +2,7 @@ import { BLACK, DEFAULT_FONT_FAMILY, DEFAULT_STROKE_MITER_LIMIT } from './consta
import { copyEffects, copyFills, copyStrokes, copyStyleRuns } from './copy'
export type { GUID, Color } from './types'
import type { Matrix, Vector } from './types'
export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH'
export type WindingRule = 'NONZERO' | 'EVENODD'
@ -18,8 +19,8 @@ export interface VectorVertex {
export interface VectorSegment {
start: number
end: number
tangentStart: { x: number; y: number }
tangentEnd: { x: number; y: number }
tangentStart: Vector
tangentEnd: Vector
}
export interface VectorRegion {
@ -124,7 +125,7 @@ export interface Stroke {
export interface Effect {
type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR'
color: Color
offset: { x: number; y: number }
offset: Vector
radius: number
spread: number
visible: boolean
@ -452,7 +453,7 @@ export class SceneGraph {
variableCollections = new Map<string, VariableCollection>()
activeMode = new Map<string, string>()
rootId: string
private absPosCache = new Map<string, { x: number; y: number }>()
private absPosCache = new Map<string, Vector>()
constructor() {
const root = createDefaultNode('FRAME', {
@ -659,7 +660,7 @@ export class SceneGraph {
this.absPosCache.clear()
}
getAbsolutePosition(id: string): { x: number; y: number } {
getAbsolutePosition(id: string): Vector {
const cached = this.absPosCache.get(id)
if (cached) return cached

View file

@ -7,6 +7,7 @@ import type {
WindingRule
} from './scene-graph'
import type { CanvasKit, Path } from 'canvaskit-wasm'
import type { Rect } from './types'
// --- vectorNetworkBlob binary format ---
// Header: [numVertices:u32, numSegments:u32, numRegions:u32] (12 bytes)
@ -320,12 +321,7 @@ function buildChains(segments: VectorSegment[], _vertexCount: number): number[][
return chains
}
export function computeVectorBounds(network: VectorNetwork): {
x: number
y: number
width: number
height: number
} {
export function computeVectorBounds(network: VectorNetwork): Rect {
if (network.vertices.length === 0) {
return { x: 0, y: 0, width: 0, height: 0 }
}

View file

@ -14,7 +14,7 @@ import {
import { computeSelectionBounds, computeSnap } from '@open-pencil/core'
import type { EditorStore, Tool } from '@/stores/editor'
import type { NodeType, Rect, SceneNode } from '@open-pencil/core'
import type { NodeType, Rect, SceneNode, Vector } from '@open-pencil/core'
type HandlePosition = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
@ -29,7 +29,7 @@ interface DragMove {
type: 'move'
startX: number
startY: number
originals: Map<string, { x: number; y: number }>
originals: Map<string, Vector>
duplicated?: boolean
autoLayoutParentId?: string
brokeFromAutoLayout?: boolean
@ -150,7 +150,7 @@ function getHandlePositions(
s: { x: mx, y: y2 },
sw: { x: x1, y: y2 },
w: { x: x1, y: my }
} satisfies Record<HandlePosition, { x: number; y: number }>
} satisfies Record<HandlePosition, Vector>
}
function unrotate(
@ -389,7 +389,7 @@ export function useCanvasInput(
store.select([hit.id], true)
}
const originals = new Map<string, { x: number; y: number }>()
const originals = new Map<string, Vector>()
for (const id of store.state.selectedIds) {
const n = store.graph.getNode(id)
if (n) originals.set(id, { x: n.x, y: n.y })
@ -398,7 +398,7 @@ export function useCanvasInput(
// Alt+drag → duplicate
if (e.altKey && store.state.selectedIds.size > 0) {
const newIds: string[] = []
const newOriginals = new Map<string, { x: number; y: number }>()
const newOriginals = new Map<string, Vector>()
for (const id of store.state.selectedIds) {
const src = store.graph.getNode(id)
if (!src) continue

View file

@ -47,6 +47,7 @@ import type {
VectorNetwork,
VectorRegion,
VectorSegment,
Vector,
VectorVertex
} from '@open-pencil/core'
import type { CanvasKit } from 'canvaskit-wasm'
@ -175,7 +176,7 @@ export function createEditorStore() {
penState: null as {
vertices: VectorVertex[]
segments: VectorSegment[]
dragTangent: { x: number; y: number } | null
dragTangent: Vector | null
closingToFirst: boolean
} | null,
penCursorX: null as number | null,
@ -629,10 +630,7 @@ export function createEditorStore() {
}
}
function setCanvasKit(
ck: CanvasKit,
renderer: SkiaRenderer
) {
function setCanvasKit(ck: CanvasKit, renderer: SkiaRenderer) {
_ck = ck
_renderer = renderer
_textEditor = new TextEditor(ck)
@ -1958,8 +1956,8 @@ export function createEditorStore() {
}
}
function commitMove(originals: Map<string, { x: number; y: number }>) {
const finals = new Map<string, { x: number; y: number }>()
function commitMove(originals: Map<string, Vector>) {
const finals = new Map<string, Vector>()
for (const [id] of originals) {
const n = graph.getNode(id)
if (n) finals.set(id, { x: n.x, y: n.y })