├── 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 | 27 | 37 | 38 | The Text 39 | 40 | 41 | 42 | 43 | 51 | 52 | The Text 53 | 54 | 55 | ``` 56 | 57 | ## 6.21 58 | 59 | - 13.4 KB 60 | - Cache colours to majorly improve widget creation speed 61 | - Padding property can now be 3 characters 62 | - Improve gradients by expanding end and start points to the edges of the widget rather than in a circle 63 | - Reduce loops and traversals to improve widget creation speed 64 | 65 | ## 6.20 66 | 67 | - 14.3 KB 68 | - Add addAccessoryWidgetBackground bool property for widget elements 69 | - Outputs code on errors 70 | - Simplify add-ons by automatically calling validate and update methods. This is a breaking change. Please take a look at the docs for the updated basic add-on code. 71 | - Fix valid CSS selectors causing an invalid CSS rule error 72 | - Add an error message for if the HTML is formatted incorrectly by a missing closing tag 73 | - Reduces useless async checking code 74 | 75 | ## 6.11 76 | 77 | - 14.1 KB 78 | - Fixed an error where the program would try to add css properties that are not in the mapping object, practically rendering the `*` selector useless 79 | 80 | ## 6.10 81 | 82 | - 14.1 KB 83 | - Small improvements and refactoring 84 | - Allow for css properties and html attributes to be camelCase or kebab-case 85 | - Updated file structure by removing html-widget-extended and the module file 86 | 87 | ## 6.02 88 | 89 | - 14.4 KB 90 | - Fixed a bug for adding a url in css 91 | - Fixed gradient bugs 92 | - Simplified many "primitive types" 93 | 94 | ## 6.01 95 | 96 | - 15.3 KB 97 | - Fix global `sym` variable and global `paddingArray` variable 98 | - Improved performance and file size 99 | - Added more comments 100 | - When `debug` is true, in the logged code, tags are split by new lines 101 | 102 | ## 6.00 103 | 104 | - 20.4 KB 105 | - Changed the HTML parsing system to use the native `XMLParser` 106 | - Reduced the size and increased speed 107 | - Self-closing tags now need to end with a forward-slash (``) 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 | ![Logo](/images/logo.jpeg) 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 | ![Small Reddit Widget](/images/RedditWidget.jpeg) 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 | 30 | 43 | Showerthoughts 44 | 45 | ${title} 46 | ${body} 47 | 48 | arrow.up.circle.fill 49 | 50 | ${ups} 51 | 52 | star.circle.fill 53 | 54 | ${awards} 55 | 56 | message.circle.fill 57 | 58 | ${comments} 59 | 60 | 61 | `, true, addons) 62 | 63 | Script.setWidget(widget) 64 | widget.presentSmall() 65 | Script.complete() 66 | ``` 67 | 68 | ## Module 69 | 70 | If you would prefer to use a module instead of a function follow these steps: 71 | 72 | 1. Copy the file from the [html-widget.js](cody/html-widget.js) and paste it into a new Scriptable script 73 | 2. Rename the script to `Scriptable-HTML-Widget` 74 | 3. Create a new script and paste `const htmlWidget = importModule("Scriptable-HTML-Widget")` at the top of the file 75 | 4. Start to create the widget as you would normally 76 | 77 | ## Bugs and Feedback 78 | 79 | If you find any bugs please message me. My preferred mode of communication is Reddit but fell free to post the issues to this respiratory. 80 | 81 | [u/Normal-Tangerine8609](https://www.reddit.com/user/Normal-Tangerine8609) 82 | 83 | ## Support 84 | 85 | Thanks for the support of the [r/Scriptable](https://www.reddit.com/r/Scriptable/) community. 86 | 87 | Thanks for [u/TheLongConIsGone](https://www.reddit.com/user/TheLongConIsGone) and [u/Glassounds](https://www.reddit.com/user/Glassounds) for answering some of my scriptable questions. 88 | 89 | Thanks for [henryluki](https://github.com/henryluki), the creator of the [HTML parser](https://github.com/henryluki/html-parser) once used in the script. 90 | 91 | Thanks for you visiting or trying out HTML Widget! 92 | -------------------------------------------------------------------------------- /add-ons/html-widget-blockquote.js: -------------------------------------------------------------------------------- 1 | /* 2 | HTML Widget blockquote 1.4 3 | https://github.com/Normal-Tangerine8609/Scriptable-HTML-Widget/blob/main/add-ons/html-widget-blockquote.js 4 | 5 | - Compatible with HTML Widget 6.3.0 6 | */ 7 | module.exports = { 8 | mapping: { 9 | url: "url", 10 | background: ["colour", "gradient"], 11 | cornerRadius: "posInt", 12 | barWidth: "posInt", 13 | barBackground: ["colour", "gradient"], 14 | barCornerRadius: "posInt", 15 | space: "posInt", 16 | spacing: "posInt", 17 | padding: "padding", 18 | layout: "layout", 19 | width: "posInt", 20 | height: "posInt" 21 | }, 22 | async render(template, styles, attrs, innerText) { 23 | let barWidth = Number(styles.barWidth ?? 5) 24 | let height = Number(styles.height ?? 100) 25 | let width = Number(styles.width ?? 100) 26 | let space = Number(styles.space ?? 0) 27 | let contentWidth = width - barWidth - space 28 | 29 | await template(` 30 | 31 | 34 | 35 | 36 | 43 | 44 | 45 | `) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /add-ons/html-widget-hr.js: -------------------------------------------------------------------------------- 1 | /* 2 | HTML Widget hr 2.5 3 | https://github.com/Normal-Tangerine8609/Scriptable-HTML-Widget/blob/main/add-ons/html-widget-hr.js 4 | 5 | - Compatible with HTML Widget 6.3.0 6 | - Fixed height spelling 7 | */ 8 | module.exports = { 9 | mapping: { 10 | background: ["colour", "gradient", "image"], 11 | url: "url", 12 | cornerRadius: "posInt", 13 | width: "posInt", 14 | height: "posInt" 15 | }, 16 | async render(template, styles, attrs, innerText) { 17 | await template(` 18 | 23 | ${styles.width === null && ""} 24 | 27 | ${styles.width === null && ""} 28 | 29 | `) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /add-ons/html-widget-progress.js: -------------------------------------------------------------------------------- 1 | /* 2 | HTML Widget progress 1.5 3 | https://github.com/Normal-Tangerine8609/Scriptable-HTML-Widget/blob/main/add-ons/html-widget-progress.js 4 | 5 | - Compatible with HTML Widget 6.3.0 6 | 7 | */ 8 | module.exports = { 9 | mapping: { 10 | url: "url", 11 | background: ["colour", "gradient"], 12 | progressBackground: ["colour", "gradient"], 13 | borderColor: "colour", 14 | borderWidth: "posInt", 15 | cornerRadius: "posInt", 16 | progressCornerRadius: "posInt", 17 | width: "posInt", 18 | height: "posInt", 19 | value: "decimal" 20 | }, 21 | async render(template, styles, attrs, innerText) { 22 | let value = /\d*(?:\.\d*)?%?/.exec(attrs.value)[0] 23 | if (value.endsWith("%")) { 24 | value = Number(value.replace("%", "")) 25 | value /= 100 26 | } 27 | if (!attrs.value) { 28 | throw new Error("`progress` tag must have a `value` attribute") 29 | } 30 | if (value < 0) { 31 | throw new Error(`\`value\` attribute must be above \`0\`: ${attrs.value}`) 32 | } 33 | if (value > 1) { 34 | throw new Error(`\`value\` attribute must be below \`1\`: ${attrs.value}`) 35 | } 36 | 37 | let width = Number(styles.width ?? 100) 38 | let height = Number(styles.height ?? 1) 39 | await template(` 40 | 47 | 50 | 51 | 54 | 55 | `) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /add-ons/html-widget-symbol.js: -------------------------------------------------------------------------------- 1 | /* 2 | HTML Widget symbol 2.5 3 | https://github.com/Normal-Tangerine8609/Scriptable-HTML-Widget/blob/main/add-ons/html-widget-symbol.js 4 | 5 | - Compatible with HTML Widget 6.3.0 6 | */ 7 | module.exports = { 8 | mapping: { 9 | url: "url", 10 | borderColor: "colour", 11 | borderWidth: "posInt", 12 | cornerRadius: "posInt", 13 | imageSize: "size", 14 | imageOpacity: "decimal", 15 | tintColor: "colour", 16 | resizable: "bool", 17 | containerRelativeShape: "bool", 18 | contentMode: "contentMode", 19 | alignImage: "alignImage" 20 | }, 21 | async render(template, styles, attrs, innerText) { 22 | let symbol = SFSymbol.named(innerText) 23 | if (!symbol) { 24 | symbol = SFSymbol.named("questionmark.circle") 25 | } 26 | 27 | let symbolSize = 100 28 | if (styles.imageSize !== null) { 29 | let [width, height] = styles.imageSize.match(/\d+/g) 30 | symbolSize = parseInt(width > height ? height : width) 31 | } 32 | symbol.applyFont(Font.systemFont(symbolSize)) 33 | await template(` 34 | 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(`// `) 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(`
`) 654 | let result = await w.evaluateJavaScript( 655 | 'window.getComputedStyle(document.getElementById("div")).color' 656 | ) 657 | 658 | const rgba = result.match(/\d+(\.\d+)?/g).map(Number) 659 | const scriptable = rgbaToScriptable(...rgba) 660 | colorCache.set(c, scriptable) 661 | 662 | return scriptable 663 | 664 | function rgbaToScriptable(r, g, b, a = 1) { 665 | const hex = (n) => n.toString(16).padStart(2, "0") 666 | const alpha = a === 1 ? "" : `,${a}` 667 | return `new Color("#${hex(r)}${hex(g)}${hex(b)}"${alpha})` 668 | } 669 | } 670 | 671 | function toCamelCase(text) { 672 | return text.replace(/-(.)/g, (_, chr) => chr.toUpperCase()) 673 | } 674 | 675 | // error function 676 | function error(message, ...params) { 677 | for (let param of params) { 678 | message = message.replace("{}", param) 679 | } 680 | throw new Error( 681 | `HTML Widget Error\n----------------\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;tt.trim()));(["to top","to top right","to right","to bottom right","to bottom","to bottom left","to left","to top left"].includes(n[0].toLowerCase())||/\d+\s*deg/.test(n[0]))&&n.shift(),n.length<2&&T("`{}` {} must have at least two color stops after the direction: `{}`.",t,e,a);let i=-1/0;for(const o of n){const n=o.match(/\s+(\d+(?:\.\d+)?%?)$/);if(n){const o=n[1],s=o.endsWith("%")?Number(o.slice(0,-1))/100:Number(o);Number.isFinite(s)||T("`{}` {} has invalid position: `{}` in `{}`.",t,e,o,a),(s<0||s>1)&&T("`{}` {} position must be between `0` and `1`: `{}`.",t,e,o),sparseInt(t.trim(),10)));let[i,o,s,r]=n;1===n.length?[i,o,s,r]=[i,i,i,i]:2===n.length?[i,o,s,r]=[i,o,i,o]:3===n.length&&([i,o,s,r]=[i,o,s,o]),I(`${a}.setPadding(${i}, ${o}, ${s}, ${r})`)},validate(t,e,a){if("default"===a)return;const n=a.split(",").map((t=>t.trim()));(n.length<1||n.length>4)&&T("`{}` {} must have 1-4 comma-separated positive integers or be `default`: `{}`.",t,e,a);for(let i of n)/^\d+$/.test(i)||T("`{}` {} must be non-negative integers: `{}` contains `{}`.",t,e,a,i)}},size:{add(t,e,a){let[n,i]=e.split(",");I(`${a}.${t} = new Size(${n}, ${i})`)},validate(t,e,a){/^\d+\s*,\s*\d+$/.test(a)||T("`{}` {} must be 2 positive integers separated by commas: `{}`.",t,e,a)}},font:{add(t,e,a){let n=/^(((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+)))$/;if(n.test(e))I(`${a}.font = Font.${e.replace(n,"$3$4$6$8($5$9)")}`);else{const[t,n]=e.split(",");I(`${a}.font = new Font("${t.replace(/"/g,"")}",${n.match(/\d+/g)[0]})`)}},validate(t,e,a){/^[^,]+,\s*\d+$/.test(a)||["body","callout","caption1","caption2","footnote","subheadline","headline","italicSystemFont","largeTitle","title1","title2","title3"].includes(a)||T("`{}` {} must be 1 font name and 1 positive integer separated by commas or be a content-based font: `{}`.",t,e,a)}},point:{add(t,e,a){const[n,i]=e.split(",");I(`${a}.shadowOffset = new Point(${n},${i})`)},validate(t,e,a){/^-?\d+\s*,\s*-?\d+$/.test(a)||T("`{}` {} must be 2 integers separated by commas: `{}`.",t,e,a)}},bool:{add(t,e,a){"false"!==e&&I("resizable"===t?`${a}.resizable = false`:`${a}.${t} = true`)},validate(){}},url:{add:(t,e,a)=>{I(`${a}.url = "${e.replace(/"/g,"")}"`)},validate(t,e,a){/^\w+:\/\/\S+$/.test(a)||T("`{}` {} must be a valid URL: `{}.`",t,e,a)}},image:{add(t,e,a){e.startsWith("data:image/")?I(`${a}.backgroundImage = Image.fromData(Data.fromBase64String("${e.split(",",2)[1].replace(/"/g,"")}"))`):I(`${a}.backgroundImage = await new Request("${e.replace(/"/g,"")}").loadImage()`)},validate(t,e,a){/^(https?:\/\/\S+|data:image\/\w+?;base64,[a-zA-Z0-9+/]+=*)$/.test(a)||T("`{}` {} must be a valid url or base encoded data link: `{}`.",t,e,a)}},layout:{add(t,e,a){I(`${a}.layout${x(e)}()`)},validate(t,e,a){"vertically"!==a&&"horizontally"!==a&&T("`{}` {} must be `vertically` or `horizontally`: `{}`.",t,e,a)}},alignText:{add(t,e,a){I(`${a}.${e}AlignText()`)},validate(t,e,a){["center","left","right"].includes(a)||T("`{}` {} must be `left`, `right` or `center`: `{}.`",t,e,a)}},alignImage:{add(t,e,a){I(`${a}.${e}AlignImage()`)},validate(t,e,a){["center","left","right"].includes(a)||T("`{}` {} must be `left`, `right` or `center`: `{}`.",t,e,a)}},alignContent:{add(t,e,a){I(`${a}.${e}AlignContent()`)},validate(t,e,a){["center","top","bottom"].includes(a)||T("`{}` {} must be `top`, `bottom` or `center`: `{}`.",t,e,a)}},applyStyle:{add(t,e,a){I(`${a}.apply${x(e)}Style()`)},validate(t,e,a){["date","timer","offset","relative","time"].includes(a)||T("`{}` {} must be `date`, `timer` , `relative`, `time`, or `offset`: `{}`.",t,e,a)}},contentMode:{add(t,e,a){I(`${a}.apply${x(e)}ContentMode()`)},validate(t,e,a){["filling","fitting"].includes(a)||T("`{}` {} must be `filling` or `fitting`: `{}`.",t,e,a)}}};await b(u),I("// Widget Complete"),e&&console.log(i);const m=new(0,Object.getPrototypeOf((async function(){})).constructor)(i+"\nreturn widget");return await m();function f(t){const e=[],a=new XMLParser(t),n={name:"root",children:[]},i=[n];return a.didStartElement=(t,a)=>{const n={name:t,attrs:a,innerText:"",children:[],classList:[],noCss:!1,putChildren:!1};"style"===t?e.push(n):i.at(-1).children.push(n),i.push(n)},a.foundCharacters=t=>{i.at(-1).innerText+=t},a.didEndElement=()=>{const t=i.pop(),e={};for(const[a,n]of Object.entries(t.attrs)){const i=A(a);if("class"===i)t.classList=t.attrs.class.trim().split(/\s+/);else if("noCss"===i)t.noCss=!0;else if("children"===i)t.putChildren=!0;else{const t=n.trim();e[i]="null"===t?null:t}}t.attrs=e,0===i.length&&T("Unexpected closing tag: <{}/>",t.name)},a.parseErrorOccurred=t=>{T("A parse error occurred: {}",t)},a.parse(),1!==i.length&&T("A parse error occurred, ensure all tags are closed and attributes are properly formatted: <{}>",i.at(-1).name),{root:n,styleTags:e}}function h(t){return t.replace(/\s/g,"").split(">").filter((t=>t.length)).map((t=>{let e,a=t;if(!t.startsWith(".")){const n=t.match(/^([a-zA-Z][\w-]*|\*)/);n?(e=n[1],a=t.slice(e.length)):T("A css parse error occurred, invalid tag name: {}.",t)}const n=[];let i;const o=/\.([-_a-zA-Z0-9]+)/g;for(;i=o.exec(a);)n.push(i[1]);return{tag:e,classes:n}}))}function $(t,{tag:e,classes:a}){return(!e||"*"===e||t.name===e)&&a.every((e=>t.classList.includes(e)))}async function b(t){"widget"==t.name&&i&&T("`widget` tag must not be nestled."),i&&I(""),t.name in o?o[t.name]+=1:o[t.name]=0;const e=o[t.name],s=t.innerText.replace(/</g,"<").replace(/>/g,">").replace(/&/g,"&").replace(/\n\s+/g,"\\n"),l=function(t=[],e,a){if(a)return{...e};const n={};for(const{css:e}of t)Object.assign(n,e);return{...n,...e}}(t.css,t.attrs,t.noCss),c={widget:{mapping:{background:["gradient","image","colour"],refreshAfterDate:"posInt",spacing:"posInt",url:"url",padding:"padding",addAccessoryWidgetBackground:"bool"},hasChildren:!0,instantiate:()=>(I("let widget = new ListWidget()"),"widget"),async applyStyles(e){const{background:a,...n}=l,i=a in t.attrs?"attribute":"property";await w("widget",i,a),await y(e,n,this.mapping)}},stack:{mapping:{background:["gradient","image","colour"],spacing:"posInt",url:"url",padding:"padding",borderColor:"colour",borderWidth:"posInt",size:"size",cornerRadius:"posInt",alignContent:"alignContent",layout:"layout"},hasChildren:!0,instantiate:()=>(I(`let stack${e} = ${n}.addStack()`),`stack${e}`),async applyStyles(e){const{background:a,...n}=l,i=a in t.attrs?"attribute":"property";await w(e,i,a),await y(e,n,this.mapping)}},spacer:{mapping:{space:"posInt"},hasChildren:!1,instantiate(){const a=t.attrs.space??"";return I(`let spacer${e} = ${n}.addSpacer(${a})`),null}},text:{mapping:{url:"url",font:"font",lineLimit:"posInt",minimumScaleFactor:"decimal",shadowColor:"colour",shadowOffset:"point",shadowRadius:"posInt",textColor:"colour",textOpacity:"decimal",alignText:"alignText"},hasChildren:!1,instantiate:()=>(I(`let text${e} = ${n}.addText("${s.replace(/"/g,"")}")`),`text${e}`),async applyStyles(t){await y(t,l,this.mapping)}},date:{mapping:{url:"url",font:"font",lineLimit:"posInt",minimumScaleFactor:"decimal",shadowColor:"colour",shadowOffset:"point",shadowRadius:"posInt",textColor:"colour",textOpacity:"decimal",alignText:"alignText",applyStyle:"applyStyle"},hasChildren:!1,instantiate:()=>(I(`let date${e} = ${n}.addDate(new Date("${s.replace(/"/g,"")}"))`),`date${e}`),async applyStyles(t){await y(t,l,this.mapping)}},img:{mapping:{src:"image",url:"url",borderColor:"colour",borderWidth:"posInt",cornerRadius:"posInt",imageSize:"size",imageOpacity:"decimal",tintColor:"colour",resizable:"bool",containerRelativeShape:"bool",contentMode:"contentMode",alignImage:"alignImage"},hasChildren:!1,instantiate(){let a;return t.attrs.src||T("`img` tag must have a `src` attribute."),a=t.attrs.src.startsWith("data:image/")?`Image.fromData(Data.fromBase64String("${t.attrs.src.replace(/data:image\/.*?;base64,/,"").replace(/"/g,"")}"))`:`await new Request("${t.attrs.src.replace(/"/g,"")}").loadImage()`,I(`let img${e} = ${n}.addImage(${a})`),`img${e}`},async applyStyles(t){const{src:e,...a}=l;await y(t,a,this.mapping)}}},d=c[t.name];if(!d){t.name in a||T("Invalid tag name: `{}`.",t.name),I(`// <${t.name}>`);const e=a[t.name],i=e.mapping||{};C(t.attrs,l,i);for(let e in i)e in l||(l[e]=null),e in t.attrs||(t.attrs[e]=null);const o=e=>async function(t,e){let{root:a}=f(t);a.children.forEach((t=>v(t,e))),r++;const i=n;for(let t of a.children)n=i,await b(t);r--}(e,t.children);return await e.render(o,l,t.attrs,s),void I(`// `)}const p=d.instantiate();if(C(t.attrs,l,d.mapping),await(d.applyStyles?.(p)),d.hasChildren){r++;const e=n;n=p;for(const e of t.children)await b(e);r--,n=e}}async function y(t,e,a){for(const[n,i]of Object.entries(e)){if(null===i)continue;const e=a[n];await g[e].add(n,i,t)}}async function w(t,e,a){if(a)try{g.url.validate("background","attribute",a),g.image.add("background",a,t)}catch{1===a.split(/,(?![^(]*\))(?![^"']*["'](?:[^"']*["'][^"']*["'])*[^"']*$)/).length?await g.colour.add("backgroundColor",a,t):(g.gradient.validate("backgroundGradient",e,a),await g.gradient.add("backgroundGradient",a,t))}}function v(t,e){t.noCss=!0;for(let a of t.children||[])v(a,e);t.putChildren&&t.children.push(...e)}function C(t,e,a){S(t,a,"attribute"),S(e,a,"property")}function S(t,e,a){for(const[n,i]of Object.entries(t)){const o=e[n];if(!o){if("property"===a){delete t[n];continue}T("Unknown attribute: `{}`.",n)}if(null===i)continue;if(!Array.isArray(o)){g[o].validate(n,a,i);continue}if(!o.some((t=>{try{return g[t].validate(n,a,i),!0}catch{return!1}}))){const t=o.join(", ").replace(/,([^,]+)$/," or$1");T("`{}` {} must be {} type: `{}`",n,a,t,i)}}}function I(t){const e=" ".repeat(r);i+=`\n${e+t}`}function x(t){return t[0].toUpperCase()+t.slice(1)}async function k(t){if(l.has(t))return l.get(t);if(/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(t))return`new Color("${t}")`;let e=new WebView;await e.loadHTML(`
`);const a=function(t,e,a,n=1){const i=t=>t.toString(16).padStart(2,"0"),o=1===n?"":`,${n}`;return`new Color("#${i(t)}${i(e)}${i(a)}"${o})`}(...(await e.evaluateJavaScript('window.getComputedStyle(document.getElementById("div")).color')).match(/\d+(\.\d+)?/g).map(Number));return l.set(t,a),a}function A(t){return t.replace(/-(.)/g,((t,e)=>e.toUpperCase()))}function T(t,...e){for(let a of e)t=t.replace("{}",a);throw new Error(`HTML Widget Error\n----------------\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 --------------------------------------------------------------------------------