[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Support inline styles in plugin elements

Grace Kind (Jul 21, 2026, 1:47 PM -0500) e063578a a513bd9a

+388 -6
+9 -1
impro-plugin/main.js
··· 753 753 constructor(tag) { 754 754 this.tag = tag; 755 755 this.attrs = {}; 756 + this.styles = {}; 756 757 this.children = []; 757 758 this.events = {}; 759 + } 760 + 761 + setStyle(name, value) { 762 + this.styles[String(name)] = value == null ? "" : String(value); 763 + return this; 758 764 } 759 765 760 766 onClick(fn) { ··· 861 867 } 862 868 863 869 _serialize() { 864 - return { 870 + const serialized = { 865 871 type: "element", 866 872 tag: this.tag, 867 873 attrs: this.attrs, 868 874 events: this.events, 869 875 children: this.children.map((child) => child._serialize()), 870 876 }; 877 + if (Object.keys(this.styles).length > 0) serialized.styles = this.styles; 878 + return serialized; 871 879 } 872 880 } 873 881
+1 -1
impro-plugin/package.json
··· 1 1 { 2 2 "name": "@impro.social/impro-plugin", 3 - "version": "0.0.13", 3 + "version": "0.0.14", 4 4 "type": "module", 5 5 "main": "main.js", 6 6 "license": "0BSD",
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.41", 3 + "version": "0.18.42", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+62
src/js/plugins/pluginRendering.js
··· 1 1 import { ExternalLinkWarningModal } from "/js/modals/externalLinkWarning.modal.js"; 2 + import { assertSafeInlineStyleValue } from "/js/plugins/pluginStylesLoader.js"; 2 3 import "/js/components/toggle-switch.js"; 3 4 import "/js/components/plugin-profiles-list.js"; 4 5 import "/js/components/plugin-posts-feed.js"; ··· 113 114 maxTotalText: 256 * 1024, 114 115 maxAttrs: 32, 115 116 maxChildren: 1000, 117 + maxStyles: 64, 118 + maxStyleValueLength: 128, 116 119 }; 117 120 118 121 class NormalizerState { ··· 170 173 return attrs; 171 174 } 172 175 176 + const IMPORTANT_SUFFIX_RE = /\s*!\s*important\s*$/i; 177 + 178 + function applyStyle(element, name, value) { 179 + const match = IMPORTANT_SUFFIX_RE.exec(value); 180 + if (match) { 181 + element.style.setProperty(name, value.slice(0, match.index), "important"); 182 + } else { 183 + element.style.setProperty(name, value); 184 + } 185 + } 186 + 187 + function normalizeStyles(rawStyles, state) { 188 + if (!rawStyles || typeof rawStyles !== "object") return {}; 189 + const entries = Object.entries(rawStyles); 190 + if (entries.length > TREE_LIMITS.maxStyles) { 191 + state.recordIssue("element exceeds max style declarations; extras ignored"); 192 + } 193 + const styles = {}; 194 + for (const [name, rawValue] of entries.slice(0, TREE_LIMITS.maxStyles)) { 195 + if (typeof name !== "string" || name === "") { 196 + state.recordIssue("style property name is not a non-empty string"); 197 + continue; 198 + } 199 + if (typeof rawValue !== "string" && typeof rawValue !== "number") { 200 + state.recordIssue("style value is not a string or number"); 201 + continue; 202 + } 203 + const value = String(rawValue); 204 + if (value.length > TREE_LIMITS.maxStyleValueLength) { 205 + state.recordIssue("style value exceeds max length"); 206 + continue; 207 + } 208 + try { 209 + assertSafeInlineStyleValue(name, value); 210 + } catch { 211 + state.recordIssue("disallowed resource function in style value"); 212 + continue; 213 + } 214 + styles[name] = value; 215 + } 216 + return styles; 217 + } 218 + 173 219 // Normalize any serialized node into `{ type: "text" | "element", ... }`, 174 220 // converting from legacy `{ tag, attrs, text, children }` format if needed. 175 221 // Returns null if invalid or over limits, recording the reason on `state`. ··· 223 269 type: "element", 224 270 tag: typeof raw.tag === "string" ? raw.tag : "div", 225 271 attrs: normalizeAttrs(raw.attrs, state), 272 + styles: normalizeStyles(raw.styles, state), 226 273 events: raw.events && typeof raw.events === "object" ? raw.events : {}, 227 274 children, 228 275 }; ··· 280 327 type: "element", 281 328 tag: "span", 282 329 attrs: {}, 330 + styles: {}, 283 331 events: {}, 284 332 children: [], 285 333 } ··· 358 406 element.setAttribute(name, String(value)); 359 407 } 360 408 } 409 + if (node.styles) { 410 + for (const [name, value] of Object.entries(node.styles)) { 411 + applyStyle(element, name, value); 412 + } 413 + } 361 414 this._patchEvents(element, null, node.events); 362 415 for (const child of node.children) { 363 416 element.appendChild(this._create(child)); ··· 399 452 continue; 400 453 } 401 454 if (oldAttrs[name] !== value) element.setAttribute(name, String(value)); 455 + } 456 + 457 + const oldStyles = oldNode.styles ?? {}; 458 + const newStyles = newNode.styles ?? {}; 459 + for (const name of Object.keys(oldStyles)) { 460 + if (!(name in newStyles)) element.style.removeProperty(name); 461 + } 462 + for (const [name, value] of Object.entries(newStyles)) { 463 + if (oldStyles[name] !== value) applyStyle(element, name, value); 402 464 } 403 465 404 466 this._patchEvents(element, oldNode.events, newNode.events);
+16 -3
src/js/plugins/pluginStylesLoader.js
··· 1 1 const URL_FUNC_RE = 2 2 /\b(?:url|image-set|-webkit-image-set|image|cross-fade|element)\s*\(/i; 3 3 4 + export function assertSafeCssValue(property, value) { 5 + if (URL_FUNC_RE.test(value)) { 6 + throw new Error(`disallowed url() in ${property}`); 7 + } 8 + } 9 + 10 + export function assertSafeInlineStyleValue(property, value) { 11 + // Reject any backslash so a CSS-escape-encoded identifier (e.g. `\75rl(...)`) 12 + // can't slip past URL_FUNC_RE 13 + if (value.includes("\\")) { 14 + throw new Error(`disallowed escape in ${property}`); 15 + } 16 + assertSafeCssValue(property, value); 17 + } 18 + 4 19 export function validatePluginCss(text) { 5 20 const sheet = new CSSStyleSheet(); 6 21 sheet.replaceSync(text); ··· 18 33 19 34 if (rule.style) { 20 35 for (const prop of rule.style) { 21 - if (URL_FUNC_RE.test(rule.style.getPropertyValue(prop))) { 22 - throw new Error(`disallowed url() in ${prop}`); 23 - } 36 + assertSafeCssValue(prop, rule.style.getPropertyValue(prop)); 24 37 } 25 38 } 26 39
+209
tests/unit/specs/plugins/pluginRendering.test.js
··· 760 760 }); 761 761 }); 762 762 763 + describe("PluginRenderer:inline styles", () => { 764 + it("applies serialized styles via setProperty on create", () => { 765 + const { bridge } = makeBridge(); 766 + const renderer = new PluginRenderer(bridge, "demo"); 767 + const element = renderer.createRoot().render({ 768 + type: "element", 769 + tag: "span", 770 + attrs: { class: "strut" }, 771 + styles: { height: "0.68333em", "vertical-align": "-0.08333em" }, 772 + events: {}, 773 + children: [], 774 + }); 775 + assert.deepEqual(element.style.getPropertyValue("height"), "0.68333em"); 776 + assert.deepEqual( 777 + element.style.getPropertyValue("vertical-align"), 778 + "-0.08333em", 779 + ); 780 + }); 781 + 782 + it("still rejects a raw `style` attribute", (t) => { 783 + t.mock.method(console, "warn", () => {}); 784 + const { bridge } = makeBridge(); 785 + const renderer = new PluginRenderer(bridge, "demo"); 786 + const element = renderer.createRoot().render({ 787 + type: "element", 788 + tag: "span", 789 + attrs: { style: "color: red" }, 790 + events: {}, 791 + children: [], 792 + }); 793 + assert(!element.hasAttribute("style")); 794 + }); 795 + 796 + it("drops values containing a resource function", (t) => { 797 + t.mock.method(console, "warn", () => {}); 798 + const { bridge } = makeBridge(); 799 + const renderer = new PluginRenderer(bridge, "demo"); 800 + const element = renderer.createRoot().render({ 801 + type: "element", 802 + tag: "span", 803 + attrs: {}, 804 + styles: { 805 + background: 'url("https://evil.test/x.png")', 806 + color: "red", 807 + }, 808 + events: {}, 809 + children: [], 810 + }); 811 + assert.deepEqual(element.style.getPropertyValue("background"), ""); 812 + assert.deepEqual(element.style.getPropertyValue("color"), "red"); 813 + }); 814 + 815 + it("drops values containing a backslash (blocks CSS-escape url() smuggling)", (t) => { 816 + t.mock.method(console, "warn", () => {}); 817 + const { bridge } = makeBridge(); 818 + const renderer = new PluginRenderer(bridge, "demo"); 819 + const element = renderer.createRoot().render({ 820 + type: "element", 821 + tag: "span", 822 + attrs: {}, 823 + styles: { 824 + background: "\\75rl(https://evil.test/x)", 825 + color: "red", 826 + }, 827 + events: {}, 828 + children: [], 829 + }); 830 + assert.deepEqual(element.style.getPropertyValue("background"), ""); 831 + assert.deepEqual(element.style.getPropertyValue("color"), "red"); 832 + }); 833 + 834 + it("drops resource functions inside custom property values", (t) => { 835 + t.mock.method(console, "warn", () => {}); 836 + const { bridge } = makeBridge(); 837 + const renderer = new PluginRenderer(bridge, "demo"); 838 + const element = renderer.createRoot().render({ 839 + type: "element", 840 + tag: "span", 841 + attrs: {}, 842 + styles: { "--bg": "url(https://evil.test/x)" }, 843 + events: {}, 844 + children: [], 845 + }); 846 + assert.deepEqual(element.style.getPropertyValue("--bg"), ""); 847 + }); 848 + 849 + it("drops values exceeding the max length", (t) => { 850 + t.mock.method(console, "warn", () => {}); 851 + const { bridge } = makeBridge(); 852 + const renderer = new PluginRenderer(bridge, "demo"); 853 + const element = renderer.createRoot().render({ 854 + type: "element", 855 + tag: "span", 856 + attrs: {}, 857 + styles: { color: "a".repeat(129), background: "blue" }, 858 + events: {}, 859 + children: [], 860 + }); 861 + assert.deepEqual(element.style.getPropertyValue("color"), ""); 862 + assert.deepEqual(element.style.getPropertyValue("background"), "blue"); 863 + }); 864 + 865 + it("caps the number of declarations per node", (t) => { 866 + t.mock.method(console, "warn", () => {}); 867 + const { bridge } = makeBridge(); 868 + const renderer = new PluginRenderer(bridge, "demo"); 869 + const styles = {}; 870 + for (let i = 0; i < 100; i++) styles[`--v${i}`] = `${i}px`; 871 + const element = renderer.createRoot().render({ 872 + type: "element", 873 + tag: "span", 874 + attrs: {}, 875 + styles, 876 + events: {}, 877 + children: [], 878 + }); 879 + // Only the first 64 declarations survive normalization. 880 + assert.deepEqual(element.style.getPropertyValue("--v0"), "0px"); 881 + assert.deepEqual(element.style.getPropertyValue("--v63"), "63px"); 882 + assert.deepEqual(element.style.getPropertyValue("--v64"), ""); 883 + }); 884 + 885 + it("permits !important", () => { 886 + const { bridge } = makeBridge(); 887 + const renderer = new PluginRenderer(bridge, "demo"); 888 + const element = renderer.createRoot().render({ 889 + type: "element", 890 + tag: "span", 891 + attrs: {}, 892 + styles: { color: "red !important" }, 893 + events: {}, 894 + children: [], 895 + }); 896 + assert.deepEqual(element.style.getPropertyPriority("color"), "important"); 897 + }); 898 + 899 + it("updates changed style values in place on patch", () => { 900 + const { bridge } = makeBridge(); 901 + const renderer = new PluginRenderer(bridge, "demo"); 902 + const root = renderer.createRoot(); 903 + const element = root.render({ 904 + type: "element", 905 + tag: "span", 906 + attrs: {}, 907 + styles: { height: "1em", color: "red" }, 908 + events: {}, 909 + children: [], 910 + }); 911 + root.render({ 912 + type: "element", 913 + tag: "span", 914 + attrs: {}, 915 + styles: { height: "2em", color: "red" }, 916 + events: {}, 917 + children: [], 918 + }); 919 + assert.deepEqual(element.style.getPropertyValue("height"), "2em"); 920 + assert.deepEqual(element.style.getPropertyValue("color"), "red"); 921 + }); 922 + 923 + it("removes style declarations that are absent from the new tree", () => { 924 + const { bridge } = makeBridge(); 925 + const renderer = new PluginRenderer(bridge, "demo"); 926 + const root = renderer.createRoot(); 927 + const element = root.render({ 928 + type: "element", 929 + tag: "span", 930 + attrs: {}, 931 + styles: { height: "1em", color: "red" }, 932 + events: {}, 933 + children: [], 934 + }); 935 + root.render({ 936 + type: "element", 937 + tag: "span", 938 + attrs: {}, 939 + styles: { height: "1em" }, 940 + events: {}, 941 + children: [], 942 + }); 943 + assert.deepEqual(element.style.getPropertyValue("height"), "1em"); 944 + assert.deepEqual(element.style.getPropertyValue("color"), ""); 945 + }); 946 + 947 + it("removes a previously applied style when its new value is invalid", (t) => { 948 + t.mock.method(console, "warn", () => {}); 949 + const { bridge } = makeBridge(); 950 + const renderer = new PluginRenderer(bridge, "demo"); 951 + const root = renderer.createRoot(); 952 + const element = root.render({ 953 + type: "element", 954 + tag: "span", 955 + attrs: {}, 956 + styles: { background: "red" }, 957 + events: {}, 958 + children: [], 959 + }); 960 + root.render({ 961 + type: "element", 962 + tag: "span", 963 + attrs: {}, 964 + styles: { background: 'url("https://evil.test/x.png")' }, 965 + events: {}, 966 + children: [], 967 + }); 968 + assert.deepEqual(element.style.getPropertyValue("background"), ""); 969 + }); 970 + }); 971 + 763 972 describe("PluginRenderer:malformed and oversized nodes", () => { 764 973 it("renders an empty span when the whole tree is malformed", () => { 765 974 const { bridge } = makeBridge();
+64
tests/unit/specs/plugins/pluginStylesLoader.test.js
··· 2 2 import assert from "node:assert/strict"; 3 3 import { 4 4 PluginStylesLoader, 5 + assertSafeCssValue, 6 + assertSafeInlineStyleValue, 5 7 validatePluginCss, 6 8 } from "/js/plugins/pluginStylesLoader.js"; 7 9 ··· 140 142 `expected error "${caught.message}" to include "${messageFragment}"`, 141 143 ); 142 144 } 145 + 146 + describe("assertSafeCssValue", () => { 147 + it("accepts values without resource functions", () => { 148 + assertSafeCssValue("color", "red"); 149 + assertSafeCssValue("height", "0.68333em"); 150 + assertSafeCssValue("transform", "translate(1px, 2px)"); 151 + }); 152 + 153 + for (const fn of [ 154 + "url", 155 + "image-set", 156 + "-webkit-image-set", 157 + "image", 158 + "cross-fade", 159 + "element", 160 + ]) { 161 + it(`rejects ${fn}() in a value`, () => { 162 + expectThrow( 163 + () => assertSafeCssValue("background", `${fn}("https://evil.test/x")`), 164 + "disallowed url() in background", 165 + ); 166 + }); 167 + } 168 + 169 + it("rejects a resource function inside a custom property value", () => { 170 + expectThrow( 171 + () => assertSafeCssValue("--bg", "url(https://evil.test/x)"), 172 + "disallowed url() in --bg", 173 + ); 174 + }); 175 + 176 + it("accepts backslashes (author CSS is post-parse serialized)", () => { 177 + assertSafeCssValue("content", '"\\A"'); 178 + }); 179 + }); 180 + 181 + describe("assertSafeInlineStyleValue", () => { 182 + it("accepts plain values", () => { 183 + assertSafeInlineStyleValue("color", "red"); 184 + assertSafeInlineStyleValue("height", "0.68333em"); 185 + }); 186 + 187 + it("rejects any backslash to block CSS-escape identifier smuggling", () => { 188 + expectThrow( 189 + () => 190 + assertSafeInlineStyleValue("background", "\\75rl(https://evil.test/x)"), 191 + "disallowed escape in background", 192 + ); 193 + expectThrow( 194 + () => assertSafeInlineStyleValue("content", '"\\A"'), 195 + "disallowed escape in content", 196 + ); 197 + }); 198 + 199 + it("still rejects raw resource functions", () => { 200 + expectThrow( 201 + () => 202 + assertSafeInlineStyleValue("background", "url(https://evil.test/x)"), 203 + "disallowed url() in background", 204 + ); 205 + }); 206 + }); 143 207 144 208 describe("validatePluginCss", () => { 145 209 let env;
+26
tests/unit/specs/plugins/pluginWorker.test.js
··· 190 190 assert.deepEqual(tail._serialize(), { type: "text", value: " after" }); 191 191 }); 192 192 193 + it("setStyle is chainable and serializes a distinct styles field", () => { 194 + const el = makeVirtualEl(); 195 + const result = el 196 + .setStyle("height", "0.68333em") 197 + .setStyle("vertical-align", "-0.08333em"); 198 + assert(result === el); 199 + const serialized = el._serialize(); 200 + assert.deepEqual(serialized.styles, { 201 + height: "0.68333em", 202 + "vertical-align": "-0.08333em", 203 + }); 204 + assert(!("style" in serialized.attrs)); 205 + }); 206 + 207 + it("omits the styles field when no styles are set", () => { 208 + const el = makeVirtualEl(); 209 + const serialized = el._serialize(); 210 + assert(!("styles" in serialized)); 211 + }); 212 + 213 + it("setStyle coerces numeric values to strings", () => { 214 + const el = makeVirtualEl(); 215 + el.setStyle("z-index", 3); 216 + assert.deepEqual(el._serialize().styles, { "z-index": "3" }); 217 + }); 218 + 193 219 it("appendChild rejects values that are not VirtualEl or VirtualText", () => { 194 220 const el = makeVirtualEl(); 195 221 assert.throws(() => el.appendChild({ tag: "div" }), TypeError);