UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

257 lines (243 loc) • 8.51 kB
import { Children, Fragment, isValidElement, type ComponentType, type ReactNode, } from 'react' import type { SFSymbolName } from '../generated/sfSymbolNames' export type WidgetStyle = { color?: string backgroundColor?: string fontSize?: number fontWeight?: 'regular' | 'medium' | 'semibold' | 'bold' fontDesign?: 'default' | 'rounded' | 'serif' | 'monospaced' padding?: number borderRadius?: number spacing?: number width?: number height?: number opacity?: number lineLimit?: number alignment?: 'leading' | 'center' | 'trailing' } type WidgetProps = { children?: ReactNode; style?: WidgetStyle } type ImageProps = Omit<WidgetProps, 'children'> & { systemName: SFSymbolName } type ProgressProps = Omit<WidgetProps, 'children'> & { value: number; total?: number } type ShapeProps = Omit<WidgetProps, 'children'> & { fill?: string; cornerRadius?: number } type LinkProps = WidgetProps & { url: string } export const WidgetUI = Object.freeze({ Text: 'one-widget-text' as unknown as ComponentType<WidgetProps>, VStack: 'one-widget-vstack' as unknown as ComponentType<WidgetProps>, HStack: 'one-widget-hstack' as unknown as ComponentType<WidgetProps>, ZStack: 'one-widget-zstack' as unknown as ComponentType<WidgetProps>, Spacer: 'one-widget-spacer' as unknown as ComponentType<Omit<WidgetProps, 'children'>>, Divider: 'one-widget-divider' as unknown as ComponentType< Omit<WidgetProps, 'children'> >, Image: 'one-widget-image' as unknown as ComponentType<ImageProps>, Progress: 'one-widget-progress' as unknown as ComponentType<ProgressProps>, Gauge: 'one-widget-gauge' as unknown as ComponentType<ProgressProps>, Circle: 'one-widget-circle' as unknown as ComponentType<ShapeProps>, Rectangle: 'one-widget-rectangle' as unknown as ComponentType<ShapeProps>, RoundedRectangle: 'one-widget-rounded-rectangle' as unknown as ComponentType<ShapeProps>, Link: 'one-widget-link' as unknown as ComponentType<LinkProps>, }) export type ActivityView = { lockScreen: ReactNode compactLeading?: ReactNode compactTrailing?: ReactNode minimal?: ReactNode expandedLeading?: ReactNode expandedTrailing?: ReactNode expandedBottom?: ReactNode } type Node = { type: string text?: string style?: WidgetStyle children?: Node[] systemName?: string value?: number total?: number fill?: string cornerRadius?: number url?: string } const hexColor = /^#[0-9a-fA-F]{6}$/ const styleNumberKeys = [ 'fontSize', 'padding', 'borderRadius', 'spacing', 'width', 'height', 'opacity', ] as const const styleStringKeys = ['fontWeight', 'fontDesign', 'alignment'] as const // every style value must survive the generated Swift decoder, which reads // numbers as Double, lineLimit as Int, and colors as #rrggbb. anything else // fails the whole layout decode, so reject it here where the error is visible. function checkStyle(style: WidgetStyle | undefined, fill?: unknown): void { if (fill != null && (typeof fill !== 'string' || !hexColor.test(fill))) { throw new Error('WidgetUI fill must be six-digit hex like #1685B1') } if (style == null) return if (typeof style !== 'object' || Array.isArray(style)) { throw new Error('WidgetUI style must be an object') } for (const key of ['color', 'backgroundColor'] as const) { const value = style[key] if (value != null && (typeof value !== 'string' || !hexColor.test(value))) { throw new Error(`WidgetUI ${key} must be six-digit hex like #1685B1`) } } if ( style.lineLimit != null && (typeof style.lineLimit !== 'number' || !Number.isInteger(style.lineLimit)) ) { throw new Error('WidgetUI lineLimit must be an integer') } for (const key of styleNumberKeys) { if (style[key] != null && typeof style[key] !== 'number') { throw new Error(`WidgetUI ${key} must be a number`) } } for (const key of styleStringKeys) { if (style[key] != null && typeof style[key] !== 'string') { throw new Error(`WidgetUI ${key} must be a string`) } } } function node(view: ReactNode): Node | null { if (view == null || typeof view === 'boolean') return null if (typeof view === 'string' || typeof view === 'number') { return { type: 'text', text: String(view) } } if (Array.isArray(view)) { return { type: 'vstack', children: children(view) } } if (!isValidElement(view)) { throw new Error('Widget JSX needs a WidgetUI root') } if (view.type === Fragment) { return { type: 'vstack', children: children((view.props as WidgetProps).children) } } if (typeof view.type === 'function') { if (view.type.prototype && 'isReactComponent' in view.type.prototype) { throw new Error('Widget JSX supports pure function components') } return node((view.type as (props: unknown) => ReactNode)(view.props)) } const type = typeof view.type === 'string' && view.type.startsWith('one-widget-') ? view.type.slice('one-widget-'.length) : undefined if ( !type || ![ 'text', 'vstack', 'hstack', 'zstack', 'spacer', 'divider', 'image', 'progress', 'gauge', 'circle', 'rectangle', 'rounded-rectangle', 'link', ].includes(type) ) { throw new Error('Widget JSX only supports WidgetUI primitives and pure components') } const { children: content, style, ...props } = view.props as WidgetProps & ImageProps & ProgressProps & ShapeProps & LinkProps checkStyle(style, (props as ShapeProps).fill) if ( (props as ShapeProps).cornerRadius != null && typeof (props as ShapeProps).cornerRadius !== 'number' ) { throw new Error('WidgetUI cornerRadius must be a number') } if (type === 'text') { const parts = Children.toArray(content) if (parts.some((part) => typeof part !== 'string' && typeof part !== 'number')) { throw new Error('WidgetUI.Text children must be text or numbers') } return { type, text: parts.join(''), style } } if (type === 'image') { if (typeof props.systemName !== 'string' || !props.systemName) { throw new Error('WidgetUI.Image needs a systemName') } return { type, style, systemName: props.systemName } } if (type === 'progress' || type === 'gauge') { if ( !Number.isFinite(props.value) || !Number.isFinite(props.total ?? 1) || (props.total ?? 1) <= 0 ) { throw new Error( 'WidgetUI progress values must be finite and total must be positive' ) } return { type, style, value: props.value, total: props.total ?? 1 } } if (type === 'circle' || type === 'rectangle' || type === 'rounded-rectangle') { return { type, style, fill: props.fill, cornerRadius: props.cornerRadius } } if (type === 'link') { let valid = false try { valid = Boolean(new URL(props.url).protocol) } catch {} if (!valid) { throw new Error('WidgetUI.Link needs an absolute URL') } return { type, style, url: props.url, children: children(content) } } if (type === 'spacer' || type === 'divider') return { type, style } return { type, style, children: children(content) } } function children(content: ReactNode): Node[] { const nodes: Node[] = [] for (const child of Children.toArray(content)) { const next = node(child) if (next) nodes.push(next) } return nodes } export function encodeWidgetView(view: ReactNode): string { const root = node(view) if (!root) { throw new Error('Widget JSX needs a WidgetUI root') } return JSON.stringify(root) } export function encodeActivityView(view: ActivityView): string { const lockScreen = node(view.lockScreen) if (!lockScreen) { throw new Error('Live Activity JSX needs a lockScreen layout') } const encoded = JSON.stringify({ lockScreen, compactLeading: view.compactLeading == null ? null : node(view.compactLeading), compactTrailing: view.compactTrailing == null ? null : node(view.compactTrailing), minimal: view.minimal == null ? null : node(view.minimal), expandedLeading: view.expandedLeading == null ? null : node(view.expandedLeading), expandedTrailing: view.expandedTrailing == null ? null : node(view.expandedTrailing), expandedBottom: view.expandedBottom == null ? null : node(view.expandedBottom), }) if (new TextEncoder().encode(encoded).length > 3500) { throw new Error('Live Activity JSX must fit within 3500 UTF-8 bytes') } return encoded }