* feat(design-jsx): export every property the renderer accepts JSX export wrote only part of a layer: one solid fill, one stroke without its alignment, shadows as repeated attributes, background blurs as layer blurs, and nothing for hidden children, constraints, size limits, absolute positioning, vertical text alignment, masks, or variable bindings. Rendering an export lost those properties, and JSX diffs could not see changes to them. The export now writes them, using paint and effect helper calls when a shorthand cannot express a value exactly, and leaves out values the renderer would infer, so ordinary output stays as it was. Prop values can now hold objects, arrays, and helper calls, printed through @open-pencil/codegen's builders, which gain a call expression. The language gains `visible`, `locked`, `constraints` (Figma's constraints object with lowercase values, as `blendMode` uses), `italic`, `strokes`, `strokeWeights`, `strokeCap`, and `strokeJoin`, and now applies `strokeAlign`, `strokeDash`, and the size limits, which it accepted but ignored. Per-corner radii are written even when the uniform radius is 0. A round-trip test renders each case's export and checks the fields and that exporting again changes nothing. * docs(fig): name the saved-glyph fixture by its repository path The observation note linked the fixture with a relative path climbing four directories. Other notes name fixtures by their repository path, which reads the same from anywhere. * fix(design-jsx): export the node-level dash pattern A node's own dashPattern was not written, so a node dashed at node level came back solid and the DOM/CSS export chose a solid border. It now round-trips as a separate dashPattern prop; strokeDash stays the stroke-local dash.
143 lines
4.9 KiB
TypeScript
143 lines
4.9 KiB
TypeScript
import { tsPlugin } from '@sveltejs/acorn-typescript'
|
|
import { Parser } from 'acorn'
|
|
import { print } from 'esrap'
|
|
import ts from 'esrap/languages/ts'
|
|
|
|
/** An ESTree or TS-ESTree node, as acorn parses it and esrap prints it. */
|
|
export interface SyntaxNode {
|
|
type: string
|
|
[key: string]: unknown
|
|
}
|
|
|
|
const TSParser = Parser.extend(tsPlugin())
|
|
|
|
function isNode(value: unknown): value is SyntaxNode {
|
|
return typeof value === 'object' && value !== null && 'type' in value
|
|
}
|
|
|
|
/** The node under `key`, or undefined when it is absent or not a node. */
|
|
export function child(node: SyntaxNode | undefined, key: string): SyntaxNode | undefined {
|
|
const value = node?.[key]
|
|
return isNode(value) ? value : undefined
|
|
}
|
|
|
|
export function children(node: SyntaxNode | undefined, key: string): SyntaxNode[] {
|
|
const value = node?.[key]
|
|
return Array.isArray(value) ? value.filter(isNode) : []
|
|
}
|
|
|
|
export function parseModule(source: string): SyntaxNode & { body: SyntaxNode[] } {
|
|
const program: unknown = TSParser.parse(source, { ecmaVersion: 'latest', sourceType: 'module' })
|
|
if (!isNode(program)) throw new Error('Expected a module')
|
|
return { ...program, body: children(program, 'body') }
|
|
}
|
|
|
|
export function parseExpression(source: string): SyntaxNode {
|
|
const expression = child(parseModule(`(${source})`).body.at(0), 'expression')
|
|
if (!expression) throw new Error(`Expected an expression: ${source}`)
|
|
return expression
|
|
}
|
|
|
|
export function parseType(source: string): SyntaxNode {
|
|
const type = child(parseModule(`type T = ${source}`).body.at(0), 'typeAnnotation')
|
|
if (!type) throw new Error(`Expected a type: ${source}`)
|
|
return type
|
|
}
|
|
|
|
/** A hole value that removes the object property holding it. */
|
|
export const OMIT = Symbol('omit')
|
|
|
|
/**
|
|
* Replace `$name` placeholders — identifiers, type references, and string literals — with
|
|
* nodes. A property whose value is filled with `OMIT` is removed.
|
|
*/
|
|
export function fill<T extends SyntaxNode>(
|
|
template: T,
|
|
holes: Record<string, SyntaxNode | typeof OMIT>
|
|
): T {
|
|
const hole = (name: unknown) => (typeof name === 'string' ? holes[name] : undefined)
|
|
const holeOf = (node: SyntaxNode) => {
|
|
if (node.type === 'Identifier') return hole(node.name)
|
|
if (node.type === 'Literal') return hole(node.value)
|
|
if (node.type === 'TSTypeReference') return hole(child(node, 'typeName')?.name)
|
|
return undefined
|
|
}
|
|
const omitted = (node: unknown) =>
|
|
isNode(node) && node.type === 'Property' && hole(child(node, 'value')?.name) === OMIT
|
|
const replacement = (node: SyntaxNode): SyntaxNode | undefined => {
|
|
const value = holeOf(node)
|
|
if (!value || value === OMIT) return undefined
|
|
// `$name: Story` keeps its annotation when the identifier is filled.
|
|
return node.typeAnnotation ? { ...value, typeAnnotation: node.typeAnnotation } : value
|
|
}
|
|
const visit = (node: SyntaxNode): SyntaxNode => {
|
|
const filled = replacement(node)
|
|
if (filled) return structuredClone(filled)
|
|
const copy: SyntaxNode = { ...node }
|
|
for (const [key, value] of Object.entries(node)) {
|
|
if (Array.isArray(value))
|
|
copy[key] = value
|
|
.filter((item) => !omitted(item))
|
|
.map((item) => (isNode(item) ? visit(item) : item))
|
|
else if (isNode(value)) copy[key] = visit(value)
|
|
}
|
|
return copy
|
|
}
|
|
return visit(template) as T
|
|
}
|
|
|
|
export function printModule(program: SyntaxNode): string {
|
|
return print(program, ts(), { indent: ' ' }).code
|
|
}
|
|
|
|
export const identifier = (name: string): SyntaxNode => ({ type: 'Identifier', name })
|
|
|
|
export const string = (value: string): SyntaxNode => ({ type: 'Literal', value })
|
|
|
|
const propertyKey = (key: string) =>
|
|
/^[A-Za-z_$][\w$]*$/.test(key) ? identifier(key) : string(key)
|
|
|
|
export function object(entries: (readonly [string, SyntaxNode])[]): SyntaxNode {
|
|
return {
|
|
type: 'ObjectExpression',
|
|
properties: entries.map(([key, value]) => ({
|
|
type: 'Property',
|
|
kind: 'init',
|
|
key: propertyKey(key),
|
|
value,
|
|
computed: false,
|
|
method: false,
|
|
shorthand: false
|
|
}))
|
|
}
|
|
}
|
|
|
|
export const array = (elements: SyntaxNode[]): SyntaxNode => ({ type: 'ArrayExpression', elements })
|
|
|
|
/** A call of a function by name, such as a helper inside an exported prop value. */
|
|
export const call = (callee: string, args: SyntaxNode[]): SyntaxNode => ({
|
|
type: 'CallExpression',
|
|
callee: identifier(callee),
|
|
arguments: args,
|
|
optional: false
|
|
})
|
|
|
|
export function stringUnionType(values: string[]): SyntaxNode {
|
|
const literals = values.map((value) => ({ type: 'TSLiteralType', literal: string(value) }))
|
|
return literals.length === 1 && literals[0]
|
|
? literals[0]
|
|
: { type: 'TSUnionType', types: literals }
|
|
}
|
|
|
|
export function objectType(entries: [string, SyntaxNode][]): SyntaxNode {
|
|
return {
|
|
type: 'TSTypeLiteral',
|
|
members: entries.map(([key, typeAnnotation]) => ({
|
|
type: 'TSPropertySignature',
|
|
key: propertyKey(key),
|
|
computed: false,
|
|
typeAnnotation: { type: 'TSTypeAnnotation', typeAnnotation }
|
|
}))
|
|
}
|
|
}
|