From 74aa5fc026434e2a6c47d7f14549db27b97df4e4 Mon Sep 17 00:00:00 2001 From: Taiki Katayama / Ankh Date: Thu, 12 Mar 2026 09:22:51 +0900 Subject: [PATCH] Add Dynamic Placeholders support for bookmark URLs Support {clipboard} and {argument name="..." default="..."} placeholders in bookmark URLs, similar to Raycast QuickLinks. Clipboard placeholders auto-insert clipboard contents, while argument placeholders prompt for user input via a form before opening the URL. Co-Authored-By: Claude Opus 4.6 --- README.md | 30 ++++++++ src/search-bookmarks.tsx | 150 +++++++++++++++++++++++++++++++++------ 2 files changed, 160 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index d9b8eec..ff291a9 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ If you already have a config file at `~/.config/fzf-bookmark-opener/config.yaml` - Fuzzy search across all bookmarks - Favicon display for each bookmark - Open in browser or copy URL to clipboard +- Edit bookmark title and URL directly from Raycast +- Dynamic Placeholders in URLs (like Raycast QuickLinks) + - `{clipboard}` — automatically inserts clipboard contents into the URL + - `{argument name="..." default="..."}` — prompts for input before opening - YAML-based configuration ## Installation @@ -42,6 +46,32 @@ bookmarks: Each entry requires `title` and `url` fields. Comments (`#`) can be used to organize bookmarks by category. +### Dynamic Placeholders + +You can use Dynamic Placeholders in bookmark URLs, similar to [Raycast QuickLinks](https://manual.raycast.com/quicklinks). + +#### Clipboard + +Use `{clipboard}` to insert the current clipboard text into the URL: + +```yaml +bookmarks: + - title: "Google Search (Clipboard)" + url: "https://google.com/search?q={clipboard}" +``` + +#### Argument + +Use `{argument name="..."}` to prompt for input when opening the bookmark: + +```yaml +bookmarks: + - title: "Google Translate" + url: "https://translate.google.com/?sl={argument name="source" default="auto"}&tl={argument name="target"}&text={argument name="word"}&op=translate" +``` + +You can also combine `{clipboard}` and `{argument}` in the same URL. + ## License MIT diff --git a/src/search-bookmarks.tsx b/src/search-bookmarks.tsx index 7ff1272..77bf581 100644 --- a/src/search-bookmarks.tsx +++ b/src/search-bookmarks.tsx @@ -5,10 +5,12 @@ import { useState } from "react"; import { Action, ActionPanel, + Clipboard, Form, getPreferenceValues, Icon, List, + open, showToast, Toast, useNavigation, @@ -54,6 +56,58 @@ function loadBookmarks(configPath: string): { bookmarks: Bookmark[]; error?: str } } +interface ArgumentPlaceholder { + name: string; + defaultValue?: string; +} + +function parseArgumentPlaceholders(url: string): ArgumentPlaceholder[] { + const regex = /\{argument\s+([^}]*)\}/g; + const seen = new Set(); + const result: ArgumentPlaceholder[] = []; + + for (const match of url.matchAll(regex)) { + const attrs = match[1]; + const nameMatch = attrs.match(/name="([^"]+)"/); + if (!nameMatch) continue; + + const name = nameMatch[1]; + if (seen.has(name)) continue; + seen.add(name); + + const defaultMatch = attrs.match(/default="([^"]+)"/); + result.push({ name, defaultValue: defaultMatch?.[1] }); + } + + return result; +} + +function hasClipboardPlaceholder(url: string): boolean { + return url.includes("{clipboard}"); +} + +async function resolveUrl(url: string, argumentValues?: Record): Promise { + let resolved = url; + + if (hasClipboardPlaceholder(resolved)) { + const clipboardText = (await Clipboard.readText()) ?? ""; + resolved = resolved.replaceAll("{clipboard}", encodeURIComponent(clipboardText)); + } + + if (argumentValues) { + for (const [name, value] of Object.entries(argumentValues)) { + const regex = new RegExp(`\\{argument\\s+[^}]*name="${name}"[^}]*\\}`, "g"); + resolved = resolved.replace(regex, encodeURIComponent(value)); + } + } + + return resolved; +} + +function stripPlaceholders(url: string): string { + return url.replace(/\{clipboard\}/g, "").replace(/\{argument\s+[^}]*\}/g, ""); +} + function saveBookmarkEdit( configPath: string, oldBookmark: Bookmark, @@ -132,6 +186,38 @@ function EditBookmarkForm(props: { bookmark: Bookmark; configPath: string; onEdi ); } +function ArgumentForm(props: { bookmark: Bookmark }) { + const { pop } = useNavigation(); + const { bookmark } = props; + const placeholders = parseArgumentPlaceholders(bookmark.url); + + async function handleSubmit(values: Record) { + const empty = Object.entries(values).filter(([, v]) => !v.trim()); + if (empty.length > 0) { + await showToast({ style: Toast.Style.Failure, title: "All fields are required" }); + return; + } + + const resolved = await resolveUrl(bookmark.url, values); + await open(resolved); + pop(); + } + + return ( +
+ + + } + > + {placeholders.map((p) => ( + + ))} + + ); +} + export default function Command() { const { configPath } = getPreferenceValues(); @@ -151,26 +237,50 @@ export default function Command() { title={error ? "Failed to load bookmarks" : "No bookmarks found"} description={error || "Add bookmarks to your config.yaml file to get started."} /> - {bookmarks.map((bookmark, index) => ( - - - - } - /> - - } - /> - ))} + {bookmarks.map((bookmark, index) => { + const args = parseArgumentPlaceholders(bookmark.url); + const hasClipboard = hasClipboardPlaceholder(bookmark.url); + const hasDynamic = args.length > 0 || hasClipboard; + + return ( + + {args.length > 0 ? ( + } + /> + ) : hasClipboard ? ( + { + const resolved = await resolveUrl(bookmark.url); + await open(resolved); + }} + /> + ) : ( + + )} + + } + /> + + } + /> + ); + })} ); }