diff --git a/lint/plugin.js b/lint/plugin.js index 0d16f8e4e..ae90d635b 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -147,6 +147,46 @@ const noHandRolledColor = { }, } +const noRawConsoleFormat = { + meta: { + docs: { + description: + 'Disallow hand-rolled formatting in console.log — use agentfmt helpers (bold, dim, kv, entity, fmtTree, fmtList, etc.)', + }, + }, + create(context) { + return { + CallExpression(node) { + if ( + node.callee?.type !== 'MemberExpression' || + node.callee.object?.type !== 'Identifier' || + node.callee.object.name !== 'console' || + node.callee.property?.type !== 'Identifier' || + node.callee.property.name !== 'log' + ) return + if (!node.arguments?.length) return + + for (const arg of node.arguments) { + if (arg.type === 'TemplateLiteral' && arg.expressions?.length > 0) { + context.report({ + node, + message: 'Use agentfmt helpers (bold, dim, kv, entity, etc.) instead of template literals in console.log.', + }) + return + } + if (arg.type === 'BinaryExpression' && arg.operator === '+') { + context.report({ + node, + message: 'Use agentfmt helpers (bold, dim, kv, entity, etc.) instead of string concatenation in console.log.', + }) + return + } + } + }, + } + }, +} + const plugin = { meta: { name: 'open-pencil' }, rules: { @@ -154,6 +194,7 @@ const plugin = { 'no-structuredclone-scene-arrays': noStructuredCloneSceneArrays, 'no-math-random': noMathRandom, 'no-hand-rolled-color': noHandRolledColor, + 'no-raw-console-format': noRawConsoleFormat, }, } diff --git a/oxlint.json b/oxlint.json index 1d3d37d00..412edd6f4 100644 --- a/oxlint.json +++ b/oxlint.json @@ -77,7 +77,8 @@ }], "open-pencil/no-structuredclone-scene-arrays": "error", "open-pencil/no-math-random": "error", - "open-pencil/no-hand-rolled-color": "error" + "open-pencil/no-hand-rolled-color": "error", + "open-pencil/no-raw-console-format": "off" }, "overrides": [ { @@ -103,6 +104,12 @@ "rules": { "max-lines": "off" } + }, + { + "files": ["packages/cli/src/commands/**"], + "rules": { + "open-pencil/no-raw-console-format": "error" + } } ], "ignorePatterns": ["node_modules", "dist", "desktop", "*.config.*"] diff --git a/package.json b/package.json index 17a4930ec..13fb4a3b5 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "bun run lint && vite build", "preview": "vite preview", "tauri": "tauri", - "lint": "oxlint -c oxlint.json --type-aware --type-check src/", + "lint": "oxlint -c oxlint.json --type-aware --type-check src/ packages/cli/src/", "format": "oxfmt --write src/", "check": "bun run lint", "test": "playwright test --project=openpencil", diff --git a/packages/cli/src/commands/analyze/clusters.ts b/packages/cli/src/commands/analyze/clusters.ts index b994eba9b..57112b0e0 100644 --- a/packages/cli/src/commands/analyze/clusters.ts +++ b/packages/cli/src/commands/analyze/clusters.ts @@ -9,7 +9,7 @@ import type { AnalyzeClustersResult } from '@open-pencil/core' function calcConfidence(nodes: Array<{ width: number; height: number; childCount: number }>): number { if (nodes.length < 2) return 100 - const base = nodes[0]! + const base = nodes[0] let score = 0 for (const node of nodes.slice(1)) { const sizeDiff = Math.abs(node.width - base.width) + Math.abs(node.height - base.height) @@ -24,7 +24,7 @@ function calcConfidence(nodes: Array<{ width: number; height: number; childCount function formatSignature(sig: string): string { const [typeSize, children] = sig.split('|') - const type = typeSize?.split(':')[0] + const type = typeSize.split(':')[0] if (!type) return sig const typeName = type.charAt(0) + type.slice(1).toLowerCase() if (!children) return typeName @@ -73,7 +73,7 @@ export default defineCommand({ console.log('') const items = data.clusters.map((c) => { - const first = c.nodes[0]! + const first = c.nodes[0] const confidence = calcConfidence(c.nodes) const widths = c.nodes.map((n) => n.width) @@ -102,10 +102,11 @@ export default defineCommand({ const clusteredNodes = data.clusters.reduce((sum, c) => sum + c.nodes.length, 0) console.log('') - console.log( - fmtSummary({ clusters: data.clusters.length }) + - ` from ${data.totalNodes} nodes (${clusteredNodes} clustered)` - ) + console.log(fmtSummary({ + clusters: data.clusters.length, + 'total nodes': data.totalNodes, + clustered: clusteredNodes + })) console.log('') } }) diff --git a/packages/cli/src/commands/analyze/typography.ts b/packages/cli/src/commands/analyze/typography.ts index 6cbe72361..e5249f2a0 100644 --- a/packages/cli/src/commands/analyze/typography.ts +++ b/packages/cli/src/commands/analyze/typography.ts @@ -91,7 +91,7 @@ export default defineCommand({ } console.log('') - console.log(fmtSummary({ 'unique styles': data.styles.length }) + ` from ${data.totalTextNodes} text nodes`) + console.log(fmtSummary({ 'unique styles': data.styles.length, 'text nodes': data.totalTextNodes })) console.log('') } }) diff --git a/packages/cli/src/commands/eval.ts b/packages/cli/src/commands/eval.ts index 2871f460c..a9e8558dc 100644 --- a/packages/cli/src/commands/eval.ts +++ b/packages/cli/src/commands/eval.ts @@ -6,9 +6,17 @@ import { loadDocument } from '../headless' import { isAppMode, requireFile, rpc } from '../app-client' import { printError } from '../format' +function printResult(value: unknown, json: boolean) { + if (json || !process.stdout.isTTY) { + console.log(JSON.stringify(value, null, 2)) + } else { + console.log(value) + } +} + function serializeResult(value: unknown): unknown { if (value === undefined || value === null) return value - if (typeof value === 'object' && value !== null && 'toJSON' in value && typeof value.toJSON === 'function') { + if (typeof value === 'object' && 'toJSON' in value && typeof value.toJSON === 'function') { return value.toJSON() } if (Array.isArray(value)) return value.map(serializeResult) @@ -22,7 +30,7 @@ export default defineCommand({ code: { type: 'string', alias: 'c', description: 'JavaScript code to execute' }, stdin: { type: 'boolean', description: 'Read code from stdin' }, write: { type: 'boolean', alias: 'w', description: 'Write changes back to the input file' }, - output: { type: 'string', alias: 'o', description: 'Write to a different file' }, + output: { type: 'string', alias: 'o', description: 'Write to a different file', required: false }, json: { type: 'boolean', description: 'Output as JSON' }, quiet: { type: 'boolean', alias: 'q', description: 'Suppress output' }, }, @@ -41,13 +49,9 @@ export default defineCommand({ } if (isAppMode(args.file)) { - const result = await rpc('eval', { code }) + const result = await rpc('eval', { code }) if (!args.quiet && result !== undefined && result !== null) { - if (args.json || !process.stdout.isTTY) { - console.log(JSON.stringify(result, null, 2)) - } else { - console.log(result) - } + printResult(result, !!args.json) } return } @@ -56,6 +60,7 @@ export default defineCommand({ const graph = await loadDocument(file) const figma = new FigmaAPI(graph) + // eslint-disable-next-line no-empty-function -- needed to get AsyncFunction constructor const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor const wrappedCode = code.trim().startsWith('return') ? code @@ -71,17 +76,12 @@ export default defineCommand({ } if (!args.quiet && result !== undefined) { - const serialized = serializeResult(result) - if (args.json || !process.stdout.isTTY) { - console.log(JSON.stringify(serialized, null, 2)) - } else { - console.log(serialized) - } + printResult(serializeResult(result), !!args.json) } if (args.write || args.output) { const { exportFigFile } = await import('@open-pencil/core') - const outPath = args.output ?? file + const outPath = args.output ? args.output : file const data = await exportFigFile(graph) await Bun.write(outPath, new Uint8Array(data)) if (!args.quiet) { diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts index 9001ff619..bef3ac9c8 100644 --- a/packages/cli/src/commands/export.ts +++ b/packages/cli/src/commands/export.ts @@ -12,16 +12,109 @@ const RASTER_FORMATS = ['PNG', 'JPG', 'WEBP'] const ALL_FORMATS = [...RASTER_FORMATS, 'SVG', 'JSX'] const JSX_STYLES = ['openpencil', 'tailwind'] +interface ExportArgs { + file?: string + output?: string + format: string + scale: string + quality?: string + page?: string + node?: string + style: string + thumbnail?: boolean + width: string + height: string +} + +async function writeAndLog(path: string, content: string | Uint8Array) { + await Bun.write(path, content) + const size = typeof content === 'string' ? content.length : content.length + console.log(ok(`Exported ${path} (${(size / 1024).toFixed(1)} KB)`)) +} + +async function exportViaApp(format: string, args: ExportArgs) { + if (format === 'SVG') { + const result = await rpc<{ svg: string }>('tool', { name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } }) + if (!result.svg) { printError('Nothing to export.'); process.exit(1) } + await writeAndLog(resolve(args.output ?? 'export.svg'), result.svg) + return + } + + if (format === 'JSX') { + const result = await rpc<{ jsx: string }>('export_jsx', { nodeIds: args.node ? [args.node] : undefined, style: args.style }) + if (!result.jsx) { printError('Nothing to export.'); process.exit(1) } + await writeAndLog(resolve(args.output ?? 'export.jsx'), result.jsx) + return + } + + const result = await rpc<{ base64: string }>('export', { + nodeIds: args.node ? [args.node] : undefined, + scale: Number(args.scale), + format: format.toLowerCase() + }) + const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0)) + const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase() + await writeAndLog(resolve(args.output ?? `export.${ext}`), data) +} + +async function exportFromFile(format: string, args: ExportArgs) { + const file = requireFile(args.file) + const graph = await loadDocument(file) + await loadFonts(graph) + + const pages = graph.getPages() + const page = args.page ? pages.find((p) => p.name === args.page) : pages[0] + if (!page) { printError(`Page "${args.page}" not found.`); process.exit(1) } + + const defaultName = basename(file, extname(file)) + + if (format === 'JSX') { + const nodeIds = args.node ? [args.node] : page.childIds + const jsxStr = nodeIds.length === 1 + ? sceneNodeToJSX(nodeIds[0], graph, args.style as JSXFormat) + : selectionToJSX(nodeIds, graph, args.style as JSXFormat) + if (!jsxStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) } + await writeAndLog(resolve(args.output ?? `${defaultName}.jsx`), jsxStr) + return + } + + const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase() + const output = resolve(args.output ?? `${defaultName}.${ext}`) + + if (format === 'SVG') { + const nodeIds = args.node ? [args.node] : page.childIds + const svgStr = renderNodesToSVG(graph, page.id, nodeIds) + if (!svgStr) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) } + await writeAndLog(output, svgStr) + return + } + + let data: Uint8Array | null + if (args.thumbnail) { + data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height)) + } else { + const nodeIds = args.node ? [args.node] : page.childIds + data = await exportNodes(graph, page.id, nodeIds, { + scale: Number(args.scale), + format: format as ExportFormat, + quality: args.quality ? Number(args.quality) : undefined + }) + } + + if (!data) { printError('Nothing to export (empty page or no visible nodes).'); process.exit(1) } + await writeAndLog(output, data) +} + export default defineCommand({ meta: { description: 'Export a .fig file to PNG, JPG, WEBP, SVG, or JSX' }, args: { file: { type: 'positional', description: '.fig file path (omit to connect to running app)', required: false }, - output: { type: 'string', alias: 'o', description: 'Output file path (default: .)' }, + output: { type: 'string', alias: 'o', description: 'Output file path (default: .)', required: false }, format: { type: 'string', alias: 'f', description: 'Export format: png, jpg, webp, svg, jsx (default: png)', default: 'png' }, scale: { type: 'string', alias: 's', description: 'Export scale (default: 1)', default: '1' }, - quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)' }, - page: { type: 'string', description: 'Page name (default: first page)' }, - node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)' }, + quality: { type: 'string', alias: 'q', description: 'Quality 0-100 for JPG/WEBP (default: 90)', required: false }, + page: { type: 'string', description: 'Page name (default: first page)', required: false }, + node: { type: 'string', description: 'Node ID to export (default: all top-level nodes)', required: false }, style: { type: 'string', description: 'JSX style: openpencil, tailwind (default: openpencil)', default: 'openpencil' }, thumbnail: { type: 'boolean', description: 'Export page thumbnail instead of full render' }, width: { type: 'string', description: 'Thumbnail width (default: 1920)', default: '1920' }, @@ -40,114 +133,9 @@ export default defineCommand({ } if (isAppMode(args.file)) { - if (format === 'SVG') { - const result = await rpc<{ svg: string }>('tool', { name: 'export_svg', args: { ids: args.node ? [args.node] : undefined } }) - if (!result.svg) { - printError('Nothing to export.') - process.exit(1) - } - const output = resolve(args.output ?? 'export.svg') - await Bun.write(output, result.svg) - console.log(ok(`Exported ${output} (${(result.svg.length / 1024).toFixed(1)} KB)`)) - return - } - - if (format === 'JSX') { - const result = await rpc<{ jsx: string }>('export_jsx', { - nodeIds: args.node ? [args.node] : undefined, - style: args.style - }) - if (!result.jsx) { - printError('Nothing to export.') - process.exit(1) - } - const output = resolve(args.output ?? 'export.jsx') - await Bun.write(output, result.jsx) - console.log(ok(`Exported ${output} (${(result.jsx.length / 1024).toFixed(1)} KB)`)) - return - } - - const result = await rpc<{ base64: string }>('export', { - nodeIds: args.node ? [args.node] : undefined, - scale: Number(args.scale), - format: format.toLowerCase() - }) - const data = Uint8Array.from(atob(result.base64), (c) => c.charCodeAt(0)) - const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase() - const output = resolve(args.output ?? `export.${ext}`) - await Bun.write(output, data) - console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`)) - return - } - - const file = requireFile(args.file) - const graph = await loadDocument(file) - await loadFonts(graph) - - const pages = graph.getPages() - const page = args.page - ? pages.find((p) => p.name === args.page) - : pages[0] - - if (!page) { - printError(`Page "${args.page}" not found.`) - process.exit(1) - } - - const defaultName = basename(file, extname(file)) - - if (format === 'JSX') { - const jsxFormat = args.style as JSXFormat - const nodeIds = args.node ? [args.node] : page.childIds - const jsxStr = nodeIds.length === 1 - ? sceneNodeToJSX(nodeIds[0], graph, jsxFormat) - : selectionToJSX(nodeIds, graph, jsxFormat) - - if (!jsxStr) { - printError('Nothing to export (empty page or no visible nodes).') - process.exit(1) - } - - const output = resolve(args.output ?? `${defaultName}.jsx`) - await Bun.write(output, jsxStr) - console.log(ok(`Exported ${output} (${(jsxStr.length / 1024).toFixed(1)} KB)`)) - return - } - - const ext = format.toLowerCase() === 'jpg' ? 'jpg' : format.toLowerCase() - const output = resolve(args.output ?? `${defaultName}.${ext}`) - - if (format === 'SVG') { - const nodeIds = args.node ? [args.node] : page.childIds - const svgStr = renderNodesToSVG(graph, page.id, nodeIds) - if (!svgStr) { - printError('Nothing to export (empty page or no visible nodes).') - process.exit(1) - } - await Bun.write(output, svgStr) - console.log(ok(`Exported ${output} (${(svgStr.length / 1024).toFixed(1)} KB)`)) - return - } - - let data: Uint8Array | null - - if (args.thumbnail) { - data = await exportThumbnail(graph, page.id, Number(args.width), Number(args.height)) + await exportViaApp(format, args) } else { - const nodeIds = args.node ? [args.node] : page.childIds - data = await exportNodes(graph, page.id, nodeIds, { - scale: Number(args.scale), - format, - quality: args.quality ? Number(args.quality) : undefined - }) + await exportFromFile(format, args) } - - if (!data) { - printError('Nothing to export (empty page or no visible nodes).') - process.exit(1) - } - - await Bun.write(output, data) - console.log(ok(`Exported ${output} (${(data.length / 1024).toFixed(1)} KB)`)) } }) diff --git a/packages/cli/src/commands/find.ts b/packages/cli/src/commands/find.ts index 4de441b2c..14f556e26 100644 --- a/packages/cli/src/commands/find.ts +++ b/packages/cli/src/commands/find.ts @@ -2,7 +2,7 @@ import { defineCommand } from 'citty' import { loadDocument } from '../headless' import { isAppMode, requireFile, rpc } from '../app-client' -import { fmtList, printError, bold, entity, formatType } from '../format' +import { fmtList, bold, entity, formatType } from '../format' import { executeRpcCommand } from '@open-pencil/core' import type { FindNodeResult } from '@open-pencil/core' diff --git a/packages/cli/src/commands/node.ts b/packages/cli/src/commands/node.ts index 43a21d6d1..0b795a468 100644 --- a/packages/cli/src/commands/node.ts +++ b/packages/cli/src/commands/node.ts @@ -5,7 +5,7 @@ import { isAppMode, requireFile, rpc } from '../app-client' import { fmtNode, printError, formatType } from '../format' import { executeRpcCommand, colorToHex } from '@open-pencil/core' -import type { NodeResult } from '@open-pencil/core' +import type { Color, NodeResult } from '@open-pencil/core' async function getData(file: string | undefined, id: string): Promise { if (isAppMode(file)) return rpc('node', { id }) @@ -47,7 +47,7 @@ export default defineCommand({ if (data.parent) details.parent = `${data.parent.name} (${data.parent.id})` if (data.text) details.text = data.text if (data.fills.length > 0) { - const solid = (data.fills as Array<{ type: string; visible: boolean; color: { r: number; g: number; b: number; a: number }; opacity: number }>) + const solid = (data.fills as Array<{ type: string; visible: boolean; color: Color; opacity: number }>) .find((f) => f.type === 'SOLID' && f.visible) if (solid) { const hex = colorToHex(solid.color) diff --git a/packages/cli/src/commands/pages.ts b/packages/cli/src/commands/pages.ts index 274ad9ec4..f179048ae 100644 --- a/packages/cli/src/commands/pages.ts +++ b/packages/cli/src/commands/pages.ts @@ -2,7 +2,7 @@ import { defineCommand } from 'citty' import { loadDocument } from '../headless' import { isAppMode, requireFile, rpc } from '../app-client' -import { bold, fmtList, entity, formatType } from '../format' +import { bold, fmtList, entity } from '../format' import type { PageItem } from '@open-pencil/core' import { executeRpcCommand } from '@open-pencil/core' diff --git a/packages/cli/src/commands/variables.ts b/packages/cli/src/commands/variables.ts index b0d06b50c..0a956e3eb 100644 --- a/packages/cli/src/commands/variables.ts +++ b/packages/cli/src/commands/variables.ts @@ -2,7 +2,7 @@ import { defineCommand } from 'citty' import { loadDocument } from '../headless' import { isAppMode, requireFile, rpc } from '../app-client' -import { bold, fmtList, fmtSummary } from '../format' +import { bold, entity, fmtList, fmtSummary } from '../format' import { executeRpcCommand } from '@open-pencil/core' import type { VariablesResult } from '@open-pencil/core' @@ -38,7 +38,7 @@ export default defineCommand({ console.log('') for (const coll of data.collections) { - console.log(bold(` ${coll.name}`) + ` (${coll.modes.join(', ')})`) + console.log(bold(entity(coll.name, coll.modes.join(', ')))) console.log('') console.log( fmtList( diff --git a/packages/cli/src/format.ts b/packages/cli/src/format.ts index c71d22b7e..8659a3c12 100644 --- a/packages/cli/src/format.ts +++ b/packages/cli/src/format.ts @@ -48,7 +48,7 @@ export function formatBox(node: SceneNode): string { function formatFill(node: SceneNode): string | null { if (!node.fills.length) return null const solid = node.fills.find((f) => f.type === 'SOLID' && f.visible) - if (!solid || solid.type !== 'SOLID') return null + if (!solid?.color) return null const { r, g, b } = solid.color const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('') return solid.opacity < 1 ? `${hex} ${Math.round(solid.opacity * 100)}%` : hex @@ -56,7 +56,7 @@ function formatFill(node: SceneNode): string | null { function formatStroke(node: SceneNode): string | null { if (!node.strokes.length) return null - const s = node.strokes[0]! + const s = node.strokes[0] const { r, g, b } = s.color const hex = '#' + [r, g, b].map((c) => Math.round(c * 255).toString(16).padStart(2, '0')).join('') return `${hex} ${s.weight}px` diff --git a/packages/cli/src/headless.ts b/packages/cli/src/headless.ts index 323e470bf..ee9c17ac7 100644 --- a/packages/cli/src/headless.ts +++ b/packages/cli/src/headless.ts @@ -2,14 +2,14 @@ import CanvasKitInit from 'canvaskit-wasm/full' import type { CanvasKit } from 'canvaskit-wasm' import { parseFigFile, - SceneGraph, + type SceneGraph, + type ExportFormat, SkiaRenderer, computeAllLayouts, loadFont, renderNodesToImage, renderThumbnail } from '@open-pencil/core' -import type { ExportFormat } from '@open-pencil/core' let ck: CanvasKit | null = null diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a1a685618..5a466f215 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -32,4 +32,4 @@ const main = defineCommand({ } }) -runMain(main) +void runMain(main)