Skip to content

Instantly share code, notes, and snippets.

@bsehovac
Last active May 2, 2024 08:24
Show Gist options
  • Save bsehovac/e8fd512bb89ea51fb1ae5ab5a19bdc3a to your computer and use it in GitHub Desktop.
Save bsehovac/e8fd512bb89ea51fb1ae5ab5a19bdc3a to your computer and use it in GitHub Desktop.
Scriptable JSX Widget Parser

Scriptable JSX Widget

Usage

Here's an example of basic usage:

widgetJSX(`
  <Widget size="medium" background="#1b1b1b:0.5,#1b1b4b:1" padding="10,10,10,10">
    <Stack layout="horizontal" align="center">
      <Image src="https://docs.scriptable.app/img/glyph.png" size="32,32" />
      <Spacer length="15" />
      <Stack layout="vertical" align="center">
        <Text string="Hello World" color="#ffffff" />
        <Text string="Scriptable JSX Widget" size="14" opacity="0.5" />
      </Stack>
    </Stack>
  </Widget>

User guide

Widget

Displays a widget. Must be included as wrapper of all other elements.

Props

Prop name Description Default value Example values
size The size for presenting widget in preview mode. small small, medium, large
background Background of the widget.
- Hex color
- Auto dark\light mode hex color
- Gradient list of colors with positions
- Image url
null #1b1b1b, #1b1b1b|#ebebeb, #1b1b1b:0.5,#1b1b4b:1, http://url.co/logo.png
padding Padding on each side of the widget (top, leading, bottom, trailing). null 10,10,10,10

Stack

Stack element shown in widget.

Props

Prop name Description Default value Example values
size The size of the stack. null 100,100
background Check Widget props.
layout Direction of the stack. null horizontal, vertical
align Alignment of elements inside stack. null start, center, end
cornerRadius Radius of the corners. 0 10
border Border width and color. null #1b1b4b:1

Text

Text element shown in a widget.

Props

Prop name Description Default value Example values
string Text to show. null Lorem ipsum.
size Font size. 16 14, 16, 18
weight Font weight. regular regular, bold
color Font color.
- Hex color
- Auto dark\light mode hex color
null #1b1b1b, #1b1b1b|#ebebeb
align Text alignment. Text can be aligned in both horizontal or vertical Stack, it is wrapped inside new Stack if some value is set. null left, center, right
opacity Text opacity. null 0.5

Spacer

Shows a spacer in the widget. A spacer with a null length has a flexible length.

Props

Prop name Description Default value Example values
length Size of the spacer. null 5, 10, 15

Image

Image element shown in widget.

Props

src, size, cornerRadius, opacity, align

Prop name Description Default value Example values
src Url of the image. null http://your-domain.com/logo.png
size Image size null 16,16, 32,32
cornerRadius Radius of the corners. 0 10
opacity Image opacity. null 0.5
align Image alignment. Image can be aligned in both horizontal or vertical Stack, it is wrapped inside new Stack if some value is set. null left, center, right
const widgetJSX = (() => {
const re = {
html: /<(?:"[^"]*"['"]*|'[^']*'['"]*|[^'">])+>/g,
attr: /([a-zA-Z0-9]+)="(.*?)"/g,
tag: /(<|<\/)([a-zA-Z][a-zA-Z0-9]*)([^>]*?)(\/>|>)/s,
color: /^(#(?:[0-9a-fA-F]{3}){1,2})$/s,
colors: /^(#(?:[0-9a-fA-F]{3}){1,2})\|(#(?:[0-9a-fA-F]{3}){1,2})$/s,
gradient: /(#(?:[0-9a-fA-F]{3}){1,2}):([+-]?\d+(\.\d+)?)/g,
url: /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/s,
}
const get = {
color: string => {
if (re.colors.test(string)) {
const [c1, c2] = string.split('|')
return Color.dynamic(new Color(c1), new Color(c2))
}
if (re.color.test(string)) {
return (new Color(string))
}
return null
},
}
const set = {
background: async (element, string, dark, light) => {
if (re.gradient.test(string)) {
const match = [...string.matchAll(/(#(?:[0-9a-fA-F]{3}){1,2}):([+-]?\d+(\.\d+)?)/g)]
const colors = match.map(v => ({ color: v[1], location: parseFloat(v[2]) }))
if (colors.length < 2) return null
const gradient = new LinearGradient()
gradient.locations = colors.map(v => v.location)
gradient.colors = colors.map(v => new Color(v.color))
element.backgroundGradient = gradient
return
}
if (re.url.test(string)) {
const req = new Request(string)
const image = await req.loadImage()
element.backgroundImage = image
return
}
const color = get.color(string)
if (color) element.backgroundColor = color
},
alignment: (align, parent, addElement) => {
if (align) {
const stack = parent.addStack()
stack.layoutHorizontally()
if (align !== 'left') stack.addSpacer()
const el = addElement(stack)
if (align !== 'right') stack.addSpacer()
return el
} else {
const el = addElement(parent)
return el
}
},
border: (element, string) => {
if (!re.gradient.test(string)) return
const match = [...string.matchAll(/(#(?:[0-9a-fA-F]{3}){1,2}):([+-]?\d+(\.\d+)?)/g)]
element.borderWidth = parseFloat(match[0][2])
element.borderColor = new Color(match[0][1])
},
}
const render = {
Widget: async ({ attributes, children }) => {
const { background = '#1b1b1b', padding, size = 'small' } = attributes
const widget = new ListWidget()
await set.background(widget, background)
if (padding) widget.setPadding(...padding.split(',').map(v => parseFloat(v)))
for (const child of children) {
if (render[child.tag]) await render[child.tag]({ parent: widget, ...child })
}
if (size === 'small') await widget.presentSmall()
if (size === 'medium') await widget.presentMedium()
if (size === 'large') await widget.presentLarge()
return widget
},
Stack: async ({ parent, attributes, children }) => {
const { layout, align, size, background, cornerRadius, border } = attributes
const stack = parent.addStack()
await set.background(stack, background)
if (size) stack.size = new Size(...size.split(',').map(v => parseFloat(v)))
if (cornerRadius) stack.cornerRadius = parseFloat(cornerRadius)
if (border) set.border(stack, border)
const renderChildren = async () => {
for (const child of children) {
if (render[child.tag]) await render[child.tag]({ parent: stack, ...child })
}
}
if (layout === 'vertical') {
stack.layoutVertically()
if (align === 'center') stack.centerAlignContent()
if (align === 'end') stack.bottomAlignContent()
if (align === 'start') stack.topAlignContent()
await renderChildren()
}
if (layout === 'horizontal') {
stack.layoutHorizontally()
if (align !== 'start') stack.addSpacer()
await renderChildren()
if (align !== 'end') stack.addSpacer()
}
return stack
},
Text: async ({ parent, attributes }) => {
const { string = '', size = 16, weight = 'regular', color, lines, align, opacity } = attributes
const text = set.alignment(align, parent, element => element.addText(string))
if (align) text[`${align}AlignText`]()
if (weight === 'regular') text.font = Font.regularSystemFont(parseFloat(size))
if (weight === 'bold') text.font = Font.boldSystemFont(parseFloat(size))
const clr = get.color(color)
if (clr) text.textColor = clr
if (opacity) text.textOpacity = parseFloat(opacity)
if (lines) text.lineLimit = parseInt(lines)
return text
},
Spacer: async ({ parent, attributes }) => {
const { length } = attributes
const spacer = parent.addSpacer(length && parseFloat(length))
return spacer
},
Image: async ({ parent, attributes }) => {
const { src, size, cornerRadius, opacity, align } = attributes
if (!src) return null
const req = new Request(src)
const img = await req.loadImage()
const image = set.alignment(align, parent, element => element.addImage(img))
if (size) image.imageSize = new Size(...size.split(',').map(v => parseFloat(v)))
if (opacity) image.imageOpacity = parseFloat(opacity)
if (cornerRadius) image.cornerRadius = parseFloat(cornerRadius)
return image
}
}
const init = async string => {
const getAttributes = input => {
const attrubutes = [...input.matchAll(re.attr)]
return attrubutes.reduce((acc, v) => ({ ...acc, [v[1]]: v[2] }), {})
}
const filter = (objects, depth = 0) => {
return !objects ? null : objects.map(item => {
if (item.depth > depth) return null
if (item.children) item.children = filter(item.children, item.depth + 1)
return item
}).filter(i => i !== null)
}
const removeDepth = objects => {
return objects.map(item => {
delete item.depthopearaopeop
if (item.children) item.children = removeDepth(item.children)
return item
})
}
const toArray = [...string.matchAll(re.html)].map(v => v[0])
const { content } = toArray.reduce(({ content, depth }, input) => {
const [, start, tag, attrs, end] = input.match(re.tag) || []
const attributes = getAttributes(attrs)
if (start === '<' && end !== '/>') {
content.push({ tag, children: [], attributes, depth: depth++ })
} else if (start === '<' && end === '/>') {
content.push({ tag, attributes, depth })
} else if (start === '</') {
depth -= 1
}
return { content, depth }
}, { content: [], depth: 0 })
content.forEach((item, index) => {
const sliced = content.slice(index + 1)
for (let i = 0; i < sliced.length; i++) {
if (sliced[i].depth > item.depth) item.children.push(sliced[i])
else break
}
})
const parsed = removeDepth(filter(content))
const widget = parsed[0]
widget.component = await render.Widget(widget)
Script.setWidget(widget.component)
Script.complete()
}
return init
})()
widgetJSX(`
<Widget size="medium" background="#1b1b1b:0.5,#1b1b4b:1" padding="10,10,10,10">
<Stack layout="horizontal" align="center">
<Image src="https://docs.scriptable.app/img/glyph.png" size="32,32" />
<Spacer length="15" />
<Stack layout="vertical" align="center">
<Text string="Hello World" />
<Text string="Scriptable JSX Widget" size="14" opacity="0.5" />
</Stack>
</Stack>
</Widget>
`)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment