User Templates Pattern
8.15 Pattern: user-authored templates with dynamic values
No SDK service required. This is a cookbook recipe showing how to build dynamic placeholder substitution — the same {clipboard} / {Selected Text} / {Date} token style used by the built-in Portals and Snippets features — using only existing SDK services. Asyar deliberately does not expose a PlaceholderService because the primitives needed to build one are already available: IClipboardHistoryService.readCurrentText(), ISelectionService.getSelectedText(), and the JavaScript standard library (crypto.randomUUID(), new Date()...). Rolling your own resolver keeps the permission model explicit — your extension declares exactly what it needs in manifest.json — and lets you choose the token names and set that fit your feature.
When to use this pattern: your extension lets the user author a template string (a URL, a shell command template, a text snippet) and you want to substitute dynamic values into it at invocation time.
Permissions to declare. Add to your manifest.json only the permissions the tokens you actually offer require:
{
"permissions": ["clipboard:read", "selection:read"]
}
clipboard:read— required if you offer a{clipboard}/{Clipboard Text}token.selection:read— required if you offer a{selection}/{Selected Text}token.- No permission needed for date/time/UUID/user-input tokens — those come from the JavaScript runtime.
Minimal resolver implementation. Drop this into your extension (e.g. src/lib/templates.ts). It mirrors the built-in launcher's semantics: tokens are deduplicated so each is resolved once even if it appears multiple times; unknown tokens are left untouched; async tokens are resolved concurrently; service failures degrade to empty strings.
import type { IClipboardHistoryService, ISelectionService } from 'asyar-sdk';
interface ResolveContext { query?: string; }
interface ResolveOptions { encodeValues?: boolean; }
type TokenResolver = (ctx: ResolveContext) => string | Promise<string>;
export function createTemplateResolver(
clipboard: IClipboardHistoryService,
selection: ISelectionService,
) {
// Token registry — add, rename, or remove to match your feature's UX.
// The canonical spellings below match Asyar's built-in Portals/Snippets
// so your users get consistent muscle memory.
const tokens: Record<string, TokenResolver> = {
query: (ctx) => ctx.query ?? '',
Argument: (ctx) => ctx.query ?? '',
'Selected Text': async () => (await selection.getSelectedText()) ?? '',
selection: async () => (await selection.getSelectedText()) ?? '',
'Clipboard Text': () => clipboard.readCurrentText(),
clipboard: () => clipboard.readCurrentText(),
UUID: () => crypto.randomUUID(),
Date: () => new Date().toLocaleDateString(),
Time: () => new Date().toLocaleTimeString(),
'Date & Time': () => new Date().toLocaleString(),
Weekday: () => new Date().toLocaleDateString(undefined, { weekday: 'long' }),
};
async function resolveTemplate(
template: string,
context: ResolveContext = {},
options: ResolveOptions = {},
): Promise<string> {
const TOKEN_RE = /\{([^{}]+)\}/g;
const unique = [...new Set([...template.matchAll(TOKEN_RE)].map((m) => m[1]))];
if (unique.length === 0) return template;
const resolved = new Map<string, string>();
await Promise.all(
unique.map(async (name) => {
const fn = tokens[name];
if (!fn) return; // unknown token → left as {name} in output
try {
const value = await fn(context);
resolved.set(name, options.encodeValues ? encodeURIComponent(value) : value);
} catch {
resolved.set(name, ''); // service failure → empty string
}
}),
);
return template.replace(TOKEN_RE, (full, name) =>
resolved.has(name) ? resolved.get(name)! : full,
);
}
function hasPlaceholders(template: string): boolean {
const TOKEN_RE = /\{([^{}]+)\}/g;
for (const m of template.matchAll(TOKEN_RE)) {
if (tokens[m[1]]) return true;
}
return false;
}
return { resolveTemplate, hasPlaceholders };
}
Usage from a command handler:
import type {
ExtensionContext,
IClipboardHistoryService,
ISelectionService,
} from 'asyar-sdk';
import { createTemplateResolver } from './lib/templates';
let resolver: ReturnType<typeof createTemplateResolver>;
export async function initialize(context: ExtensionContext) {
const clipboard = context.getService<IClipboardHistoryService>('clipboard');
const selection = context.getService<ISelectionService>('selection');
resolver = createTemplateResolver(clipboard, selection);
}
export async function openTemplatedUrl(template: string, query: string) {
// For URL contexts, pass encodeValues: true so {clipboard}, {Selected Text},
// etc. are percent-encoded safely into query strings.
const url = await resolver.resolveTemplate(
template,
{ query },
{ encodeValues: true },
);
window.open(url, '_blank');
}
// Example template the user could store in your extension's settings:
// https://translate.google.com/?sl=auto&tl=en&text={Selected Text}
Matching the built-in token set. If you want your extension's tokens to feel native, use the names and aliases above verbatim — they match the Portals and Snippets built-in features one-for-one. Users who already know how to author a portal URL will know how to author your template.
Error handling notes. getSelectedText() can throw a SelectionError — in particular ACCESSIBILITY_PERMISSION_REQUIRED on macOS when the user hasn't granted accessibility access. The resolver above swallows these into empty strings for template-filling convenience, but if your extension is the user's first experience with selection reading, wrap the first call in an explicit try/catch and use IFeedbackService.showToast to guide them into System Settings. See §8.14 for the full SelectionError handling pattern.
Why isn't there a built-in PlaceholderService? The primary reason is architectural. As covered in §4's two-tier model, Asyar's launcher features (Tier 1) run in the privileged SvelteKit host context with direct access to internal services, while third-party extensions (Tier 2) run in sandboxed <iframe>s whose only channel to the host is serializable postMessage IPC. Asyar does not — and has no plans to — share launcher components or internal module code across that boundary. The SDK exposes service interfaces only; never UI components, Svelte code, or internal implementations. The launcher's placeholder resolver and its { picker are Tier 1 Svelte code that directly imports concrete launcher services, so "just exposing the built-in" isn't a small refactor — it would mean designing a separate IPC-routed service surface from scratch and solving the picker-in-iframe problem on the extension side. On top of that, a packaged PlaceholderService would bundle clipboard:read and selection:read behind an implicit contract, and extensions would pay for the whole permission set even if they only wanted date/time tokens. Keeping the pattern in user-land lets each extension declare exactly the permissions it uses, choose its own token names, and add tokens Asyar doesn't ship (e.g. a {current_project} token scoped to a project-tracking extension).