[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 bundled fonts in plugins

Grace Kind (Jul 21, 2026, 4:21 PM -0500) b1ae8e2d a448c919

+510 -2
+8
eleventy.config.js
··· 83 83 if (fs.existsSync(readmePath)) { 84 84 fs.copyFileSync(readmePath, path.join(destDir, "README.md")); 85 85 } 86 + for (const font of manifest.fonts ?? []) { 87 + if (typeof font?.file !== "string") continue; 88 + const src = path.join(pluginPath, font.file); 89 + if (!fs.existsSync(src)) continue; 90 + const dest = path.join(destDir, font.file); 91 + fs.mkdirSync(path.dirname(dest), { recursive: true }); 92 + fs.copyFileSync(src, dest); 93 + } 86 94 } 87 95 fs.writeFileSync( 88 96 path.join(buildPluginsDir, "index.json"),
+1 -1
package.json
··· 1 1 { 2 2 "name": "impro", 3 - "version": "0.18.42", 3 + "version": "0.18.43", 4 4 "type": "module", 5 5 "scripts": { 6 6 "start": "rm -rf \"${BUILD_DIR:-build}\" && NODE_ENV=development eleventy --serve",
+23
src/js/plugins/pluginBridge.js
··· 309 309 throw new Error("Plugin styles failed validation"); 310 310 } 311 311 } 312 + if (manifest.fonts?.length) { 313 + try { 314 + const descriptors = await Promise.all( 315 + manifest.fonts.map(async (font) => ({ 316 + ...font, 317 + blob: await this._provider.getFont( 318 + pluginId, 319 + version, 320 + repo, 321 + font.file, 322 + ), 323 + })), 324 + ); 325 + this._pluginStylesLoader.mountFonts(pluginId, descriptors); 326 + } catch (error) { 327 + this._pluginStylesLoader.unmount(pluginId); 328 + logger.error( 329 + `failed to load "${pluginId}": could not load fonts`, 330 + error, 331 + ); 332 + throw new Error("Failed to load plugin fonts"); 333 + } 334 + } 312 335 try { 313 336 const pluginInstance = await this._loadPluginInstance( 314 337 pluginId,
+96
src/js/plugins/pluginStylesLoader.js
··· 41 41 } 42 42 } 43 43 44 + function cssStringLiteral(value) { 45 + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; 46 + } 47 + 48 + const FONT_STYLES = new Set(["normal", "italic", "oblique"]); 49 + const FONT_WEIGHT_KEYWORDS = new Set(["normal", "bold"]); 50 + 51 + function isValidFontWeightNumber(value) { 52 + if (!/^\d{1,4}$/.test(value)) return false; 53 + const num = Number(value); 54 + return num >= 1 && num <= 1000; 55 + } 56 + 57 + function isValidFontWeight(value) { 58 + if (typeof value !== "string") return false; 59 + if (FONT_WEIGHT_KEYWORDS.has(value)) return true; 60 + const parts = value.trim().split(/\s+/); 61 + if (parts.length === 1) return isValidFontWeightNumber(parts[0]); 62 + if (parts.length === 2) { 63 + return ( 64 + isValidFontWeightNumber(parts[0]) && 65 + isValidFontWeightNumber(parts[1]) && 66 + Number(parts[0]) <= Number(parts[1]) 67 + ); 68 + } 69 + return false; 70 + } 71 + 72 + function buildFontFaceRule({ family, weight, style, url, file }) { 73 + const format = /\.woff2$/i.test(file) ? "woff2" : "woff"; 74 + const lines = [ 75 + `font-family: ${cssStringLiteral(family)};`, 76 + `src: url(${cssStringLiteral(url)}) format(${cssStringLiteral(format)});`, 77 + `font-weight: ${weight};`, 78 + `font-style: ${style};`, 79 + ]; 80 + return `@font-face { ${lines.join(" ")} }`; 81 + } 82 + 44 83 export class PluginStylesLoader { 45 84 constructor() { 46 85 this._manifestSheets = new Map(); 47 86 this._snippetSheets = new Map(); 87 + this._fontSheets = new Map(); 88 + this._fontUrls = new Map(); 89 + } 90 + 91 + mountFonts(pluginId, descriptors) { 92 + if (!descriptors || descriptors.length === 0) { 93 + this._unmountFonts(pluginId); 94 + return; 95 + } 96 + const normalized = descriptors.map((desc, i) => { 97 + const weight = desc.weight ?? "400"; 98 + const style = desc.style ?? "normal"; 99 + if (!isValidFontWeight(weight)) { 100 + throw new Error(`fonts[${i}] invalid weight "${weight}"`); 101 + } 102 + if (!FONT_STYLES.has(style)) { 103 + throw new Error(`fonts[${i}] invalid style "${style}"`); 104 + } 105 + return { ...desc, weight, style }; 106 + }); 107 + this._unmountFonts(pluginId); 108 + const urls = []; 109 + const ruleTexts = []; 110 + for (const desc of normalized) { 111 + const url = URL.createObjectURL(desc.blob); 112 + urls.push(url); 113 + ruleTexts.push( 114 + buildFontFaceRule({ 115 + family: desc.family, 116 + weight: desc.weight, 117 + style: desc.style, 118 + url, 119 + file: desc.file, 120 + }), 121 + ); 122 + } 123 + const sheet = new CSSStyleSheet(); 124 + sheet.replaceSync(ruleTexts.join("\n")); 125 + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet]; 126 + this._fontSheets.set(pluginId, sheet); 127 + this._fontUrls.set(pluginId, urls); 128 + } 129 + 130 + _unmountFonts(pluginId) { 131 + const sheet = this._fontSheets.get(pluginId); 132 + if (sheet) { 133 + document.adoptedStyleSheets = document.adoptedStyleSheets.filter( 134 + (entry) => entry !== sheet, 135 + ); 136 + this._fontSheets.delete(pluginId); 137 + } 138 + const urls = this._fontUrls.get(pluginId); 139 + if (urls) { 140 + for (const url of urls) URL.revokeObjectURL(url); 141 + this._fontUrls.delete(pluginId); 142 + } 48 143 } 49 144 50 145 mount(pluginId, cssText) { ··· 64 159 ); 65 160 } 66 161 this._snippetSheets.delete(pluginId); 162 + this._unmountFonts(pluginId); 67 163 } 68 164 69 165 mountSnippet(pluginId, snippetId, cssText) {
+84 -1
src/js/plugins/sourceProvider.js
··· 1 1 const REQUIRED_MANIFEST_FIELDS = ["id", "name", "version"]; 2 2 3 + function isRelativePath(file) { 4 + if (typeof file !== "string" || file.length === 0) return false; 5 + if (file.startsWith("/")) return false; 6 + if (file.includes("://")) return false; 7 + for (const segment of file.split("/")) { 8 + if (segment === "" || segment === "." || segment === "..") return false; 9 + } 10 + return true; 11 + } 12 + 13 + function parseFontEntry(entry, index) { 14 + if (!entry || typeof entry !== "object") { 15 + throw new Error(`fonts[${index}] must be an object`); 16 + } 17 + const { family, file } = entry; 18 + if (typeof family !== "string" || family.length === 0) { 19 + throw new Error(`fonts[${index}] missing required field "family"`); 20 + } 21 + if (typeof file !== "string" || file.length === 0) { 22 + throw new Error(`fonts[${index}] missing required field "file"`); 23 + } 24 + if (!/\.(woff2?|woff)$/i.test(file)) { 25 + throw new Error(`fonts[${index}] file must end in .woff2 or .woff`); 26 + } 27 + if (!isRelativePath(file)) { 28 + throw new Error(`fonts[${index}] file must be a relative path`); 29 + } 30 + return { ...entry, family, file }; 31 + } 32 + 3 33 function parsePluginManifest(pluginId, manifest) { 4 34 for (const field of REQUIRED_MANIFEST_FIELDS) { 5 35 if (typeof manifest[field] !== "string") { ··· 10 40 throw new Error( 11 41 `manifest id "${manifest.id}" does not match plugin id "${pluginId}"`, 12 42 ); 43 + } 44 + if (manifest.fonts !== undefined) { 45 + if (!Array.isArray(manifest.fonts)) { 46 + throw new Error(`fonts must be an array`); 47 + } 48 + manifest.fonts = manifest.fonts.map((entry, i) => parseFontEntry(entry, i)); 13 49 } 14 50 return manifest; 15 51 } ··· 68 104 ); 69 105 } 70 106 107 + function assertFontMagicBytes(file, bytes) { 108 + const view = new Uint8Array(bytes, 0, 4); 109 + const isWoff2 = 110 + view[0] === 0x77 && 111 + view[1] === 0x4f && 112 + view[2] === 0x46 && 113 + view[3] === 0x32; 114 + const isWoff = 115 + view[0] === 0x77 && 116 + view[1] === 0x4f && 117 + view[2] === 0x46 && 118 + view[3] === 0x46; 119 + const wantWoff2 = /\.woff2$/i.test(file); 120 + if (wantWoff2 ? !isWoff2 : !isWoff) { 121 + throw new Error(`font "${file}" has invalid magic bytes`); 122 + } 123 + } 124 + 71 125 function remoteAssetUrl({ repo, file, release = null }) { 72 126 const { host, path } = parseRepoSpec(repo); 73 127 if (host === "tangled") { ··· 168 222 } 169 223 } 170 224 225 + async getFont(pluginId, version, repo, file) { 226 + let bytes; 227 + if (pluginId.endsWith("__LOCAL")) { 228 + const response = await fetch(`/plugins-local/${pluginId}/${file}`); 229 + if (!response.ok) throw new Error(`HTTP ${response.status}`); 230 + bytes = await response.arrayBuffer(); 231 + } else { 232 + if (!version || !repo) { 233 + throw new Error("Version and repo are required"); 234 + } 235 + const url = remoteAssetUrl({ repo, file, release: version }); 236 + const response = await this.pluginCache.fetch(url); 237 + bytes = await response.arrayBuffer(); 238 + } 239 + assertFontMagicBytes(file, bytes); 240 + const mime = /\.woff2$/i.test(file) ? "font/woff2" : "font/woff"; 241 + return new Blob([bytes], { type: mime }); 242 + } 243 + 171 244 async getReadme(pluginId, repo) { 172 245 if (pluginId.endsWith("__LOCAL")) { 173 246 const response = await fetch(`/plugins-local/${pluginId}/README.md`); ··· 195 268 if (pluginId.endsWith("__LOCAL")) { 196 269 return []; 197 270 } 198 - return [ 271 + const urls = [ 199 272 remoteAssetUrl({ repo, file: "manifest.json", release: version }), 200 273 remoteAssetUrl({ repo, file: "main.js", release: version }), 201 274 remoteAssetUrl({ repo, file: "styles.css", release: version }), 202 275 ]; 276 + try { 277 + const manifest = await this.getManifest(pluginId, version, repo); 278 + for (const font of manifest.fonts ?? []) { 279 + urls.push(remoteAssetUrl({ repo, file: font.file, release: version })); 280 + } 281 + } catch { 282 + // If the manifest can't be read the base URLs are still returned so 283 + // reconcile doesn't purge a partially-cached plugin. 284 + } 285 + return urls; 203 286 } 204 287 }
+102
tests/unit/specs/plugins/pluginStylesLoader.test.js
··· 411 411 }); 412 412 }); 413 413 414 + describe("PluginStylesLoader.mountFonts", () => { 415 + let env; 416 + let createdUrls; 417 + let revokedUrls; 418 + let originalCreate; 419 + let originalRevoke; 420 + beforeEach(() => { 421 + env = stubCssEnv(); 422 + createdUrls = []; 423 + revokedUrls = []; 424 + originalCreate = URL.createObjectURL; 425 + originalRevoke = URL.revokeObjectURL; 426 + let counter = 0; 427 + URL.createObjectURL = () => { 428 + const url = `blob:mock/${++counter}`; 429 + createdUrls.push(url); 430 + return url; 431 + }; 432 + URL.revokeObjectURL = (url) => { 433 + revokedUrls.push(url); 434 + }; 435 + }); 436 + afterEach(() => { 437 + URL.createObjectURL = originalCreate; 438 + URL.revokeObjectURL = originalRevoke; 439 + env.restore(); 440 + }); 441 + 442 + const desc = (overrides = {}) => ({ 443 + family: "MyFont", 444 + weight: "400", 445 + style: "normal", 446 + file: "fonts/myfont.woff2", 447 + blob: { size: 0, type: "font/woff2" }, 448 + ...overrides, 449 + }); 450 + 451 + it("adopts a font sheet and creates one object URL per descriptor", () => { 452 + const loader = new PluginStylesLoader(); 453 + loader.mountFonts("plugin-a", [ 454 + desc(), 455 + desc({ weight: "700", file: "fonts/myfont-bold.woff2" }), 456 + ]); 457 + assert.deepEqual(env.adoptedStyleSheets.length, 1); 458 + assert.deepEqual(createdUrls.length, 2); 459 + }); 460 + 461 + it("is a no-op for an empty or missing descriptor list", () => { 462 + const loader = new PluginStylesLoader(); 463 + loader.mountFonts("plugin-a", []); 464 + loader.mountFonts("plugin-a", null); 465 + assert.deepEqual(env.adoptedStyleSheets.length, 0); 466 + assert.deepEqual(createdUrls.length, 0); 467 + }); 468 + 469 + it("unmount removes the font sheet and revokes every object URL", () => { 470 + const loader = new PluginStylesLoader(); 471 + loader.mount("plugin-a", ".a { color: red; }"); 472 + loader.mountFonts("plugin-a", [desc(), desc({ file: "fonts/b.woff2" })]); 473 + assert.deepEqual(env.adoptedStyleSheets.length, 2); 474 + loader.unmount("plugin-a"); 475 + assert.deepEqual(env.adoptedStyleSheets.length, 0); 476 + assert.deepEqual(revokedUrls.sort(), createdUrls.sort()); 477 + }); 478 + 479 + it("defaults weight to 400 and style to normal when omitted", () => { 480 + const loader = new PluginStylesLoader(); 481 + loader.mountFonts("plugin-a", [ 482 + { family: "MyFont", file: "fonts/f.woff2", blob: { size: 0 } }, 483 + ]); 484 + assert.deepEqual(env.adoptedStyleSheets.length, 1); 485 + }); 486 + 487 + it("throws (without leaking object URLs) on invalid weight/style", () => { 488 + const loader = new PluginStylesLoader(); 489 + for (const bad of [ 490 + { weight: "1200" }, 491 + { weight: "500 200" }, 492 + { style: "backwards" }, 493 + ]) { 494 + let caught = null; 495 + try { 496 + loader.mountFonts("plugin-a", [desc(bad)]); 497 + } catch (error) { 498 + caught = error; 499 + } 500 + assert(caught, `expected mountFonts(${JSON.stringify(bad)}) to throw`); 501 + } 502 + assert.deepEqual(createdUrls.length, 0); 503 + assert.deepEqual(env.adoptedStyleSheets.length, 0); 504 + }); 505 + 506 + it("remounting fonts revokes the prior URLs", () => { 507 + const loader = new PluginStylesLoader(); 508 + loader.mountFonts("plugin-a", [desc()]); 509 + const first = [...createdUrls]; 510 + loader.mountFonts("plugin-a", [desc({ file: "fonts/b.woff2" })]); 511 + assert.deepEqual(revokedUrls, first); 512 + assert.deepEqual(env.adoptedStyleSheets.length, 1); 513 + }); 514 + }); 515 + 414 516 describe("PluginStylesLoader.unmount with snippets", () => { 415 517 let env; 416 518 beforeEach(() => {
+196
tests/unit/specs/sourceProvider.test.js
··· 326 326 }); 327 327 }); 328 328 329 + function woff2Bytes(extra = 8) { 330 + const bytes = new Uint8Array(4 + extra); 331 + bytes[0] = 0x77; 332 + bytes[1] = 0x4f; 333 + bytes[2] = 0x46; 334 + bytes[3] = 0x32; 335 + return bytes; 336 + } 337 + 338 + function woffBytes(extra = 8) { 339 + const bytes = new Uint8Array(4 + extra); 340 + bytes[0] = 0x77; 341 + bytes[1] = 0x4f; 342 + bytes[2] = 0x46; 343 + bytes[3] = 0x46; 344 + return bytes; 345 + } 346 + 347 + function fontResponse(bytes) { 348 + return { 349 + ok: true, 350 + status: 200, 351 + async arrayBuffer() { 352 + return bytes.buffer.slice( 353 + bytes.byteOffset, 354 + bytes.byteOffset + bytes.byteLength, 355 + ); 356 + }, 357 + }; 358 + } 359 + 360 + describe("parsePluginManifest fonts", () => { 361 + const base = { id: "alpha", name: "A", version: "1.0.0" }; 362 + 363 + async function parseViaLocal(manifest) { 364 + const stub = stubFetch(async () => jsonResponse(manifest)); 365 + try { 366 + return await new SourceProvider(null).getManifest("alpha__LOCAL"); 367 + } finally { 368 + stub.restore(); 369 + } 370 + } 371 + 372 + it("accepts a valid fonts array and preserves author fields", async () => { 373 + const manifest = await parseViaLocal({ 374 + ...base, 375 + fonts: [ 376 + { family: "MyFont", file: "fonts/myfont.woff2" }, 377 + { 378 + family: "MyFont", 379 + file: "fonts/myfont-bold.woff2", 380 + weight: "700", 381 + style: "italic", 382 + }, 383 + ], 384 + }); 385 + assert.deepEqual(manifest.fonts, [ 386 + { family: "MyFont", file: "fonts/myfont.woff2" }, 387 + { 388 + family: "MyFont", 389 + file: "fonts/myfont-bold.woff2", 390 + weight: "700", 391 + style: "italic", 392 + }, 393 + ]); 394 + }); 395 + 396 + it("rejects fonts that is not an array", async () => { 397 + let caught = null; 398 + try { 399 + await parseViaLocal({ ...base, fonts: "nope" }); 400 + } catch (error) { 401 + caught = error; 402 + } 403 + assert(caught?.message.includes("fonts must be an array")); 404 + }); 405 + 406 + const bad = [ 407 + [{ file: "fonts/f.woff2" }, `missing required field "family"`], 408 + [{ family: "F" }, `missing required field "file"`], 409 + [{ family: "F", file: "fonts/f.ttf" }, "must end in .woff2 or .woff"], 410 + [{ family: "F", file: "/abs.woff2" }, "must be a relative path"], 411 + [ 412 + { family: "F", file: "https://evil.test/f.woff2" }, 413 + "must be a relative path", 414 + ], 415 + [{ family: "F", file: "../escape.woff2" }, "must be a relative path"], 416 + ]; 417 + for (const [entry, fragment] of bad) { 418 + it(`rejects ${JSON.stringify(entry)}`, async () => { 419 + let caught = null; 420 + try { 421 + await parseViaLocal({ ...base, fonts: [entry] }); 422 + } catch (error) { 423 + caught = error; 424 + } 425 + assert( 426 + caught?.message.includes(fragment), 427 + `expected "${caught?.message}" to include "${fragment}"`, 428 + ); 429 + }); 430 + } 431 + }); 432 + 433 + describe("SourceProvider.getFont", () => { 434 + it("returns a Blob for a valid remote woff2", async () => { 435 + const bytes = woff2Bytes(); 436 + const pluginCache = fakePluginCache(async () => fontResponse(bytes)); 437 + const provider = new SourceProvider(pluginCache); 438 + const blob = await provider.getFont( 439 + "alpha", 440 + "1.0.0", 441 + "ow/alpha", 442 + "fonts/f.woff2", 443 + ); 444 + assert.deepEqual( 445 + pluginCache.calls[0].url, 446 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.0.0/fonts/f.woff2", 447 + ); 448 + assert(blob instanceof Blob); 449 + assert.deepEqual(blob.type, "font/woff2"); 450 + assert.deepEqual(blob.size, bytes.byteLength); 451 + }); 452 + 453 + it("returns a Blob for a valid local woff", async () => { 454 + const bytes = woffBytes(); 455 + const stub = stubFetch(async () => fontResponse(bytes)); 456 + try { 457 + const blob = await new SourceProvider(null).getFont( 458 + "alpha__LOCAL", 459 + null, 460 + null, 461 + "fonts/f.woff", 462 + ); 463 + assert.deepEqual( 464 + stub.calls[0].url, 465 + "/plugins-local/alpha__LOCAL/fonts/f.woff", 466 + ); 467 + assert.deepEqual(blob.type, "font/woff"); 468 + } finally { 469 + stub.restore(); 470 + } 471 + }); 472 + 473 + it("rejects a woff2 filename whose bytes aren't a woff2", async () => { 474 + const notFont = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]); 475 + const pluginCache = fakePluginCache(async () => fontResponse(notFont)); 476 + const provider = new SourceProvider(pluginCache); 477 + let caught = null; 478 + try { 479 + await provider.getFont("alpha", "1.0.0", "ow/alpha", "fonts/f.woff2"); 480 + } catch (error) { 481 + caught = error; 482 + } 483 + assert(caught?.message.includes("invalid magic bytes")); 484 + }); 485 + 486 + it("rejects a woff2 file whose bytes are a woff", async () => { 487 + const bytes = woffBytes(); 488 + const pluginCache = fakePluginCache(async () => fontResponse(bytes)); 489 + const provider = new SourceProvider(pluginCache); 490 + let caught = null; 491 + try { 492 + await provider.getFont("alpha", "1.0.0", "ow/alpha", "fonts/f.woff2"); 493 + } catch (error) { 494 + caught = error; 495 + } 496 + assert(caught?.message.includes("invalid magic bytes")); 497 + }); 498 + }); 499 + 500 + describe("SourceProvider.getCacheUrls with fonts", () => { 501 + it("includes each declared font URL", async () => { 502 + const pluginCache = fakePluginCache(async () => 503 + jsonResponse({ 504 + id: "alpha", 505 + name: "A", 506 + version: "1.2.3", 507 + fonts: [ 508 + { family: "F", file: "fonts/a.woff2" }, 509 + { family: "F", file: "fonts/b.woff2", weight: "700" }, 510 + ], 511 + }), 512 + ); 513 + const provider = new SourceProvider(pluginCache); 514 + const urls = await provider.getCacheUrls("alpha", "1.2.3", "ow/alpha"); 515 + assert.deepEqual(urls, [ 516 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.2.3/manifest.json", 517 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.2.3/main.js", 518 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.2.3/styles.css", 519 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.2.3/fonts/a.woff2", 520 + "https://raw.githubusercontent.com/ow/alpha/refs/tags/1.2.3/fonts/b.woff2", 521 + ]); 522 + }); 523 + }); 524 + 329 525 describe("SourceProvider with tangled.sh-hosted plugins", () => { 330 526 it("fetches a versioned manifest from tangled.org via plugin cache", async () => { 331 527 const pluginCache = fakePluginCache(async () =>