Bookmarks Example
17. Complete Example — Bookmarks Extension
A complete production-ready extension demonstrating:
- A view command (open the bookmarks list).
- A no-view command (save today's date as a bookmark).
- In-view search (filter bookmarks as the user types).
- A ⌘K action (clear all non-favorite bookmarks).
- Background feedback via
FeedbackService. - Svelte 5 Runes throughout.
manifest.json
{
"id": "com.yourname.bookmarks",
"name": "Bookmarks",
"version": "1.0.0",
"description": "Save and search your personal bookmarks quickly.",
"author": "Your Name",
"icon": "🔖",
"type": "extension",
"background": { "main": "dist/worker.js" },
"searchable": true,
"asyarSdk": "^2.7.0",
"permissions": ["notifications:send"],
"commands": [
{
"id": "open",
"name": "Open Bookmarks",
"description": "Browse your saved bookmarks",
"mode": "view",
"component": "BookmarksView"
},
{
"id": "add-today",
"name": "Bookmark Today's Date",
"description": "Saves today's date to your bookmarks",
"mode": "background"
}
]
}
src/main.ts
import { mount } from 'svelte';
import BookmarksView from './BookmarksView.svelte';
import { ExtensionContext, type IActionService, type IFeedbackService } from 'asyar-sdk';
const extensionId = window.location.hostname || 'com.yourname.bookmarks';
// 1. Single context — one per iframe lifetime.
const context = new ExtensionContext();
context.setExtensionId(extensionId);
// 2. Signal readiness to the host.
window.parent.postMessage({ type: 'asyar:extension:loaded', extensionId }, '*');
// 3. Forward ⌘K to the host.
window.addEventListener('keydown', (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
window.parent.postMessage(
{
type: 'asyar:extension:keydown',
payload: {
key: event.key,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
},
},
'*',
);
}
});
// 4. Resolve services once.
const feedbackService = context.getService<IFeedbackService>('feedback');
const actionService = context.getService<IActionService>('actions');
// 5. Handle no-view command (add-today) invoked by the host.
window.addEventListener('message', async (event) => {
if (event.data?.type !== 'asyar:invoke:command') return;
const { commandId } = event.data.payload;
if (commandId === 'add-today') {
const entry = new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
const stored: string[] = JSON.parse(localStorage.getItem('bookmarks') ?? '[]');
stored.unshift(entry);
localStorage.setItem('bookmarks', JSON.stringify(stored));
await feedbackService.sendBackground({ title: 'Bookmark Added', body: entry });
}
});
// 6. Mount the view for the correct ?view= param.
const viewName = new URLSearchParams(window.location.search).get('view') || 'BookmarksView';
if (viewName === 'BookmarksView') {
mount(BookmarksView, {
target: document.getElementById('app')!,
props: { actionService, feedbackService },
});
}
src/BookmarksView.svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { ActionContext, ActionCategory } from 'asyar-sdk';
import type { IActionService, IFeedbackService } from 'asyar-sdk';
interface Props {
actionService: IActionService;
feedbackService: IFeedbackService;
}
let { actionService, feedbackService }: Props = $props();
let bookmarks = $state<string[]>([]);
let query = $state('');
const filtered = $derived(
query.trim()
? bookmarks.filter((b) => b.toLowerCase().includes(query.toLowerCase()))
: bookmarks,
);
const ACTION_ID = 'com.yourname.bookmarks:clear-all';
// --- In-view search listener ---
function handleMessage(event: MessageEvent) {
if (event.source !== window.parent) return;
if (event.data?.type === 'asyar:view:search') {
query = event.data.payload?.query ?? '';
}
}
onMount(() => {
// Load from localStorage
bookmarks = JSON.parse(localStorage.getItem('bookmarks') ?? '[]');
// Register in-view search listener
window.addEventListener('message', handleMessage);
// Register ⌘K action
actionService.registerAction({
id: ACTION_ID,
title: 'Clear Non-Favorites',
description: 'Remove all bookmarks that are not starred',
icon: '🗑',
extensionId: 'com.yourname.bookmarks',
category: ActionCategory.DESTRUCTIVE,
context: ActionContext.EXTENSION_VIEW,
execute: clearNonFavorites,
});
});
onDestroy(() => {
window.removeEventListener('message', handleMessage);
actionService.unregisterAction(ACTION_ID); // Critical cleanup
});
function addBookmark() {
const entry = query.trim();
if (!entry) return;
bookmarks = [entry, ...bookmarks];
localStorage.setItem('bookmarks', JSON.stringify(bookmarks));
query = '';
}
function removeBookmark(index: number) {
bookmarks = bookmarks.filter((_, i) => i !== index);
localStorage.setItem('bookmarks', JSON.stringify(bookmarks));
}
async function clearNonFavorites() {
bookmarks = [];
localStorage.removeItem('bookmarks');
await feedbackService.sendBackground({
title: 'Bookmarks Cleared',
body: 'All bookmarks have been removed.',
});
}
</script>
<div class="container">
<header>
<h1>🔖 Bookmarks</h1>
<span class="count">{filtered.length} item{filtered.length !== 1 ? 's' : ''}</span>
</header>
{#if filtered.length === 0 && query}
<p class="empty">No bookmarks match "{query}"</p>
{:else if bookmarks.length === 0}
<p class="empty">No bookmarks yet. Type below and press Enter to add one.</p>
{:else}
<ul>
{#each filtered as bookmark, i}
<li>
<span>{bookmark}</span>
<button onclick={() => removeBookmark(i)} aria-label="Delete">✕</button>
</li>
{/each}
</ul>
{/if}
<footer>
<input
type="text"
bind:value={query}
placeholder="Add a bookmark or search..."
onkeydown={(e) => e.key === 'Enter' && addBookmark()}
/>
</footer>
</div>
<style>
.container {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-primary);
color: var(--text-primary);
font-family: system-ui, sans-serif;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem 0.5rem;
border-bottom: 1px solid var(--separator);
}
h1 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
.count {
font-size: 0.75rem;
opacity: 0.5;
}
ul {
list-style: none;
margin: 0;
padding: 0.5rem 0;
flex: 1;
overflow-y: auto;
}
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1.25rem;
border-radius: 6px;
margin: 0 0.5rem;
}
li:hover {
background: var(--bg-secondary);
}
li button {
background: none;
border: none;
cursor: pointer;
opacity: 0.4;
font-size: 0.75rem;
color: var(--text-primary);
}
li button:hover {
opacity: 1;
}
.empty {
padding: 2rem 1.25rem;
opacity: 0.5;
font-size: 0.875rem;
}
footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--separator);
}
input {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--separator);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.875rem;
outline: none;
box-sizing: border-box;
}
input:focus {
border-color: var(--accent-primary);
}
</style>