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>