feat: Add subscription component 597d4cb3
Resolves #16
Heath Stewart · 2026-02-20 23:58 2 file(s) · +422 −10
packages/cli/src/commands/add.ts +21 −10
14 14
15 15
const DEFAULT_COMPONENTS_PATH = "src/components";
16 16
17 -
const AVAILABLE_COMPONENTS = ["sequoia-comments"];
17 +
const AVAILABLE_COMPONENTS: { name: string; notes?: string }[] = [
18 +
	{
19 +
		name: "sequoia-comments",
20 +
		notes:
21 +
			`The component will automatically read the document URI from:\n` +
22 +
			`<link rel="site.standard.document" href="at://...">`,
23 +
	},
24 +
	{
25 +
		name: "sequoia-subscribe",
26 +
	},
27 +
];
18 28
19 29
export const addCommand = command({
20 30
	name: "add",
30 40
		intro("Add Sequoia Component");
31 41
32 42
		// Validate component name
33 -
		if (!AVAILABLE_COMPONENTS.includes(componentName)) {
43 +
		const component = AVAILABLE_COMPONENTS.find((c) => c.name === componentName);
44 +
		if (!component) {
34 45
			log.error(`Component '${componentName}' not found`);
35 46
			log.info("Available components:");
36 47
			for (const comp of AVAILABLE_COMPONENTS) {
37 -
				log.info(`  - ${comp}`);
48 +
				log.info(`  - ${comp.name}`);
38 49
			}
39 50
			process.exit(1);
40 51
		}
143 154
		}
144 155
145 156
		// Show usage instructions
146 -
		note(
157 +
		let notes =
147 158
			`Add to your HTML:\n\n` +
148 -
				`<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` +
149 -
				`<${componentName}></${componentName}>\n\n` +
150 -
				`The component will automatically read the document URI from:\n` +
151 -
				`<link rel="site.standard.document" href="at://...">`,
152 -
			"Usage",
153 -
		);
159 +
			`<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` +
160 +
			`<${componentName}></${componentName}>\n`;
161 +
		if (component.notes) {
162 +
			notes += `\n${component.notes}`;
163 +
		}
164 +
		note(notes, "Usage");
154 165
155 166
		outro(`${componentName} added successfully!`);
156 167
	},
packages/cli/src/components/sequoia-subscribe.js (added) +401 −0
1 +
/**
2 +
 * Sequoia Subscribe - A Bluesky-powered subscribe component
3 +
 *
4 +
 * A self-contained Web Component that lets users subscribe to a publication
5 +
 * via the AT Protocol by creating a site.standard.graph.subscription record.
6 +
 *
7 +
 * Usage:
8 +
 *   <sequoia-subscribe></sequoia-subscribe>
9 +
 *
10 +
 * The component resolves the publication AT URI from the host site's
11 +
 * /.well-known/site.standard.publication endpoint.
12 +
 *
13 +
 * Attributes:
14 +
 *   - publication-uri: Override the publication AT URI (optional)
15 +
 *   - label: Button label text (default: "Subscribe on Bluesky")
16 +
 *
17 +
 * CSS Custom Properties:
18 +
 *   - --sequoia-fg-color: Text color (default: #1f2937)
19 +
 *   - --sequoia-bg-color: Background color (default: #ffffff)
20 +
 *   - --sequoia-border-color: Border color (default: #e5e7eb)
21 +
 *   - --sequoia-accent-color: Accent/button color (default: #2563eb)
22 +
 *   - --sequoia-secondary-color: Secondary text color (default: #6b7280)
23 +
 *   - --sequoia-border-radius: Border radius (default: 8px)
24 +
 *
25 +
 * Events:
26 +
 *   - sequoia-subscribed: Fired when the subscription is created successfully.
27 +
 *     detail: { publicationUri: string, recordUri: string }
28 +
 *   - sequoia-subscribe-error: Fired when the subscription fails.
29 +
 *     detail: { message: string }
30 +
 */
31 +
32 +
// ============================================================================
33 +
// Styles
34 +
// ============================================================================
35 +
36 +
const styles = `
37 +
:host {
38 +
	display: inline-block;
39 +
	font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
40 +
	color: var(--sequoia-fg-color, #1f2937);
41 +
	line-height: 1.5;
42 +
}
43 +
44 +
* {
45 +
	box-sizing: border-box;
46 +
}
47 +
48 +
.sequoia-subscribe-button {
49 +
	display: inline-flex;
50 +
	align-items: center;
51 +
	gap: 0.375rem;
52 +
	padding: 0.5rem 1rem;
53 +
	background: var(--sequoia-accent-color, #2563eb);
54 +
	color: #ffffff;
55 +
	border: none;
56 +
	border-radius: var(--sequoia-border-radius, 8px);
57 +
	font-size: 0.875rem;
58 +
	font-weight: 500;
59 +
	cursor: pointer;
60 +
	text-decoration: none;
61 +
	transition: background-color 0.15s ease;
62 +
	font-family: inherit;
63 +
}
64 +
65 +
.sequoia-subscribe-button:hover:not(:disabled) {
66 +
	background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
67 +
}
68 +
69 +
.sequoia-subscribe-button:disabled {
70 +
	opacity: 0.6;
71 +
	cursor: not-allowed;
72 +
}
73 +
74 +
.sequoia-subscribe-button svg {
75 +
	width: 1rem;
76 +
	height: 1rem;
77 +
	flex-shrink: 0;
78 +
}
79 +
80 +
.sequoia-subscribe-button--success {
81 +
	background: #16a34a;
82 +
}
83 +
84 +
.sequoia-subscribe-button--success:hover:not(:disabled) {
85 +
	background: color-mix(in srgb, #16a34a 85%, black);
86 +
}
87 +
88 +
.sequoia-loading-spinner {
89 +
	display: inline-block;
90 +
	width: 1rem;
91 +
	height: 1rem;
92 +
	border: 2px solid rgba(255, 255, 255, 0.4);
93 +
	border-top-color: #ffffff;
94 +
	border-radius: 50%;
95 +
	animation: sequoia-spin 0.8s linear infinite;
96 +
	flex-shrink: 0;
97 +
}
98 +
99 +
@keyframes sequoia-spin {
100 +
	to { transform: rotate(360deg); }
101 +
}
102 +
103 +
.sequoia-error-message {
104 +
	display: inline-block;
105 +
	font-size: 0.8125rem;
106 +
	color: #dc2626;
107 +
	margin-top: 0.375rem;
108 +
}
109 +
`;
110 +
111 +
// ============================================================================
112 +
// Icons
113 +
// ============================================================================
114 +
115 +
const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
116 +
  <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/>
117 +
</svg>`;
118 +
119 +
const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
120 +
  <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
121 +
</svg>`;
122 +
123 +
// ============================================================================
124 +
// AT Protocol Functions
125 +
// ============================================================================
126 +
127 +
/**
128 +
 * Resolve a DID to its PDS URL.
129 +
 * Supports did:plc and did:web methods.
130 +
 * @param {string} did - Decentralized Identifier
131 +
 * @returns {Promise<string>} PDS URL
132 +
 */
133 +
async function resolvePDS(did) {
134 +
	let pdsUrl;
135 +
136 +
	if (did.startsWith("did:plc:")) {
137 +
		const didDocUrl = `https://plc.directory/${did}`;
138 +
		const didDocResponse = await fetch(didDocUrl);
139 +
		if (!didDocResponse.ok) {
140 +
			throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
141 +
		}
142 +
		const didDoc = await didDocResponse.json();
143 +
144 +
		const pdsService = didDoc.service?.find(
145 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
146 +
		);
147 +
		pdsUrl = pdsService?.serviceEndpoint;
148 +
	} else if (did.startsWith("did:web:")) {
149 +
		const domain = did.replace("did:web:", "");
150 +
		const didDocUrl = `https://${domain}/.well-known/did.json`;
151 +
		const didDocResponse = await fetch(didDocUrl);
152 +
		if (!didDocResponse.ok) {
153 +
			throw new Error(`Could not fetch DID document: ${didDocResponse.status}`);
154 +
		}
155 +
		const didDoc = await didDocResponse.json();
156 +
157 +
		const pdsService = didDoc.service?.find(
158 +
			(s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer",
159 +
		);
160 +
		pdsUrl = pdsService?.serviceEndpoint;
161 +
	} else {
162 +
		throw new Error(`Unsupported DID method: ${did}`);
163 +
	}
164 +
165 +
	if (!pdsUrl) {
166 +
		throw new Error("Could not find PDS URL for user");
167 +
	}
168 +
169 +
	return pdsUrl;
170 +
}
171 +
172 +
/**
173 +
 * Create a site.standard.graph.subscription record in the subscriber's PDS.
174 +
 * @param {string} did - DID of the subscriber
175 +
 * @param {string} accessToken - AT Protocol access token
176 +
 * @param {string} publicationUri - AT URI of the publication to subscribe to
177 +
 * @returns {Promise<{uri: string, cid: string}>} The created record's URI and CID
178 +
 */
179 +
async function createRecord(did, accessToken, publicationUri) {
180 +
	const pdsUrl = await resolvePDS(did);
181 +
182 +
	const collection = "site.standard.graph.subscription";
183 +
	const url = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`;
184 +
	const response = await fetch(url, {
185 +
		method: "POST",
186 +
		headers: {
187 +
			"Content-Type": "application/json",
188 +
			Authorization: `Bearer ${accessToken}`,
189 +
		},
190 +
		body: JSON.stringify({
191 +
			repo: did,
192 +
			collection,
193 +
			record: {
194 +
				$type: "site.standard.graph.subscription",
195 +
				publication: publicationUri,
196 +
			},
197 +
		}),
198 +
	});
199 +
200 +
	if (!response.ok) {
201 +
		const body = await response.json().catch(() => ({}));
202 +
		const message = body?.message ?? body?.error ?? `HTTP ${response.status}`;
203 +
		throw new Error(`Failed to create record: ${message}`);
204 +
	}
205 +
206 +
	const data = await response.json();
207 +
	return { uri: data.uri, cid: data.cid };
208 +
}
209 +
210 +
/**
211 +
 * Fetch the publication AT URI from the host site's well-known endpoint.
212 +
 * @param {string} [origin] - Origin to fetch from (defaults to current page origin)
213 +
 * @returns {Promise<string>} Publication AT URI
214 +
 */
215 +
async function fetchPublicationUri(origin) {
216 +
	const base = origin ?? window.location.origin;
217 +
	const url = `${base}/.well-known/site.standard.publication`;
218 +
	const response = await fetch(url);
219 +
	if (!response.ok) {
220 +
		throw new Error(
221 +
			`Could not fetch publication URI: ${response.status}`,
222 +
		);
223 +
	}
224 +
225 +
	// Accept either plain text (the AT URI itself) or JSON with a `uri` field.
226 +
	const contentType = response.headers.get("content-type") ?? "";
227 +
	if (contentType.includes("application/json")) {
228 +
		const data = await response.json();
229 +
		const uri = data?.uri ?? data?.atUri ?? data?.publication;
230 +
		if (!uri) {
231 +
			throw new Error("Publication response did not contain a URI");
232 +
		}
233 +
		return uri;
234 +
	}
235 +
236 +
	const text = (await response.text()).trim();
237 +
	if (!text.startsWith("at://")) {
238 +
		throw new Error(`Unexpected publication URI format: ${text}`);
239 +
	}
240 +
	return text;
241 +
}
242 +
243 +
// ============================================================================
244 +
// Web Component
245 +
// ============================================================================
246 +
247 +
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
248 +
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
249 +
250 +
class SequoiaSubscribe extends BaseElement {
251 +
	constructor() {
252 +
		super();
253 +
		const shadow = this.attachShadow({ mode: "open" });
254 +
255 +
		const styleTag = document.createElement("style");
256 +
		styleTag.innerText = styles;
257 +
		shadow.appendChild(styleTag);
258 +
259 +
		const wrapper = document.createElement("div");
260 +
		shadow.appendChild(wrapper);
261 +
		wrapper.part = "container";
262 +
263 +
		this.wrapper = wrapper;
264 +
		this.state = { type: "idle" };
265 +
		this.render();
266 +
	}
267 +
268 +
	static get observedAttributes() {
269 +
		return ["publication-uri", "label"];
270 +
	}
271 +
272 +
	attributeChangedCallback() {
273 +
		// Reset to idle if attributes change after an error or success
274 +
		if (
275 +
			this.state.type === "error" ||
276 +
			this.state.type === "subscribed"
277 +
		) {
278 +
			this.state = { type: "idle" };
279 +
		}
280 +
		this.render();
281 +
	}
282 +
283 +
	get publicationUri() {
284 +
		return this.getAttribute("publication-uri") ?? null;
285 +
	}
286 +
287 +
	get label() {
288 +
		return this.getAttribute("label") ?? "Subscribe on Bluesky";
289 +
	}
290 +
291 +
	async handleClick() {
292 +
		if (this.state.type === "loading" || this.state.type === "subscribed") {
293 +
			return;
294 +
		}
295 +
296 +
		this.state = { type: "loading" };
297 +
		this.render();
298 +
299 +
		try {
300 +
			// Resolve the publication AT URI
301 +
			const publicationUri =
302 +
				this.publicationUri ?? (await fetchPublicationUri());
303 +
304 +
			// TODO: resolve authenticated DID and access token before calling createRecord
305 +
			const { uri: recordUri } = await createRecord(
306 +
				/* did */ undefined,
307 +
				/* accessToken */ undefined,
308 +
				publicationUri,
309 +
			);
310 +
311 +
			this.state = { type: "subscribed", recordUri, publicationUri };
312 +
			this.render();
313 +
314 +
			this.dispatchEvent(
315 +
				new CustomEvent("sequoia-subscribed", {
316 +
					bubbles: true,
317 +
					composed: true,
318 +
					detail: { publicationUri, recordUri },
319 +
				}),
320 +
			);
321 +
		} catch (error) {
322 +
			const message =
323 +
				error instanceof Error ? error.message : "Failed to subscribe";
324 +
			this.state = { type: "error", message };
325 +
			this.render();
326 +
327 +
			this.dispatchEvent(
328 +
				new CustomEvent("sequoia-subscribe-error", {
329 +
					bubbles: true,
330 +
					composed: true,
331 +
					detail: { message },
332 +
				}),
333 +
			);
334 +
		}
335 +
	}
336 +
337 +
	render() {
338 +
		const { type } = this.state;
339 +
		const isLoading = type === "loading";
340 +
		const isSubscribed = type === "subscribed";
341 +
342 +
		const icon = isLoading
343 +
			? `<span class="sequoia-loading-spinner"></span>`
344 +
			: isSubscribed
345 +
				? CHECK_ICON
346 +
				: BLUESKY_ICON;
347 +
348 +
		const label = isSubscribed ? "Subscribed" : this.label;
349 +
		const buttonClass = [
350 +
			"sequoia-subscribe-button",
351 +
			isSubscribed ? "sequoia-subscribe-button--success" : "",
352 +
		]
353 +
			.filter(Boolean)
354 +
			.join(" ");
355 +
356 +
		const errorHtml =
357 +
			type === "error"
358 +
				? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>`
359 +
				: "";
360 +
361 +
		this.wrapper.innerHTML = `
362 +
			<button
363 +
				class="${buttonClass}"
364 +
				type="button"
365 +
				part="button"
366 +
				${isLoading || isSubscribed ? "disabled" : ""}
367 +
				aria-label="${isSubscribed ? "Subscribed" : this.label}"
368 +
			>
369 +
				${icon}
370 +
				${label}
371 +
			</button>
372 +
			${errorHtml}
373 +
		`;
374 +
375 +
		if (type !== "subscribed") {
376 +
			const btn = this.wrapper.querySelector("button");
377 +
			btn?.addEventListener("click", () => this.handleClick());
378 +
		}
379 +
	}
380 +
}
381 +
382 +
/**
383 +
 * Escape HTML special characters (no DOM dependency for SSR).
384 +
 * @param {string} text
385 +
 * @returns {string}
386 +
 */
387 +
function escapeHtml(text) {
388 +
	return text
389 +
		.replace(/&/g, "&amp;")
390 +
		.replace(/</g, "&lt;")
391 +
		.replace(/>/g, "&gt;")
392 +
		.replace(/"/g, "&quot;");
393 +
}
394 +
395 +
// Register the custom element
396 +
if (typeof customElements !== "undefined") {
397 +
	customElements.define("sequoia-subscribe", SequoiaSubscribe);
398 +
}
399 +
400 +
// Export for module usage
401 +
export { SequoiaSubscribe };