|
| 1 | +--- |
| 2 | +title: Build Your Own Adapter |
| 3 | +description: Integrate Logo Soup with any framework using the core engine's subscribe/getSnapshot API. |
| 4 | +--- |
| 5 | + |
| 6 | +Every first-party adapter follows the same pattern. If your framework isn't listed, you can build an adapter in 20-40 lines. |
| 7 | + |
| 8 | +## The Pattern |
| 9 | + |
| 10 | +```ts |
| 11 | +import { createLogoSoup } from "@sanity-labs/logo-soup"; |
| 12 | + |
| 13 | +// 1. Create an engine instance |
| 14 | +const engine = createLogoSoup(); |
| 15 | + |
| 16 | +// 2. Subscribe — push snapshots into your framework's reactivity |
| 17 | +const unsubscribe = engine.subscribe(() => { |
| 18 | + const snapshot = engine.getSnapshot(); |
| 19 | + // yourFramework.setState(snapshot) |
| 20 | +}); |
| 21 | + |
| 22 | +// 3. Process — call when options change |
| 23 | +engine.process({ logos: ["a.svg", "b.svg"], baseSize: 48 }); |
| 24 | + |
| 25 | +// 4. Destroy — call on teardown |
| 26 | +unsubscribe(); |
| 27 | +engine.destroy(); |
| 28 | +``` |
| 29 | + |
| 30 | +## Example: Preact 10.x |
| 31 | + |
| 32 | +Preact exposes `useSyncExternalStore` via `preact/compat` — the same API React uses. The adapter is nearly identical to the React one. |
| 33 | + |
| 34 | +```tsx |
| 35 | +// use-logo-soup.ts |
| 36 | +import { useRef, useCallback, useEffect } from "preact/hooks"; |
| 37 | +import { useSyncExternalStore } from "preact/compat"; |
| 38 | +import { createLogoSoup } from "@sanity-labs/logo-soup"; |
| 39 | +import type { ProcessOptions, LogoSoupState } from "@sanity-labs/logo-soup"; |
| 40 | + |
| 41 | +const SERVER_SNAPSHOT: LogoSoupState = { |
| 42 | + status: "idle", |
| 43 | + normalizedLogos: [], |
| 44 | + error: null, |
| 45 | +}; |
| 46 | + |
| 47 | +export function useLogoSoup(options: ProcessOptions) { |
| 48 | + const engineRef = useRef(createLogoSoup()); |
| 49 | + const engine = engineRef.current; |
| 50 | + |
| 51 | + const subscribe = useCallback( |
| 52 | + (cb: () => void) => engine.subscribe(cb), |
| 53 | + [engine], |
| 54 | + ); |
| 55 | + const getSnapshot = useCallback(() => engine.getSnapshot(), [engine]); |
| 56 | + |
| 57 | + const state = useSyncExternalStore( |
| 58 | + subscribe, |
| 59 | + getSnapshot, |
| 60 | + () => SERVER_SNAPSHOT, |
| 61 | + ); |
| 62 | + |
| 63 | + useEffect(() => { |
| 64 | + engine.process(options); |
| 65 | + }, [engine, options.logos, options.baseSize, options.scaleFactor]); |
| 66 | + |
| 67 | + useEffect(() => () => engine.destroy(), [engine]); |
| 68 | + |
| 69 | + return state; |
| 70 | +} |
| 71 | +``` |
| 72 | + |
| 73 | +```tsx |
| 74 | +// usage |
| 75 | +import { useLogoSoup } from "./use-logo-soup"; |
| 76 | +import { getVisualCenterTransform } from "@sanity-labs/logo-soup"; |
| 77 | + |
| 78 | +function LogoStrip() { |
| 79 | + const { status, normalizedLogos } = useLogoSoup({ |
| 80 | + logos: ["/logos/acme.svg", "/logos/globex.svg"], |
| 81 | + }); |
| 82 | + |
| 83 | + if (status !== "ready") return null; |
| 84 | + |
| 85 | + return ( |
| 86 | + <div style={{ textAlign: "center" }}> |
| 87 | + {normalizedLogos.map((logo) => ( |
| 88 | + <img |
| 89 | + key={logo.src} |
| 90 | + src={logo.src} |
| 91 | + alt={logo.alt} |
| 92 | + width={logo.normalizedWidth} |
| 93 | + height={logo.normalizedHeight} |
| 94 | + style={{ |
| 95 | + transform: getVisualCenterTransform(logo, "visual-center-y"), |
| 96 | + }} |
| 97 | + /> |
| 98 | + ))} |
| 99 | + </div> |
| 100 | + ); |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +## Example: Lit 3.x |
| 105 | + |
| 106 | +Lit uses `ReactiveController` to encapsulate reusable logic that hooks into a component's update cycle. The controller subscribes to the engine and calls `host.requestUpdate()` when state changes. |
| 107 | + |
| 108 | +```ts |
| 109 | +// logo-soup-controller.ts |
| 110 | +import { type ReactiveController, type ReactiveControllerHost } from "lit"; |
| 111 | +import { createLogoSoup } from "@sanity-labs/logo-soup"; |
| 112 | +import type { ProcessOptions, LogoSoupState } from "@sanity-labs/logo-soup"; |
| 113 | + |
| 114 | +export class LogoSoupController implements ReactiveController { |
| 115 | + private engine = createLogoSoup(); |
| 116 | + private unsubscribe: (() => void) | null = null; |
| 117 | + |
| 118 | + state: LogoSoupState = this.engine.getSnapshot(); |
| 119 | + |
| 120 | + constructor(private host: ReactiveControllerHost) { |
| 121 | + host.addController(this); |
| 122 | + } |
| 123 | + |
| 124 | + hostConnected() { |
| 125 | + this.unsubscribe = this.engine.subscribe(() => { |
| 126 | + this.state = this.engine.getSnapshot(); |
| 127 | + this.host.requestUpdate(); |
| 128 | + }); |
| 129 | + } |
| 130 | + |
| 131 | + hostDisconnected() { |
| 132 | + this.unsubscribe?.(); |
| 133 | + this.engine.destroy(); |
| 134 | + } |
| 135 | + |
| 136 | + process(options: ProcessOptions) { |
| 137 | + this.engine.process(options); |
| 138 | + } |
| 139 | +} |
| 140 | +``` |
| 141 | + |
| 142 | +```ts |
| 143 | +// usage |
| 144 | +import { LitElement, html } from "lit"; |
| 145 | +import { customElement, property } from "lit/decorators.js"; |
| 146 | +import { getVisualCenterTransform } from "@sanity-labs/logo-soup"; |
| 147 | +import { LogoSoupController } from "./logo-soup-controller"; |
| 148 | + |
| 149 | +@customElement("logo-strip") |
| 150 | +export class LogoStrip extends LitElement { |
| 151 | + private soup = new LogoSoupController(this); |
| 152 | + |
| 153 | + @property({ type: Array }) logos: string[] = []; |
| 154 | + @property({ type: Number }) baseSize = 48; |
| 155 | + |
| 156 | + updated(changed: Map<string, unknown>) { |
| 157 | + if (changed.has("logos") || changed.has("baseSize")) { |
| 158 | + this.soup.process({ logos: this.logos, baseSize: this.baseSize }); |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | + render() { |
| 163 | + if (this.soup.state.status !== "ready") return html``; |
| 164 | + |
| 165 | + return html` |
| 166 | + <div style="text-align: center"> |
| 167 | + ${this.soup.state.normalizedLogos.map( |
| 168 | + (logo) => html` |
| 169 | + <img |
| 170 | + src=${logo.src} |
| 171 | + alt=${logo.alt} |
| 172 | + width=${logo.normalizedWidth} |
| 173 | + height=${logo.normalizedHeight} |
| 174 | + style="display:inline-block;margin:0 14px;transform:${getVisualCenterTransform( |
| 175 | + logo, |
| 176 | + "visual-center-y", |
| 177 | + ) ?? "none"}" |
| 178 | + /> |
| 179 | + `, |
| 180 | + )} |
| 181 | + </div> |
| 182 | + `; |
| 183 | + } |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +## Checklist |
| 188 | + |
| 189 | +| Concern | What to do | |
| 190 | +| ------------- | ------------------------------------------------------------------------------------------- | |
| 191 | +| **Create** | Call `createLogoSoup()` once per component instance | |
| 192 | +| **Subscribe** | Push `engine.getSnapshot()` into your reactive state on each notification | |
| 193 | +| **Process** | Call `engine.process(options)` when inputs change | |
| 194 | +| **Cleanup** | Call both `unsubscribe()` and `engine.destroy()` on teardown | |
| 195 | +| **Stability** | Store the engine in a ref/field — don't recreate it on every render | |
| 196 | +| **SSR** | The engine needs `<canvas>`, so guard behind a client-side check if your framework does SSR | |
| 197 | + |
| 198 | +## How Our First-Party Adapters Map |
| 199 | + |
| 200 | +| Framework | Reactive primitive | Subscribe mechanism | Cleanup | |
| 201 | +| --------- | ---------------------- | -------------------------------------------------- | ------------------------- | |
| 202 | +| React | `useSyncExternalStore` | Engine's `subscribe`/`getSnapshot` directly | `useEffect` return | |
| 203 | +| Vue | `shallowRef` | `engine.subscribe()` → `ref.value = snapshot` | `onScopeDispose` | |
| 204 | +| Svelte | `createSubscriber` | Getter calls `subscribe()` before reading | `$effect` teardown | |
| 205 | +| Solid | `from()` | Producer function `(set) => engine.subscribe(...)` | `onCleanup` | |
| 206 | +| Angular | `signal()` | `engine.subscribe()` → `_state.set(snapshot)` | `DestroyRef.onDestroy` | |
| 207 | +| jQuery | `$.data()` | `engine.subscribe()` → re-render DOM | `$el.logoSoup('destroy')` | |
| 208 | + |
| 209 | +Each adapter is 30-80 lines. The source is at [`src/react`](https://github.com/sanity-labs/logo-soup/tree/main/src/react), [`src/vue`](https://github.com/sanity-labs/logo-soup/tree/main/src/vue), [`src/svelte`](https://github.com/sanity-labs/logo-soup/tree/main/src/svelte), [`src/solid`](https://github.com/sanity-labs/logo-soup/tree/main/src/solid), [`src/angular`](https://github.com/sanity-labs/logo-soup/tree/main/src/angular), and [`src/jquery`](https://github.com/sanity-labs/logo-soup/tree/main/src/jquery). |
| 210 | + |
| 211 | +<Tip> |
| 212 | + Built an adapter for a framework we don't support? [Let us |
| 213 | + know](https://github.com/sanity-labs/logo-soup/issues) — we'll link to it from |
| 214 | + the docs. |
| 215 | +</Tip> |
0 commit comments