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).
- Notification feedback via
NotificationService. - 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 INotificationService,
} 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 notifService = context.getService<INotificationService>('notifications');
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 notifService.send({ 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, notifService },
});
}
src/BookmarksView.svelte
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { ActionContext, ActionCategory } from 'asyar-sdk';
import type { IActionService, INotificationService } from 'asyar-sdk';
interface Props {
actionService: IActionService;
notifService: INotificationService;
}
let { actionService, notifService }: 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 notifService.send({
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>