205 lines
8.4 KiB
JavaScript
205 lines
8.4 KiB
JavaScript
// Recover literal SVG JSX from the existing REA export without executing captured code.
|
|
import crypto from 'node:crypto'
|
|
import fs from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { parse } from '@babel/parser'
|
|
|
|
const inputPath = '.tmp/rea-runtime-scripts.json'
|
|
const outputPath = 'src/renderer/src/workbuddy/message-actions/SourceActionIcons.tsx'
|
|
const bundle = JSON.parse(await fs.readFile(inputPath, 'utf8'))
|
|
const mainItem = bundle.items.find(item => item.file_path?.endsWith('src-0FGg1seX.js'))
|
|
const chevronItem = bundle.items.find(item => item.file_path?.endsWith('chevron-down-icon-Ve_gE8h-.js'))
|
|
|
|
if (!mainItem?.source?.included || !chevronItem?.source?.included) {
|
|
throw new Error('The REA export is missing one or more captured icon sources')
|
|
}
|
|
|
|
function verifiedSource(item) {
|
|
const source = item.source.artifact.text
|
|
const sha256 = crypto.createHash('sha256').update(source).digest('hex')
|
|
if (sha256 !== item.source.artifact.sha256) throw new Error(`REA source digest mismatch: ${item.file_path}`)
|
|
return { source, sha256 }
|
|
}
|
|
|
|
function findAssignment(ast, name) {
|
|
const matches = []
|
|
function visit(node) {
|
|
if (!node || typeof node !== 'object') return
|
|
if (node.type === 'AssignmentExpression' && node.left.type === 'Identifier' && node.left.name === name) {
|
|
matches.push(node)
|
|
}
|
|
for (const value of Object.values(node)) {
|
|
if (Array.isArray(value)) value.forEach(visit)
|
|
else if (value && typeof value === 'object') visit(value)
|
|
}
|
|
}
|
|
visit(ast.program)
|
|
if (matches.length !== 1) throw new Error(`Expected one assignment for ${name}, found ${matches.length}`)
|
|
return matches[0]
|
|
}
|
|
|
|
function keyName(node) {
|
|
if (node.type === 'Identifier') return node.name
|
|
if (node.type === 'StringLiteral' || node.type === 'NumericLiteral') return String(node.value)
|
|
return undefined
|
|
}
|
|
|
|
function staticValue(node) {
|
|
if (node.type === 'StringLiteral') return { kind: 'string', value: node.value }
|
|
if (node.type === 'NumericLiteral') return { kind: 'number', value: node.value }
|
|
if (node.type === 'BooleanLiteral') return { kind: 'boolean', value: node.value }
|
|
if (node.type === 'NullLiteral') return { kind: 'null' }
|
|
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
|
|
return { kind: 'string', value: node.quasis[0].value.cooked ?? node.quasis[0].value.raw }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function escapeAttribute(value) {
|
|
return String(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<')
|
|
}
|
|
|
|
function isFactoryCall(node) {
|
|
if (node.type !== 'CallExpression' || node.arguments.length < 2) return false
|
|
const callee = node.callee
|
|
return callee.type === 'SequenceExpression'
|
|
|| callee.type === 'MemberExpression'
|
|
|| callee.type === 'Identifier'
|
|
}
|
|
|
|
function renderChild(node, depth, bindings) {
|
|
if (!node || node.type === 'NullLiteral') return ''
|
|
if (node.type === 'ArrayExpression') return node.elements.map(child => renderChild(child, depth, bindings)).filter(Boolean).join('\n')
|
|
if (node.type === 'StringLiteral') return `${' '.repeat(depth)}${node.value}`
|
|
if (!isFactoryCall(node)) throw new Error(`Unsupported captured SVG child node: ${node.type}`)
|
|
const tagNode = node.arguments[0]
|
|
const propsNode = node.arguments[1]
|
|
const tag = staticValue(tagNode)
|
|
if (tag?.kind !== 'string' || propsNode.type !== 'ObjectExpression') {
|
|
throw new Error('Captured SVG element is not a literal JSX factory call')
|
|
}
|
|
|
|
const attrs = []
|
|
let children
|
|
for (const property of propsNode.properties) {
|
|
if (property.type === 'SpreadElement') {
|
|
const binding = bindings.get(property.argument.name)
|
|
if (!binding || binding.kind !== 'spread') throw new Error('Unexpected dynamic SVG props spread')
|
|
attrs.push(`{...${binding.value}}`)
|
|
continue
|
|
}
|
|
if (property.type !== 'ObjectProperty') throw new Error('Unsupported SVG props member')
|
|
const name = keyName(property.key)
|
|
if (!name || name === 'key') continue
|
|
if (name === 'children') {
|
|
children = property.value
|
|
continue
|
|
}
|
|
const value = staticValue(property.value)
|
|
if (value?.kind === 'string') {
|
|
attrs.push(`${name}="${escapeAttribute(value.value)}"`)
|
|
} else if (value?.kind === 'number') {
|
|
attrs.push(`${name}={${value.value}}`)
|
|
} else if (value?.kind === 'boolean') {
|
|
attrs.push(value.value ? name : `${name}={false}`)
|
|
} else if (value?.kind === 'null') {
|
|
attrs.push(`${name}={null}`)
|
|
} else if (property.value.type === 'Identifier') {
|
|
const binding = bindings.get(property.value.name)
|
|
if (!binding || binding.kind !== 'expression') throw new Error(`Unexpected dynamic SVG prop: ${name}`)
|
|
attrs.push(`${name}={${binding.value}}`)
|
|
} else {
|
|
throw new Error(`Unsupported captured SVG attribute value: ${name}`)
|
|
}
|
|
}
|
|
|
|
const pad = ' '.repeat(depth)
|
|
const attrText = attrs.map(attribute => `${' '.repeat(depth + 1)}${attribute}`).join('\n')
|
|
const renderedChildren = children ? renderChild(children, depth + 1, bindings) : ''
|
|
if (!renderedChildren) return `${pad}<${tag.value}${attrs.length ? `\n${attrText}\n${pad}` : ''} />`
|
|
return `${pad}<${tag.value}${attrs.length ? `\n${attrText}\n${pad}` : ''}>\n${renderedChildren}\n${pad}</${tag.value}>`
|
|
}
|
|
|
|
function componentSource(assignment, exportedName, sourceLabel, sha256) {
|
|
const arrow = assignment.right
|
|
if (arrow.type !== 'ArrowFunctionExpression' || arrow.params.length !== 1) {
|
|
throw new Error(`Unexpected icon component form: ${assignment.left.name}`)
|
|
}
|
|
const param = arrow.params[0]
|
|
let signature
|
|
let bindings
|
|
if (param.type === 'ObjectPattern') {
|
|
const propsBindings = new Map()
|
|
for (const property of param.properties) {
|
|
if (property.type === 'ObjectProperty' && property.value.type === 'Identifier') {
|
|
const key = keyName(property.key)
|
|
if (key === 'className' || key === 'style') propsBindings.set(property.value.name, { kind: 'expression', value: key })
|
|
} else if (property.type === 'RestElement' && property.argument.type === 'Identifier') {
|
|
propsBindings.set(property.argument.name, { kind: 'spread', value: 'props' })
|
|
}
|
|
}
|
|
signature = '({ className, style, ...props }: IconProps)'
|
|
bindings = propsBindings
|
|
} else if (param.type === 'Identifier') {
|
|
signature = '(props: IconProps)'
|
|
bindings = new Map([[param.name, { kind: 'spread', value: 'props' }]])
|
|
} else {
|
|
throw new Error(`Unsupported icon component parameter: ${assignment.left.name}`)
|
|
}
|
|
const body = arrow.body
|
|
if (!isFactoryCall(body)) throw new Error(`Unsupported icon component body: ${assignment.left.name}`)
|
|
const svg = renderChild(body, 2, bindings)
|
|
return `/** REA ${bundle.evidence_id}; ${sourceLabel} SHA-256 ${sha256}; UTF-16 [${assignment.start}, ${assignment.end}). */\nexport function ${exportedName}${signature}: JSX.Element {\n return (\n${svg}\n )\n}\n`
|
|
}
|
|
|
|
const main = verifiedSource(mainItem)
|
|
const chevron = verifiedSource(chevronItem)
|
|
const mainAst = parse(main.source, { sourceType: 'module', plugins: ['jsx'] })
|
|
const chevronAst = parse(chevron.source, { sourceType: 'module', plugins: ['jsx'] })
|
|
const mainIcons = [
|
|
['ThumbsUpIcon', 'SourceThumbsUpIcon'],
|
|
['ThumbsUpSolidIcon', 'SourceThumbsUpSolidIcon'],
|
|
['ThumbsDownIcon', 'SourceThumbsDownIcon'],
|
|
['ThumbsDownSolidIcon', 'SourceThumbsDownSolidIcon'],
|
|
['ResendIcon', 'SourceResendIcon'],
|
|
['RevertIcon', 'SourceRevertIcon']
|
|
]
|
|
const snippets = [
|
|
"import type { JSX, SVGProps } from 'react'",
|
|
'',
|
|
'type IconProps = SVGProps<SVGSVGElement>',
|
|
'',
|
|
'// No dedicated regenerate icon declaration was found in the captured bundle.',
|
|
''
|
|
]
|
|
|
|
for (const [sourceName, exportedName] of mainIcons) {
|
|
const assignment = findAssignment(mainAst, sourceName)
|
|
snippets.push(componentSource(
|
|
assignment,
|
|
exportedName,
|
|
'src-0FGg1seX.js',
|
|
main.sha256
|
|
))
|
|
}
|
|
|
|
const chevronAssignment = findAssignment(chevronAst, 'ChevronDownIconImpl')
|
|
snippets.push(componentSource(
|
|
chevronAssignment,
|
|
'SourceFoldChevronIcon',
|
|
'chevron-down-icon-Ve_gE8h-.js',
|
|
chevron.sha256
|
|
))
|
|
|
|
await fs.mkdir(path.dirname(outputPath), { recursive: true })
|
|
await fs.writeFile(outputPath, `${snippets.join('\n')}\n`)
|
|
console.log(JSON.stringify({
|
|
outputPath,
|
|
evidenceId: bundle.evidence_id,
|
|
sources: [
|
|
{ file: 'src-0FGg1seX.js', sha256: main.sha256, icons: mainIcons.length },
|
|
{ file: 'chevron-down-icon-Ve_gE8h-.js', sha256: chevron.sha256, icons: 1 }
|
|
],
|
|
regenerate: 'no dedicated icon declaration found'
|
|
}, null, 2))
|