├── Changelog.md
├── LICENSE
├── README.md
├── add-ons
├── html-widget-blockquote.js
├── html-widget-hr.js
├── html-widget-progress.js
└── html-widget-symbol.js
├── code
├── html-widget.js
└── html-widget.min.js
└── images
├── HelloWorldWidget.jpeg
├── RedditWidget.jpeg
├── code preview.png
└── logo.jpeg
/Changelog.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 6.3.0
4 |
5 | - 13.7 KB
6 | - The values used for filling undefined properties and attributes are now `null` rather than `"null"`
7 | - Return early on hex colours to improve render performance
8 | - Stop adding spaces with inline text and accents
9 | - Allow non `https://` URLs, like `scriptable:///run/script-name`
10 | - Allow `no-css` attribute to be camel case: `noCss`
11 | - Fix unknown attribute error
12 | - Maintain CSS order regardless of the child combiner (`>`)
13 | ```css
14 | widget > text {
15 | text-color: red;
16 | }
17 | text {
18 | text-color: blue;
19 | }
20 | /* Previously, text-color would be red. */
21 | /* Now, text-color is blue */
22 | ```
23 | - Allow most text properties (not `url`) to be cascading. These properties work with `date` tags too.
24 | ```html
25 |
26 | `)
108 | - Templates do not need to specify if they are self-closing (all tags technically are)
109 | - Boolean attributes need to have a value attached to them (including `children` and `no-css`)
110 | - Parser will not bug out and infinity run with an invalid HTML
111 |
112 | ## 5.11
113 |
114 | - 22.7 KB
115 | - Fixed background on stack, now allowing the `image` type
116 | - Fixed `>` selector in template tags
117 |
118 | ## 5.10
119 |
120 | - 22.6 KB
121 | - Added support for multiple classes in css and `tag.class`
122 | - Added the `>` selector to css
123 |
124 | ## 5.03
125 |
126 | - 21.9 KB
127 | - Instead of throwing an error, it will be ignored when a property is attempted to be applied to a tag and not in the mapping. Previously an error was raised, but that makes the `*` selector useless. This also makes it possible to share classes through different tags
128 | - An error text was fixed
129 |
130 | ## 5.02
131 |
132 | - 21.9 KB
133 | - Fixed a bug with the children of stack elements being forced on the widget, not the stack
134 | - Added commas to css selectors
135 | - Added validation for the image type
136 | - Improved validation for padding and url type
137 |
138 | ## 5.01
139 |
140 | - 21.5 KB
141 | - Improved error messages
142 | - Logged code is now indented to show nestled tags
143 | - Fixed an issue where you would need the addons parameter to run the function
144 | - Fixed some error messages to work with template tags
145 |
146 | ## 5.00
147 |
148 | - 21.1 KB
149 | - Many improvements and bug fixes
150 | - Errors display more information
151 | - Users can make tag templates
152 | - Most tag boolean attributes change
153 |
154 | ## 4.1
155 |
156 | * 17.9 KB
157 | * Improvements for the possibility of adding more add-ons (mostly those that use a canvas/DrawContext)
158 | * Improvements to other aspects of add-ons
159 | * Fixed a bug that would create an impropor css/attribute order
160 |
161 | ## 4.0
162 |
163 | * 17.6 KB (html-widget.min.js file)
164 | * Removed `symbol` and `hr` tag from normal script
165 | * Now allows custom tags (also called add-ons)
166 | * `symbol` and `hr` custom tag syntax can be found in their separate modules. All base tags and add-ons combined in the same code can be found in the [html-widget-expanded.js](https://github.com/Normal-Tangerine8609/Scriptable-HTML-Widget/blob/main/code/html-widget-expanded.js) file.
167 | * `symbol` `named` attribute changes to inner text
168 |
169 | ## 3.10
170 |
171 | * 17.2 KB
172 | * Fixed error messages to encompass properties as well as the attributes
173 | * Fixed boolean attributes and css boolean properties
174 | * Comments are now parsed into the debug code
175 | * Added support for pre-set fonts based on its contents like `caption1`
176 | * Added support for the `use-default-padding` attribute
177 | * Added degree directions and colour locations to the `background-gradient` attribute
178 |
179 | ## 3.00
180 |
181 | * 16 KB
182 | * Fixed image opacity
183 | * Added the `style` tag which allows setting default styles for tags with css
184 | * Added the `class` attribute
185 |
186 | ## 2.10
187 |
188 | * 14.2 KB
189 | * Removed logging of text when setting a gradient
190 | * Colours have now changed to support all HTML colours including hsl, hsla, rgb, rgba, hex and css colour names like `red`
191 | * Light mode/ dark mode colours have changed to be separated by hyphens instead of commas and do not need to go in brackets when in a gradient
192 |
193 | ## 2.00
194 |
195 | * 14.7 KB
196 | * Fixed a bug preventing line breaks in the text tag
197 | * Removed logged text when making a gradient
198 | * Gradients now support css directions (`to left`)
199 | * Added a `hr` element
200 | * Added a `date` element
201 |
202 | ## 1.11
203 |
204 | * 11.3 KB
205 | * Fixed URL setting on text
206 |
207 | ## 1.10
208 |
209 | * 11.3 KB
210 | * Added in pre-set fonts such as `lightRoundedSystemFont`
211 |
212 | ## 1.02
213 |
214 | * 10.9 KB
215 | * Fixed issue with stacks nestled in stacks
216 |
217 | ## 1.01
218 |
219 | * 10.9 KB
220 | * Fixed alpha values when they were not specified in a hex
221 |
222 | ## 1.00
223 |
224 | * 10.9 KB
225 | * First release of HTML Widget
226 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Normal-Tangerine8609
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HTML Widget
2 | 
3 |
4 | HTML Widget allows you to create [Scriptable](https://scriptable.app/) widgets in HTML-like syntax. HTML Widget is easy to use and supports all widget features.
5 |
6 | ## Documentation
7 |
8 | [Link](https://normal-tangerine8609.gitbook.io/html-widget/)
9 |
10 | ## Example
11 |
12 | 
13 |
14 | ```javascript
15 | const htmlWidget = importModule("html-widget")
16 | const symbol = importModule("html-widget-symbol")
17 | const addons = {symbol}
18 |
19 | let json = await new Request("https://www.reddit.com/r/Showerthoughts.json").loadJSON()
20 | let post = json["data"]["children"][Math.floor((Math.random() * 10) + 2)]["data"]
21 | let title = post["title"].replace(//g,">")
22 | let body = post["selftext"].replace(//g,">")
23 | let ups = post["ups"]
24 | let awards = post["all_awardings"].length
25 | let comments = post["num_comments"]
26 | let url = post["url"]
27 |
28 | let widget = await htmlWidget(`
29 |
27 | ${styles.width === null && "
50 | `)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/code/html-widget.js:
--------------------------------------------------------------------------------
1 | //HTML Widget Version 6.3.0
2 | //https://github.com/Normal-Tangerine8609/Scriptable-HTML-Widget
3 |
4 | async function htmlWidget(input, debug = false, addons = {}) {
5 | /*****************
6 | * MAIN PROGRAM
7 | *****************/
8 | let currentStack = "widget"
9 | let code = ""
10 | let incrementors = {}
11 | let gradientNumber = -1
12 | let indentLevel = 0
13 | const colorCache = new Map()
14 | const cascadingProperties = [
15 | "font",
16 | "lineLimit",
17 | "minimumScaleFactor",
18 | "shadowColor",
19 | "shadowOffset",
20 | "shadowRadius",
21 | "textColor",
22 | "textOpacity",
23 | "alignText"
24 | ]
25 |
26 | // Parse and locate the widget
27 | const {root, styleTags} = parseHTML(input)
28 | const widgetBody = root.children.find((e) => e.name == "widget")
29 | if (!widgetBody) {
30 | error("`widget` tag must be the root tag.")
31 | }
32 |
33 | // Combine style text and apply css to the widget
34 | const mainCss = createCss(styleTags.map((e) => e.innerText).join("\n"))
35 | applyCss(widgetBody, mainCss)
36 |
37 | // Generate types then compile the widget
38 | const types = getTypes()
39 | await compile(widgetBody)
40 | appendCodeLine("// Widget Complete")
41 |
42 | // Debug and create the widget
43 | if (debug) {
44 | console.log(code)
45 | }
46 | const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
47 | const runCode = new AsyncFunction(code + "\nreturn widget")
48 | return await runCode()
49 |
50 | /*********************
51 | * PARSE HTML + STYLES
52 | *********************/
53 |
54 | // Function to parse a HTML
55 | function parseHTML(string) {
56 | const styleTags = []
57 | const parser = new XMLParser(string)
58 |
59 | // Root of html
60 | const root = {
61 | name: "root",
62 | children: []
63 | }
64 | const stack = [root]
65 |
66 | // Store the new node and add it to the stack
67 | parser.didStartElement = (name, attrs) => {
68 | const node = {
69 | name,
70 | attrs,
71 | innerText: "",
72 | children: [],
73 | classList: [],
74 | noCss: false,
75 | putChildren: false
76 | }
77 |
78 | // Style tags are not part of the output tree
79 | if (name === "style") {
80 | styleTags.push(node)
81 | } else {
82 | stack.at(-1).children.push(node)
83 | }
84 | // Still need to add style tags to the stack
85 | stack.push(node)
86 | }
87 |
88 | // Add the inner text to the node
89 | parser.foundCharacters = (text) => {
90 | stack.at(-1).innerText += text
91 | }
92 |
93 | // Remove element from the stack and normalize its attributes
94 | parser.didEndElement = () => {
95 | const removed = stack.pop()
96 |
97 | const normalizedAttrs = {}
98 | for (const [attr, value] of Object.entries(removed.attrs)) {
99 | const normalizedAttrName = toCamelCase(attr)
100 | if (normalizedAttrName === "class") {
101 | removed.classList = removed.attrs.class.trim().split(/\s+/)
102 | } else if (normalizedAttrName === "noCss") {
103 | removed.noCss = true
104 | } else if (normalizedAttrName === "children") {
105 | removed.putChildren = true
106 | } else {
107 | const normalizedValue = value.trim()
108 | normalizedAttrs[normalizedAttrName] =
109 | normalizedValue === "null" ? null : normalizedValue
110 | }
111 | }
112 | removed.attrs = normalizedAttrs
113 |
114 | if (stack.length === 0) {
115 | error("Unexpected closing tag: <{}/>", removed.name)
116 | }
117 | }
118 |
119 | // Throw error on invalid input
120 | parser.parseErrorOccurred = (message) => {
121 | error("A parse error occurred: {}", message)
122 | }
123 |
124 | parser.parse()
125 | if (stack.length !== 1) {
126 | error(
127 | "A parse error occurred, ensure all tags are closed and attributes are properly formatted: <{}>",
128 | stack.at(-1).name
129 | )
130 | }
131 |
132 | return {root, styleTags}
133 | }
134 |
135 | // function to parse the selectors
136 | function parseSelector(input) {
137 | // ignore whitespace, split on '>', and filter out empties
138 | const segments = input
139 | .replace(/\s/g, "")
140 | .split(">")
141 | .filter((segment) => segment.length)
142 |
143 | return segments.map((segment) => {
144 | let tag
145 | let rest = segment
146 | if (!segment.startsWith(".")) {
147 | // There is a tag name
148 | const match = segment.match(/^([a-zA-Z][\w-]*|\*)/)
149 | if (match) {
150 | tag = match[1]
151 | rest = segment.slice(tag.length)
152 | } else {
153 | error("A css parse error occurred, invalid tag name: {}.", segment)
154 | }
155 | }
156 | // There can be multiple class names
157 | const classes = []
158 | let classMatch
159 | const CLASS_RE = /\.([-_a-zA-Z0-9]+)/g
160 | while ((classMatch = CLASS_RE.exec(rest))) {
161 | classes.push(classMatch[1])
162 | }
163 |
164 | return {tag, classes}
165 | })
166 | }
167 |
168 | // function to convert the css text into a object
169 | function createCss(cssText) {
170 | const css = []
171 | const RULE_RE = /([^{]+)\{([^}]+)\}/g
172 | let match
173 |
174 | // loop through each css rule
175 | while ((match = RULE_RE.exec(cssText))) {
176 | const selectorPart = match[1].trim()
177 | const declarationPart = match[2].trim()
178 |
179 | // parse declarations into { textColor: "red", font: "system-ui, 11"} form
180 | const declarations = declarationPart
181 | .split(";")
182 | .map((declaration) => declaration.trim())
183 | .filter((declaration) => declaration.length)
184 | .reduce((out, declaration) => {
185 | const [prop, ...rest] = declaration.split(":")
186 | const key = toCamelCase(prop.trim())
187 | const value = rest.join(":").trim()
188 | out[key] = value === "null" ? null : value
189 | return out
190 | }, {})
191 |
192 | // for each comma-separated selector, add it to the css
193 | selectorPart
194 | .split(",")
195 | .map((selector) => selector.trim())
196 | .forEach((rawSelector) => {
197 | css.push({
198 | selector: parseSelector(rawSelector),
199 | css: declarations
200 | })
201 | })
202 | }
203 |
204 | return css
205 | }
206 |
207 | // function to add the css to each element and children elements
208 | function applyCss(root, css) {
209 | traverse(root, css, {})
210 |
211 | // css is the full parsed css, the inheritedRules are css rules that have been partially matched by the parent
212 | function traverse(node, css, cascadingCss) {
213 | const cascadingPropertiesObject = {...cascadingCss}
214 | // cascading properties always matches, but has the lowest priority
215 | const matchingRules = [{selector: "*", css: cascadingPropertiesObject}]
216 | const nextRules = []
217 |
218 | // get the matching rules, and the rules for the children nodes
219 | for (const rule of css) {
220 | // rules that have been partially matched do not extend to children nodes
221 | if (!rule.partial) {
222 | nextRules.push(rule)
223 | }
224 | const [segment, ...rest] = rule.selector
225 | if (matchesSegment(node, segment)) {
226 | if (rest.length) {
227 | nextRules.push({selector: rest, css: rule.css, partial: true})
228 | } else {
229 | for (const property in rule.css) {
230 | if (cascadingProperties.includes(property)) {
231 | cascadingPropertiesObject[property] = rule.css[property]
232 | }
233 | }
234 | matchingRules.push(rule)
235 | }
236 | }
237 | }
238 |
239 | // apply the matching css
240 | node.css = matchingRules
241 |
242 | // recurse with the children nodes
243 | for (const child of node.children || []) {
244 | traverse(child, nextRules, cascadingPropertiesObject)
245 | }
246 | }
247 | }
248 |
249 | // Checks if a node matches a segment
250 | function matchesSegment(node, {tag, classes}) {
251 | return (
252 | (!tag || tag === "*" || node.name === tag) &&
253 | classes.every((c) => node.classList.includes(c))
254 | )
255 | }
256 | /*********************
257 | * COMPILE THE WIDGET
258 | ********************/
259 |
260 | async function compile(tag) {
261 | // Throw an error if there is a nestled widget tag
262 | if (tag.name == "widget" && code) {
263 | error("`widget` tag must not be nestled.")
264 | }
265 |
266 | // Add a new line spacing before tags
267 | if (code) {
268 | appendCodeLine("")
269 | }
270 |
271 | // Increment incrementor
272 | if (tag.name in incrementors) {
273 | incrementors[tag.name] += 1
274 | } else {
275 | incrementors[tag.name] = 0
276 | }
277 | const incrementor = incrementors[tag.name]
278 |
279 | // Get innerText
280 | const innerText = tag.innerText
281 | .replace(/</g, "<")
282 | .replace(/>/g, ">")
283 | .replace(/&/g, "&")
284 | .replace(/\n\s+/g, "\\n")
285 |
286 | // get the css
287 | const finalCss = mergeCssRules(tag.css, tag.attrs, tag.noCss)
288 |
289 | // object for data pertaining to each widget element
290 | const handlers = {
291 | widget: {
292 | mapping: {
293 | background: ["gradient", "image", "colour"],
294 | refreshAfterDate: "posInt",
295 | spacing: "posInt",
296 | url: "url",
297 | padding: "padding",
298 | addAccessoryWidgetBackground: "bool"
299 | },
300 | hasChildren: true,
301 | instantiate() {
302 | appendCodeLine(`let widget = new ListWidget()`)
303 | return "widget"
304 | },
305 | async applyStyles(onVar) {
306 | const {background, ...rest} = finalCss
307 | const kind = background in tag.attrs ? "attribute" : "property"
308 | await applyBackground("widget", kind, background)
309 | await applyStandardStyles(onVar, rest, this.mapping)
310 | }
311 | },
312 |
313 | stack: {
314 | mapping: {
315 | background: ["gradient", "image", "colour"],
316 | spacing: "posInt",
317 | url: "url",
318 | padding: "padding",
319 | borderColor: "colour",
320 | borderWidth: "posInt",
321 | size: "size",
322 | cornerRadius: "posInt",
323 | alignContent: "alignContent",
324 | layout: "layout"
325 | },
326 | hasChildren: true,
327 | instantiate() {
328 | appendCodeLine(`let stack${incrementor} = ${currentStack}.addStack()`)
329 | return `stack${incrementor}`
330 | },
331 | async applyStyles(onVar) {
332 | const {background, ...rest} = finalCss
333 | const kind = background in tag.attrs ? "attribute" : "property"
334 | await applyBackground(onVar, kind, background)
335 | await applyStandardStyles(onVar, rest, this.mapping)
336 | }
337 | },
338 |
339 | spacer: {
340 | mapping: {space: "posInt"},
341 | hasChildren: false,
342 | instantiate() {
343 | const space = tag.attrs.space ?? ""
344 | appendCodeLine(
345 | `let spacer${incrementor} = ${currentStack}.addSpacer(${space})`
346 | )
347 | return null
348 | }
349 | // No styles to apply
350 | },
351 |
352 | text: {
353 | mapping: {
354 | url: "url",
355 | font: "font",
356 | lineLimit: "posInt",
357 | minimumScaleFactor: "decimal",
358 | shadowColor: "colour",
359 | shadowOffset: "point",
360 | shadowRadius: "posInt",
361 | textColor: "colour",
362 | textOpacity: "decimal",
363 | alignText: "alignText"
364 | },
365 | hasChildren: false,
366 | instantiate() {
367 | appendCodeLine(
368 | `let text${incrementor} = ${currentStack}.addText("${innerText.replace(
369 | /"/g,
370 | ""
371 | )}")`
372 | )
373 | return `text${incrementor}`
374 | },
375 | async applyStyles(onVar) {
376 | await applyStandardStyles(onVar, finalCss, this.mapping)
377 | }
378 | },
379 |
380 | date: {
381 | mapping: {
382 | url: "url",
383 | font: "font",
384 | lineLimit: "posInt",
385 | minimumScaleFactor: "decimal",
386 | shadowColor: "colour",
387 | shadowOffset: "point",
388 | shadowRadius: "posInt",
389 | textColor: "colour",
390 | textOpacity: "decimal",
391 | alignText: "alignText",
392 | applyStyle: "applyStyle"
393 | },
394 | hasChildren: false,
395 | instantiate() {
396 | appendCodeLine(
397 | `let date${incrementor} = ${currentStack}.addDate(new Date("${innerText.replace(
398 | /"/g,
399 | ""
400 | )}"))`
401 | )
402 | return `date${incrementor}`
403 | },
404 | async applyStyles(onVar) {
405 | await applyStandardStyles(onVar, finalCss, this.mapping)
406 | }
407 | },
408 |
409 | img: {
410 | mapping: {
411 | src: "image",
412 | url: "url",
413 | borderColor: "colour",
414 | borderWidth: "posInt",
415 | cornerRadius: "posInt",
416 | imageSize: "size",
417 | imageOpacity: "decimal",
418 | tintColor: "colour",
419 | resizable: "bool",
420 | containerRelativeShape: "bool",
421 | contentMode: "contentMode",
422 | alignImage: "alignImage"
423 | },
424 | hasChildren: false,
425 | instantiate() {
426 | let image
427 |
428 | // Throw an error if there is no src attribute
429 | if (!tag.attrs.src) {
430 | error("`img` tag must have a `src` attribute.")
431 | }
432 |
433 | // Determine if the image is a URL or base encoding
434 | if (tag.attrs.src.startsWith("data:image/")) {
435 | image = `Image.fromData(Data.fromBase64String("${tag.attrs.src
436 | .replace(/data:image\/.*?;base64,/, "")
437 | .replace(/"/g, "")}"))`
438 | } else {
439 | image = `await new Request("${tag.attrs.src.replace(
440 | /"/g,
441 | ""
442 | )}").loadImage()`
443 | }
444 |
445 | appendCodeLine(
446 | `let img${incrementor} = ${currentStack}.addImage(${image})`
447 | )
448 | return `img${incrementor}`
449 | },
450 | async applyStyles(onVar) {
451 | const {src, ...rest} = finalCss
452 | await applyStandardStyles(onVar, rest, this.mapping)
453 | }
454 | }
455 | }
456 |
457 | // get the element we are dealing with
458 | const handler = handlers[tag.name]
459 |
460 | // compile the addon
461 | if (!handler) {
462 | // throw an error if it is not a valid addon
463 | if (!(tag.name in addons)) {
464 | error("Invalid tag name: `{}`.", tag.name)
465 | }
466 |
467 | appendCodeLine(`// <${tag.name}>`)
468 |
469 | // grab the addon
470 | const addon = addons[tag.name]
471 | const mapping = addon.mapping || {}
472 |
473 | // set up the css
474 | validateAll(tag.attrs, finalCss, mapping)
475 | for (let key in mapping) {
476 | if (!(key in finalCss)) {
477 | finalCss[key] = null
478 | }
479 | if (!(key in tag.attrs)) {
480 | tag.attrs[key] = null
481 | }
482 | }
483 |
484 | // render the addon
485 | const template = (input) => renderTemplate(input, tag.children)
486 | await addon.render(template, finalCss, tag.attrs, innerText)
487 | appendCodeLine(`// ${tag.name}>`)
488 | return
489 | }
490 |
491 | // instantiate element and validate styles and attributes
492 | const onVar = handler.instantiate()
493 | validateAll(tag.attrs, finalCss, handler.mapping)
494 |
495 | // apply styles, if there are any
496 | await handler.applyStyles?.(onVar)
497 |
498 | // compile children with indention
499 | if (handler.hasChildren) {
500 | indentLevel++
501 | const prevStack = currentStack
502 | currentStack = onVar
503 |
504 | for (const child of tag.children) {
505 | await compile(child)
506 | }
507 |
508 | indentLevel--
509 | currentStack = prevStack
510 | }
511 | }
512 |
513 | // create the final css, merging the matching css rules and attribute css
514 | function mergeCssRules(rules = [], attributeCss, noCss) {
515 | if (noCss) return {...attributeCss}
516 | const fromRules = {}
517 | for (const {css} of rules) {
518 | Object.assign(fromRules, css)
519 | }
520 | return {...fromRules, ...attributeCss}
521 | }
522 |
523 | // apply styles on a widget element
524 | async function applyStandardStyles(onVar, finalCss, mapping) {
525 | for (const [key, value] of Object.entries(finalCss)) {
526 | if (value === null) continue
527 | const typeKey = mapping[key]
528 | await types[typeKey].add(key, value, onVar)
529 | }
530 | }
531 |
532 | // special function for background, since it can be an image, colour, or gradient
533 | async function applyBackground(onVar, kind, value) {
534 | if (!value) return
535 | try {
536 | types.url.validate("background", "attribute", value)
537 | types.image.add("background", value, onVar)
538 | } catch {
539 | // gradient vs solid color
540 | if (
541 | value.split(
542 | /,(?![^(]*\))(?![^"']*["'](?:[^"']*["'][^"']*["'])*[^"']*$)/
543 | ).length === 1
544 | ) {
545 | await types.colour.add("backgroundColor", value, onVar)
546 | } else {
547 | // Need to revalidate because the validation is always true when validating for the array of types since background passes the colour type
548 | types.gradient.validate("backgroundGradient", kind, value)
549 | await types.gradient.add("backgroundGradient", value, onVar)
550 | }
551 | }
552 | }
553 |
554 | // Function to add the no-css property to all children and put the tag children into the template
555 | function mergeChildren(templateNode, children) {
556 | templateNode.noCss = true
557 | for (let child of templateNode.children || []) {
558 | mergeChildren(child, children)
559 | }
560 | if (templateNode.putChildren) {
561 | templateNode.children.push(...children)
562 | }
563 | }
564 |
565 | // Function to add the raw html of the addon to the widget
566 | async function renderTemplate(input, children) {
567 | // parse the template and ignore style tags
568 | let {root} = parseHTML(input)
569 |
570 | // run through all children to determine where to put the tag children and add the no-css property
571 | root.children.forEach((node) => mergeChildren(node, children))
572 |
573 | // compile the template
574 | indentLevel++
575 | const prevStack = currentStack
576 |
577 | for (let child of root.children) {
578 | currentStack = prevStack
579 | await compile(child)
580 | }
581 |
582 | indentLevel--
583 | }
584 |
585 | function validateAll(attributeCss, finalCss, mapping) {
586 | validate(attributeCss, mapping, "attribute")
587 | validate(finalCss, mapping, "property")
588 | }
589 |
590 | // Function that validates css
591 | function validate(entries, mapping, kind) {
592 | for (const [key, value] of Object.entries(entries)) {
593 | // get the expected type of the property or attribute
594 | const expected = mapping[key]
595 | if (!expected) {
596 | if (kind === "property") {
597 | delete entries[key]
598 | continue
599 | }
600 | // we only error on invalid attributes because of the "*" or other similar selectors
601 | error("Unknown attribute: `{}`.", key)
602 | }
603 |
604 | // skip null placeholders
605 | if (value === null) continue
606 |
607 | // validate one type
608 | if (!Array.isArray(expected)) {
609 | types[expected].validate(key, kind, value)
610 | continue
611 | }
612 |
613 | // validate over an array of types
614 | const passed = expected.some((typeName) => {
615 | try {
616 | types[typeName].validate(key, kind, value)
617 | return true
618 | } catch {
619 | return false
620 | }
621 | })
622 |
623 | if (!passed) {
624 | const list = expected.join(", ").replace(/,([^,]+)$/, " or$1")
625 | error("`{}` {} must be {} type: `{}`", key, kind, list, value)
626 | }
627 | }
628 | }
629 |
630 | /******************
631 | * HELPER FUNCTIONS
632 | ******************/
633 |
634 | function appendCodeLine(line) {
635 | const prefix = " ".repeat(indentLevel)
636 | code += `\n${prefix + line}`
637 | }
638 |
639 | function upperCaseFirstChar(value) {
640 | return value[0].toUpperCase() + value.slice(1)
641 | }
642 |
643 | // Function to get any html supported color, colours are memorized to speed up reusing same colours
644 | async function colorFromValue(c) {
645 | if (colorCache.has(c)) return colorCache.get(c)
646 |
647 | // Hex colours are supported by scriptable
648 | if (/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(c)) {
649 | return `new Color("${c}")`
650 | }
651 |
652 | let w = new WebView()
653 | await w.loadHTML(`
--------\n${code}\n--------
--------\n${message}`
682 | )
683 | }
684 |
685 | /******************
686 | * PRIMITIVE TYPES
687 | ******************/
688 | function getTypes() {
689 | return {
690 | colour: {
691 | async add(attribute, value, on) {
692 | const [first, second] = value.split("-").map((c) => c.trim())
693 | let colour
694 | // colour can be in light-dark format
695 | if (second != null) {
696 | const [scriptableFirst, scriptableSecond] = await Promise.all([
697 | colorFromValue(first),
698 | colorFromValue(second)
699 | ])
700 | colour = `Color.dynamic(${scriptableFirst}, ${scriptableSecond})`
701 | } else {
702 | colour = await colorFromValue(first)
703 | }
704 | appendCodeLine(`${on}.${attribute} = ${colour}`)
705 | },
706 | validate() {}
707 | },
708 | posInt: {
709 | add(attribute, value, on) {
710 | if (attribute === "refreshAfterDate") {
711 | appendCodeLine(
712 | `${on}.refreshAfterDate = new Date(Date.now() + ${value} * 60000)`
713 | )
714 | } else {
715 | appendCodeLine(`${on}.${attribute} = ${value}`)
716 | }
717 | },
718 | validate(attribute, kind, value) {
719 | if (!/^\d+$/.test(value)) {
720 | error(
721 | "`{}` {} must be a positive integer: `{}`.",
722 | attribute,
723 | kind,
724 | value
725 | )
726 | }
727 | }
728 | },
729 | decimal: {
730 | add(attribute, value, on) {
731 | let number = parseFloat(value.replace("%", ""))
732 | if (value.endsWith("%")) {
733 | number = number / 100
734 | }
735 | appendCodeLine(`${on}.${attribute} = ${number}`)
736 | },
737 | validate(attribute, kind, value) {
738 | const isPercent = value.endsWith("%")
739 | const raw = isPercent ? value.slice(0, -1) : value
740 | const number = parseFloat(raw)
741 |
742 | // reject if parsing failed, or is negative
743 | if (!Number.isFinite(number) || number < 0) {
744 | error(
745 | "`{}` {} must be a positive integer or float with an optional `%` at the end: `{}`.",
746 | attribute,
747 | kind,
748 | value
749 | )
750 | }
751 | }
752 | },
753 | gradient: {
754 | async add(_, value, on) {
755 | gradientNumber++
756 |
757 | // split into parts by commas not in quotes or brackets
758 | let splitGradient = value
759 | .split(/,(?![^(]*\))(?![^"']*["'](?:[^"']*["'][^"']*["'])*[^"']*$)/)
760 | .map((e) => e.trim())
761 |
762 | // get the direction from the first item of gradient
763 | let gradientDirection
764 | const wordDirections = {
765 | "to top": 0,
766 | "to top right": 45,
767 | "to right": 90,
768 | "to bottom right": 135,
769 | "to bottom": 180,
770 | "to bottom left": 225,
771 | "to left": 270,
772 | "to top left": 315
773 | }
774 | // check if it is a word direction, degrees direction or none are provided
775 | const first = splitGradient[0].toLowerCase()
776 | if (first in wordDirections) {
777 | splitGradient.shift()
778 | gradientDirection = wordDirections[first]
779 | } else if (/\d+\s*deg/.test(first)) {
780 | splitGradient.shift()
781 | gradientDirection = Number(first.match(/(\d+)\s*deg/)[1])
782 | } else {
783 | gradientDirection = 0
784 | }
785 |
786 | // Get colours and locations
787 | const colours = []
788 | const locations = []
789 | for (const part of splitGradient) {
790 | // Get the location
791 | const locationMatch = part.match(/\s+(\d+(?:\.\d+)?%?)$/)
792 | let location = null
793 | let colorPart = part
794 |
795 | if (locationMatch) {
796 | const rawLocation = locationMatch[1]
797 |
798 | // Locations ending in % are percentages
799 | if (rawLocation.endsWith("%")) {
800 | location = Number(rawLocation.slice(0, -1)) / 100
801 | } else {
802 | location = Number(rawLocation)
803 | }
804 |
805 | colorPart = part.slice(0, locationMatch.index).trim()
806 | }
807 | locations.push(location)
808 |
809 | // Get the colour of the part
810 | let color
811 | const [first, second] = colorPart.split("-")
812 | if (second != null) {
813 | const [scriptableFirst, scriptableSecond] = await Promise.all([
814 | colorFromValue(first),
815 | colorFromValue(second)
816 | ])
817 | color = `Color.dynamic(${scriptableFirst}, ${scriptableSecond})`
818 | } else {
819 | color = await colorFromValue(first)
820 | }
821 | colours.push(color)
822 | }
823 |
824 | // Set default first and last locations
825 | if (locations[0] === null) {
826 | locations[0] = 0
827 | }
828 | if (locations.at(-1) === null) {
829 | locations[locations.length - 1] = 1
830 | }
831 |
832 | // Set not specified locations
833 | for (let i = 0; i < locations.length; i++) {
834 | let currentLocation = locations[i]
835 |
836 | // get next non-null location index
837 | let index = i + 1
838 | while (index < locations.length && locations[index] === null) {
839 | index++
840 | }
841 | // calculate the difference between each null location for a linear transition
842 | let difference = (locations[index] - locations[i]) / (index - i)
843 |
844 | // set each between null location
845 | for (let plusIndex = 1; plusIndex < index - i; plusIndex++) {
846 | locations[i + plusIndex] =
847 | difference * plusIndex + currentLocation
848 | }
849 | }
850 |
851 | // calculate gradient points based on the direction
852 | const rad = (Math.PI * (gradientDirection - 90)) / 180
853 | const cos = Math.cos(rad)
854 | const sin = Math.sin(rad)
855 | const t = 0.5 / Math.max(Math.abs(cos), Math.abs(sin))
856 | const x1 = 0.5 - t * cos
857 | const y1 = 0.5 - t * sin
858 | const x2 = 0.5 + t * cos
859 | const y2 = 0.5 + t * sin
860 |
861 | appendCodeLine(`let gradient${gradientNumber} = new LinearGradient()`)
862 | appendCodeLine(`gradient${gradientNumber}.colors = [${colours}]`)
863 | appendCodeLine(`gradient${gradientNumber}.locations = [${locations}]`)
864 | appendCodeLine(
865 | `gradient${gradientNumber}.startPoint = ${`new Point(${x1}, ${y1})`}`
866 | )
867 | appendCodeLine(
868 | `gradient${gradientNumber}.endPoint = ${`new Point(${x2}, ${y2})`}`
869 | )
870 | appendCodeLine(`${on}.backgroundGradient = gradient${gradientNumber}`)
871 | },
872 |
873 | validate(attribute, kind, value) {
874 | // split into parts by commas not in quotes or brackets
875 | let splitGradient = value
876 | .split(/,(?![^(]*\))(?![^"']*["'](?:[^"']*["'][^"']*["'])*[^"']*$)/)
877 | .map((e) => e.trim())
878 |
879 | // remove the gradient direction
880 | const wordDirections = [
881 | "to top",
882 | "to top right",
883 | "to right",
884 | "to bottom right",
885 | "to bottom",
886 | "to bottom left",
887 | "to left",
888 | "to top left"
889 | ]
890 | if (
891 | wordDirections.includes(splitGradient[0].toLowerCase()) ||
892 | /\d+\s*deg/.test(splitGradient[0])
893 | ) {
894 | splitGradient.shift()
895 | }
896 | if (splitGradient.length < 2) {
897 | error(
898 | "`{}` {} must have at least two color stops after the direction: `{}`.",
899 | attribute,
900 | kind,
901 | value
902 | )
903 | }
904 |
905 | // validate locations
906 | let greatest = -Infinity
907 | for (const part of splitGradient) {
908 | // Get the location
909 | const locationMatch = part.match(/\s+(\d+(?:\.\d+)?%?)$/)
910 | if (locationMatch) {
911 | const rawLocation = locationMatch[1]
912 | // Locations ending in % are percentages
913 | const location = rawLocation.endsWith("%")
914 | ? Number(rawLocation.slice(0, -1)) / 100
915 | : Number(rawLocation)
916 | if (!Number.isFinite(location)) {
917 | error(
918 | "`{}` {} has invalid position: `{}` in `{}`.",
919 | attribute,
920 | kind,
921 | rawLocation,
922 | value
923 | )
924 | }
925 | if (location < 0 || location > 1) {
926 | error(
927 | "`{}` {} position must be between `0` and `1`: `{}`.",
928 | attribute,
929 | kind,
930 | rawLocation
931 | )
932 | }
933 | if (location < greatest) {
934 | error(
935 | "`{}` {} color-stop positions must be in ascending order: `{}`.",
936 | attribute,
937 | kind,
938 | value
939 | )
940 | }
941 | greatest = location
942 | }
943 | }
944 | }
945 | },
946 | padding: {
947 | add(_, value, on) {
948 | if (value === "default") {
949 | appendCodeLine(`${on}.useDefaultPadding()`)
950 | return
951 | }
952 | const numbers = value.split(",").map((s) => parseInt(s.trim(), 10))
953 |
954 | // CSS shorthand rules: [top, right?, bottom?, left?]
955 | let [t, r, b, l] = numbers
956 | if (numbers.length === 1) [t, r, b, l] = [t, t, t, t]
957 | else if (numbers.length === 2) [t, r, b, l] = [t, r, t, r]
958 | else if (numbers.length === 3) [t, r, b, l] = [t, r, b, r]
959 |
960 | appendCodeLine(`${on}.setPadding(${t}, ${r}, ${b}, ${l})`)
961 | },
962 | validate(attribute, kind, value) {
963 | if (value === "default") return
964 |
965 | const parts = value.split(",").map((s) => s.trim())
966 | if (parts.length < 1 || parts.length > 4) {
967 | error(
968 | "`{}` {} must have 1-4 comma-separated positive integers or be `default`: `{}`.",
969 | attribute,
970 | kind,
971 | value
972 | )
973 | }
974 |
975 | for (let part of parts) {
976 | if (!/^\d+$/.test(part)) {
977 | error(
978 | "`{}` {} must be non-negative integers: `{}` contains `{}`.",
979 | attribute,
980 | kind,
981 | value,
982 | part
983 | )
984 | }
985 | }
986 | }
987 | },
988 | size: {
989 | add(attribute, value, on) {
990 | let [width, height] = value.split(",")
991 | appendCodeLine(`${on}.${attribute} = new Size(${width}, ${height})`)
992 | },
993 | validate(attribute, kind, value) {
994 | if (!/^\d+\s*,\s*\d+$/.test(value)) {
995 | error(
996 | "`{}` {} must be 2 positive integers separated by commas: `{}`.",
997 | attribute,
998 | kind,
999 | value
1000 | )
1001 | }
1002 | }
1003 | },
1004 | font: {
1005 | add(_, value, on) {
1006 | let fontRegex =
1007 | /^(((black|bold|medium|light|heavy|regular|semibold|thin|ultraLight)(MonospacedSystemFont|RoundedSystemFont|SystemFont)\s*,\s*(\d+))|(body|callout|caption1|caption2|footnote|subheadline|headline|largeTitle|title1|title2|title3)|((italicSystemFont)\s*,\s*(\d+)))$/
1008 | if (fontRegex.test(value)) {
1009 | appendCodeLine(
1010 | `${on}.font = Font.${value.replace(fontRegex, "$3$4$6$8($5$9)")}`
1011 | )
1012 | } else {
1013 | const [name, size] = value.split(",")
1014 | appendCodeLine(
1015 | `${on}.font = new Font("${name.replace(/"/g, "")}",${
1016 | size.match(/\d+/g)[0]
1017 | })`
1018 | )
1019 | }
1020 | },
1021 | validate(attribute, kind, value) {
1022 | if (
1023 | !/^[^,]+,\s*\d+$/.test(value) &&
1024 | ![
1025 | "body",
1026 | "callout",
1027 | "caption1",
1028 | "caption2",
1029 | "footnote",
1030 | "subheadline",
1031 | "headline",
1032 | "italicSystemFont",
1033 | "largeTitle",
1034 | "title1",
1035 | "title2",
1036 | "title3"
1037 | ].includes(value)
1038 | ) {
1039 | error(
1040 | "`{}` {} must be 1 font name and 1 positive integer separated by commas or be a content-based font: `{}`.",
1041 | attribute,
1042 | kind,
1043 | value
1044 | )
1045 | }
1046 | }
1047 | },
1048 | point: {
1049 | add(_, value, on) {
1050 | const [x, y] = value.split(",")
1051 | appendCodeLine(`${on}.shadowOffset = new Point(${x},${y})`)
1052 | },
1053 | validate(attribute, kind, value) {
1054 | if (!/^-?\d+\s*,\s*-?\d+$/.test(value)) {
1055 | error(
1056 | "`{}` {} must be 2 integers separated by commas: `{}`.",
1057 | attribute,
1058 | kind,
1059 | value
1060 | )
1061 | }
1062 | }
1063 | },
1064 | bool: {
1065 | add(attribute, value, on) {
1066 | if (value === "false") {
1067 | return
1068 | }
1069 | if (attribute === "resizable") {
1070 | appendCodeLine(`${on}.resizable = false`)
1071 | } else {
1072 | appendCodeLine(`${on}.${attribute} = true`)
1073 | }
1074 | },
1075 | validate() {}
1076 | },
1077 | url: {
1078 | add: (_, value, on) => {
1079 | appendCodeLine(`${on}.url = "${value.replace(/"/g, "")}"`)
1080 | },
1081 | validate(attribute, kind, value) {
1082 | if (!/^\w+:\/\/\S+$/.test(value)) {
1083 | error("`{}` {} must be a valid URL: `{}.`", attribute, kind, value)
1084 | }
1085 | }
1086 | },
1087 | image: {
1088 | add(_, value, on) {
1089 | if (value.startsWith("data:image/")) {
1090 | const base64 = value.split(",", 2)[1].replace(/"/g, "")
1091 | appendCodeLine(
1092 | `${on}.backgroundImage = Image.fromData(Data.fromBase64String("${base64}"))`
1093 | )
1094 | } else {
1095 | appendCodeLine(
1096 | `${on}.backgroundImage = await new Request("${value.replace(
1097 | /"/g,
1098 | ""
1099 | )}").loadImage()`
1100 | )
1101 | }
1102 | },
1103 | validate(attribute, kind, value) {
1104 | if (
1105 | !/^(https?:\/\/\S+|data:image\/\w+?;base64,[a-zA-Z0-9+/]+=*)$/.test(
1106 | value
1107 | )
1108 | ) {
1109 | error(
1110 | "`{}` {} must be a valid url or base encoded data link: `{}`.",
1111 | attribute,
1112 | kind,
1113 | value
1114 | )
1115 | }
1116 | }
1117 | },
1118 | layout: {
1119 | add(_, value, on) {
1120 | appendCodeLine(`${on}.layout${upperCaseFirstChar(value)}()`)
1121 | },
1122 | validate(attribute, kind, value) {
1123 | if (value !== "vertically" && value !== "horizontally") {
1124 | error(
1125 | "`{}` {} must be `vertically` or `horizontally`: `{}`.",
1126 | attribute,
1127 | kind,
1128 | value
1129 | )
1130 | }
1131 | }
1132 | },
1133 | alignText: {
1134 | add(_, value, on) {
1135 | appendCodeLine(`${on}.${value}AlignText()`)
1136 | },
1137 | validate(attribute, kind, value) {
1138 | if (!["center", "left", "right"].includes(value)) {
1139 | error(
1140 | "`{}` {} must be `left`, `right` or `center`: `{}.`",
1141 | attribute,
1142 | kind,
1143 | value
1144 | )
1145 | }
1146 | }
1147 | },
1148 | alignImage: {
1149 | add(_, value, on) {
1150 | appendCodeLine(`${on}.${value}AlignImage()`)
1151 | },
1152 | validate(attribute, kind, value) {
1153 | if (!["center", "left", "right"].includes(value)) {
1154 | error(
1155 | "`{}` {} must be `left`, `right` or `center`: `{}`.",
1156 | attribute,
1157 | kind,
1158 | value
1159 | )
1160 | }
1161 | }
1162 | },
1163 | alignContent: {
1164 | add(_, value, on) {
1165 | appendCodeLine(`${on}.${value}AlignContent()`)
1166 | },
1167 | validate(attribute, kind, value) {
1168 | if (!["center", "top", "bottom"].includes(value)) {
1169 | error(
1170 | "`{}` {} must be `top`, `bottom` or `center`: `{}`.",
1171 | attribute,
1172 | kind,
1173 | value
1174 | )
1175 | }
1176 | }
1177 | },
1178 | applyStyle: {
1179 | add(_, value, on) {
1180 | appendCodeLine(`${on}.apply${upperCaseFirstChar(value)}Style()`)
1181 | },
1182 | validate(attribute, kind, value) {
1183 | if (
1184 | !["date", "timer", "offset", "relative", "time"].includes(value)
1185 | ) {
1186 | error(
1187 | "`{}` {} must be `date`, `timer` , `relative`, `time`, or `offset`: `{}`.",
1188 | attribute,
1189 | kind,
1190 | value
1191 | )
1192 | }
1193 | }
1194 | },
1195 | contentMode: {
1196 | add(_, value, on) {
1197 | appendCodeLine(`${on}.apply${upperCaseFirstChar(value)}ContentMode()`)
1198 | },
1199 | validate(attribute, kind, value) {
1200 | if (!["filling", "fitting"].includes(value)) {
1201 | error(
1202 | "`{}` {} must be `filling` or `fitting`: `{}`.",
1203 | attribute,
1204 | kind,
1205 | value
1206 | )
1207 | }
1208 | }
1209 | }
1210 | }
1211 | }
1212 | }
1213 |
1214 | module.exports = htmlWidget
1215 |
--------------------------------------------------------------------------------
/code/html-widget.min.js:
--------------------------------------------------------------------------------
1 | async function htmlWidget(t,e=!1,a={}){let n="widget",i="",o={},s=-1,r=0;const l=new Map,c=["font","lineLimit","minimumScaleFactor","shadowColor","shadowOffset","shadowRadius","textColor","textOpacity","alignText"],{root:d,styleTags:p}=f(t),u=d.children.find((t=>"widget"==t.name));u||T("`widget` tag must be the root tag.");!function(t,e){!function t(e,a,n){const i={...n},o=[{selector:"*",css:i}],s=[];for(const t of a){t.partial||s.push(t);const[a,...n]=t.selector;if($(e,a))if(n.length)s.push({selector:n,css:t.css,partial:!0});else{for(const e in t.css)c.includes(e)&&(i[e]=t.css[e]);o.push(t)}}e.css=o;for(const a of e.children||[])t(a,s,i)}(t,e,{})}(u,function(t){const e=[],a=/([^{]+)\{([^}]+)\}/g;let n;for(;n=a.exec(t);){const t=n[1].trim(),a=n[2].trim().split(";").map((t=>t.trim())).filter((t=>t.length)).reduce(((t,e)=>{const[a,...n]=e.split(":"),i=A(a.trim()),o=n.join(":").trim();return t[i]="null"===o?null:o,t}),{});t.split(",").map((t=>t.trim())).forEach((t=>{e.push({selector:h(t),css:a})}))}return e}(p.map((t=>t.innerText)).join("\n")));const g={colour:{async add(t,e,a){const[n,i]=e.split("-").map((t=>t.trim()));let o;if(null!=i){const[t,e]=await Promise.all([k(n),k(i)]);o=`Color.dynamic(${t}, ${e})`}else o=await k(n);I(`${a}.${t} = ${o}`)},validate(){}},posInt:{add(t,e,a){I("refreshAfterDate"===t?`${a}.refreshAfterDate = new Date(Date.now() + ${e} * 60000)`:`${a}.${t} = ${e}`)},validate(t,e,a){/^\d+$/.test(a)||T("`{}` {} must be a positive integer: `{}`.",t,e,a)}},decimal:{add(t,e,a){let n=parseFloat(e.replace("%",""));e.endsWith("%")&&(n/=100),I(`${a}.${t} = ${n}`)},validate(t,e,a){const n=a.endsWith("%")?a.slice(0,-1):a,i=parseFloat(n);(!Number.isFinite(i)||i<0)&&T("`{}` {} must be a positive integer or float with an optional `%` at the end: `{}`.",t,e,a)}},gradient:{async add(t,e,a){s++;let n,i=e.split(/,(?![^(]*\))(?![^"']*["'](?:[^"']*["'][^"']*["'])*[^"']*$)/).map((t=>t.trim()));const o={"to top":0,"to top right":45,"to right":90,"to bottom right":135,"to bottom":180,"to bottom left":225,"to left":270,"to top left":315},r=i[0].toLowerCase();r in o?(i.shift(),n=o[r]):/\d+\s*deg/.test(r)?(i.shift(),n=Number(r.match(/(\d+)\s*deg/)[1])):n=0;const l=[],c=[];for(const t of i){const e=t.match(/\s+(\d+(?:\.\d+)?%?)$/);let a,n=null,i=t;if(e){const a=e[1];n=a.endsWith("%")?Number(a.slice(0,-1))/100:Number(a),i=t.slice(0,e.index).trim()}c.push(n);const[o,s]=i.split("-");if(null!=s){const[t,e]=await Promise.all([k(o),k(s)]);a=`Color.dynamic(${t}, ${e})`}else a=await k(o);l.push(a)}null===c[0]&&(c[0]=0),null===c.at(-1)&&(c[c.length-1]=1);for(let t=0;t--------\n${i}\n--------
--------\n${t}`)}}module.exports=htmlWidget;
--------------------------------------------------------------------------------
/images/HelloWorldWidget.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Normal-Tangerine8609/Scriptable-HTML-Widget/66a3433a06d5bcfb00561e74a37876ee9ad32d16/images/HelloWorldWidget.jpeg
--------------------------------------------------------------------------------
/images/RedditWidget.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Normal-Tangerine8609/Scriptable-HTML-Widget/66a3433a06d5bcfb00561e74a37876ee9ad32d16/images/RedditWidget.jpeg
--------------------------------------------------------------------------------
/images/code preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Normal-Tangerine8609/Scriptable-HTML-Widget/66a3433a06d5bcfb00561e74a37876ee9ad32d16/images/code preview.png
--------------------------------------------------------------------------------
/images/logo.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Normal-Tangerine8609/Scriptable-HTML-Widget/66a3433a06d5bcfb00561e74a37876ee9ad32d16/images/logo.jpeg
--------------------------------------------------------------------------------