diff --git a/Makefile b/Makefile
index 6201313e..dc4c552d 100644
--- a/Makefile
+++ b/Makefile
@@ -74,7 +74,7 @@ beaker-vue/dist:$(call npm_build_deps,beaker-vue)
touch beaker-vue/dist
beaker-vue/html:$(call npm_build_deps,beaker-vue)
- (cd beaker-vue && npm run build-ui) && \
+ (cd beaker-vue && npm run build-ui && npm run routes) && \
touch beaker-vue/html
src/beaker_notebook/app/ui/index.html:beaker-vue/node_modules beaker-vue/html
diff --git a/beaker-vue/package.json b/beaker-vue/package.json
index 538c5c72..94312d3b 100644
--- a/beaker-vue/package.json
+++ b/beaker-vue/package.json
@@ -66,6 +66,7 @@
"content-disposition": "^0.5.4",
"cookie": "^1.0.2",
"cytoscape": "^3.31.2",
+ "dompurify": "^3.4.14",
"escape-html": "^1.0.3",
"fflate": "^0.8.3",
"filesize": "^10.1.6",
diff --git a/beaker-vue/src/__tests__/skillMarkdown.spec.ts b/beaker-vue/src/__tests__/skillMarkdown.spec.ts
new file mode 100644
index 00000000..78dcbb53
--- /dev/null
+++ b/beaker-vue/src/__tests__/skillMarkdown.spec.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect } from 'vitest';
+import {
+ isRelativeHref,
+ resolveResourceFromHref,
+ type Integration,
+} from '../util/integration';
+import { renderMarkdown } from '../util/markdown';
+
+const integration = {
+ resources: {
+ 'r1': { resource_id: 'r1', resource_type: 'skill_file', relative_path: 'references/FILTERS.md' },
+ 'r2': { resource_id: 'r2', resource_type: 'skill_file', relative_path: 'references/CROSS-REPOSITORY.md' },
+ 'r3': { resource_id: 'r3', resource_type: 'skill_file', relative_path: 'assets/service_openapi.yaml' },
+ 'r4': { resource_id: 'r4', resource_type: 'skill_file', relative_path: 'references/my notes.md' },
+ 'e1': { resource_id: 'e1', resource_type: 'skill_example', filename: 'find_cohort.md' },
+ 'i1': { resource_id: 'i1', resource_type: 'skill_instructions', content: '# hi' },
+ },
+} as unknown as Integration;
+
+describe('isRelativeHref', () => {
+ it('accepts relative paths', () => {
+ expect(isRelativeHref('references/FILTERS.md')).toBe(true);
+ expect(isRelativeHref('./FILTERS.md')).toBe(true);
+ expect(isRelativeHref('../assets/service_openapi.yaml')).toBe(true);
+ });
+
+ it('rejects external, protocol-relative, mailto, and in-page links', () => {
+ expect(isRelativeHref('https://example.com/x.md')).toBe(false);
+ expect(isRelativeHref('http://example.com')).toBe(false);
+ expect(isRelativeHref('//example.com/x.md')).toBe(false);
+ expect(isRelativeHref('mailto:someone@example.com')).toBe(false);
+ expect(isRelativeHref('#section')).toBe(false);
+ expect(isRelativeHref('')).toBe(false);
+ });
+});
+
+describe('resolveResourceFromHref', () => {
+ it('resolves a skill-root link to a file resource', () => {
+ expect(resolveResourceFromHref(integration, 'references/FILTERS.md')?.resource_id).toBe('r1');
+ });
+
+ it('resolves an examples/ link to an example resource', () => {
+ expect(resolveResourceFromHref(integration, 'examples/find_cohort.md')?.resource_id).toBe('e1');
+ });
+
+ it('resolves sibling links against the linking file directory', () => {
+ expect(resolveResourceFromHref(integration, 'CROSS-REPOSITORY.md', 'references')?.resource_id).toBe('r2');
+ });
+
+ it('resolves ../ traversal', () => {
+ expect(resolveResourceFromHref(integration, '../assets/service_openapi.yaml', 'references')?.resource_id).toBe('r3');
+ });
+
+ it('falls back to the skill root when a base-relative link does not resolve', () => {
+ // Example files commonly use root-relative paths like SKILL.md does.
+ expect(resolveResourceFromHref(integration, 'references/FILTERS.md', 'examples')?.resource_id).toBe('r1');
+ });
+
+ it('percent-decodes encoded hrefs', () => {
+ expect(resolveResourceFromHref(integration, 'references/my%20notes.md')?.resource_id).toBe('r4');
+ });
+
+ it('ignores ./ segments, query strings, and fragments', () => {
+ expect(resolveResourceFromHref(integration, './references/FILTERS.md')?.resource_id).toBe('r1');
+ expect(resolveResourceFromHref(integration, 'references/FILTERS.md#operators')?.resource_id).toBe('r1');
+ expect(resolveResourceFromHref(integration, 'references/FILTERS.md?x=1')?.resource_id).toBe('r1');
+ });
+
+ it('returns undefined for unknown paths and empty hrefs', () => {
+ expect(resolveResourceFromHref(integration, 'auth.yaml')).toBeUndefined();
+ expect(resolveResourceFromHref(integration, '')).toBeUndefined();
+ expect(resolveResourceFromHref(undefined, 'references/FILTERS.md')).toBeUndefined();
+ });
+});
+
+describe('renderMarkdown', () => {
+ it('renders markdown to HTML', () => {
+ const html = renderMarkdown('# Title\n\nSome **bold** text.');
+ expect(html).toContain('
');
+ expect(html).toContain('bold');
+ });
+
+ it('strips script tags and event handlers from embedded HTML', () => {
+ expect(renderMarkdown('hello ')).not.toContain('
-
-
diff --git a/beaker-vue/src/components/integrations/IntegrationPanel.vue b/beaker-vue/src/components/integrations/IntegrationPanel.vue
index 2084a13f..656d0396 100644
--- a/beaker-vue/src/components/integrations/IntegrationPanel.vue
+++ b/beaker-vue/src/components/integrations/IntegrationPanel.vue
@@ -80,7 +80,7 @@
>
const renderIntegrations = (integrations: Integration[]) =>
integrations.map(integration =>
- ({...integration, description: marked.parse(integration?.description ?? "") as string}))
+ ({...integration, description: renderMarkdown(integration?.description)}))
-const processIntegrations = (integrations: Integration[]) =>
- renderIntegrations(filterIntegrations(sortIntegrations(integrations)))
+// Markdown parsing + sanitization is the expensive step, so cache it keyed on
+// the integration data; the cheap search filter recomputes per keystroke on
+// top of the cached result instead of re-rendering every description (which
+// an inline template call would also do on every hover-state change).
+const renderedIntegrations = computed
(() =>
+ renderIntegrations(sortIntegrations(Object.values(integrations.value ?? {}))));
+
+const displayIntegrations = computed(() =>
+ filterIntegrations(renderedIntegrations.value));
// const relevantProviders = (providers: IntegrationProviders): IntegrationProviders =>
// Object.keys(providers)
diff --git a/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue b/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue
index 6172cb2b..88cc3f04 100644
--- a/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue
+++ b/beaker-vue/src/components/integrations/MCPIntegrationEditor.vue
@@ -323,7 +323,7 @@ import Checkbox from 'primevue/checkbox';
import Button from 'primevue/button';
import ProgressSpinner from 'primevue/progressspinner';
-import { marked } from 'marked';
+import { renderMarkdown } from '../../util/markdown';
import CodeEditor from '../misc/CodeEditor.vue';
@@ -434,7 +434,7 @@ watch(() => selectedIntegration.value?.description, (current) => {
// read-only here (see the Instructions fieldset). Not persisted to config.
const renderedInstructions = computed(() => {
const instructions = selectedIntegration.value?.instructions;
- return instructions ? marked.parse(instructions) as string : "";
+ return renderMarkdown(instructions);
});
const hasServerInfo = computed(() =>
diff --git a/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue b/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue
index 941345ca..d64ef7c6 100644
--- a/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue
+++ b/beaker-vue/src/components/integrations/MCPIntegrationViewer.vue
@@ -160,7 +160,7 @@ import Fieldset from 'primevue/fieldset';
import InputText from 'primevue/inputtext';
import ProgressSpinner from 'primevue/progressspinner';
-import { marked } from 'marked';
+import { renderMarkdown } from '../../util/markdown';
const props = defineProps<{
fetchResources: () => Promise,
@@ -199,11 +199,11 @@ const serverConfig = computed(() =>
selectedIntegration.value?.server_config);
const renderedDescription = computed(() =>
- marked.parse(selectedIntegration.value?.description ?? "") as string);
+ renderMarkdown(selectedIntegration.value?.description));
const renderedInstructions = computed(() => {
const instructions = selectedIntegration.value?.instructions;
- return instructions ? marked.parse(instructions) as string : "";
+ return renderMarkdown(instructions);
});
const hasServerInfo = computed(() =>
diff --git a/beaker-vue/src/components/integrations/MCPToolsPanel.vue b/beaker-vue/src/components/integrations/MCPToolsPanel.vue
index 0bbd3d3f..c1504526 100644
--- a/beaker-vue/src/components/integrations/MCPToolsPanel.vue
+++ b/beaker-vue/src/components/integrations/MCPToolsPanel.vue
@@ -124,7 +124,7 @@ import Tag from 'primevue/tag';
import InputGroup from "primevue/inputgroup";
import InputGroupAddon from "primevue/inputgroupaddon";
import InputText from "primevue/inputtext";
-import { marked } from 'marked';
+import { renderMarkdown } from '../../util/markdown';
import {
type IntegrationInterfaceState,
type MCPToolResource,
@@ -209,7 +209,7 @@ const focusedArgs = computed(() =>
const renderedDescription = computed(() => {
if (viewState.value.view !== 'focused') return "";
const description = viewState.value.tool.description;
- return description ? marked.parse(description) as string : "";
+ return renderMarkdown(description);
});
const viewTool = (tool: MCPToolResource) => {
diff --git a/beaker-vue/src/components/integrations/ResourceViewer.vue b/beaker-vue/src/components/integrations/ResourceViewer.vue
deleted file mode 100644
index 62a09e97..00000000
--- a/beaker-vue/src/components/integrations/ResourceViewer.vue
+++ /dev/null
@@ -1,309 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- {{ resourceLabel(resource) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
- Loading resource...
-
-
-
- Failed to load resource content.
-
-
-
-
-
-
-
-
-
-
diff --git a/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue b/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue
index 915b6066..75b013c2 100644
--- a/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue
+++ b/beaker-vue/src/components/integrations/SkillIntegrationEditor.vue
@@ -110,8 +110,19 @@
-
@@ -83,29 +58,49 @@ import { computed } from 'vue';
import {
type Integration,
type IntegrationInterfaceState,
- type IntegrationResource,
type SkillMetadataResource,
+ type SkillInstructionsResource,
type SkillFileResource,
type SkillExampleResource,
filterByResourceType,
+ resourceFromLinkClick,
} from '../../util/integration';
import Fieldset from 'primevue/fieldset';
import InputText from 'primevue/inputtext';
+import ClampedMarkdown from '../misc/ClampedMarkdown.vue';
+import SkillResourceLinks from './SkillResourceLinks.vue';
-import { marked } from 'marked';
+import { renderMarkdown } from '../../util/markdown';
const props = defineProps<{
fetchResources: () => Promise,
}>();
+const emit = defineEmits<{
+ (e: 'open-resource', resourceId: string): void,
+}>();
+
const model = defineModel();
const selectedIntegration = computed(() =>
model.value.integrations[model.value.selected]);
const renderedDescription = computed(() =>
- marked.parse(selectedIntegration.value?.description ?? "") as string);
+ renderMarkdown(selectedIntegration.value?.description));
+
+const renderedInstructions = computed(() => {
+ const instructions = Object.values(filterByResourceType(
+ selectedIntegration.value?.resources, "skill_instructions"))[0];
+ return renderMarkdown(instructions?.content);
+});
+
+const onInstructionsLinkClick = (event: MouseEvent) => {
+ const resource = resourceFromLinkClick(event, selectedIntegration.value);
+ if (resource) {
+ emit('open-resource', resource.resource_id);
+ }
+};
const metadata = computed(() => {
const resources = filterByResourceType(
@@ -150,14 +145,6 @@ const exampleResources = computed(() => {
}
}
-.skill-description {
- h1 { font-size: 1.25rem; margin-bottom: 1rem; }
- h2 { font-size: 1.2rem; margin-bottom: 0.8rem; }
- h3 { font-size: 1.15rem; margin-bottom: 0.8rem; }
- p, ul, li { margin-bottom: 0.8rem; margin-top: 0rem; }
- > *:first-child { margin-top: 0rem; }
-}
-
.skill-metadata-grid {
display: flex;
flex-direction: column;
@@ -184,38 +171,4 @@ const exampleResources = computed(() => {
.skill-no-metadata {
color: var(--p-text-muted-color);
}
-
-.skill-resource-list {
- display: flex;
- flex-direction: column;
- gap: 0.25rem;
-}
-
-.skill-resource-item {
- display: flex;
- align-items: center;
- gap: 0.5rem;
- padding: 0.35rem 0.5rem;
- border-radius: 4px;
- font-size: 0.9rem;
-
- &:hover {
- background-color: var(--p-surface-100);
- }
-}
-
-.skill-resource-path {
- font-family: monospace;
-}
-
-.skill-example-info {
- display: flex;
- flex-direction: column;
- gap: 0.15rem;
-}
-
-.skill-example-title {
- font-size: 0.85rem;
- color: var(--p-text-muted-color);
-}
diff --git a/beaker-vue/src/components/integrations/SkillResourceLinks.vue b/beaker-vue/src/components/integrations/SkillResourceLinks.vue
new file mode 100644
index 00000000..69b59ff4
--- /dev/null
+++ b/beaker-vue/src/components/integrations/SkillResourceLinks.vue
@@ -0,0 +1,108 @@
+
+
+
+ These resources are available to the agent and will be loaded on demand when the skill is active.
+
+
+
+
+ {{ resource.relative_path }}
+
+
+
+
+
+
+
+ Code examples demonstrating usage patterns for this skill.
+
+
+
+
+
+ {{ example.filename }}
+ {{ example.title }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/beaker-vue/src/components/integrations/SkillResourcePanel.vue b/beaker-vue/src/components/integrations/SkillResourcePanel.vue
index 9bfc137b..c34c254d 100644
--- a/beaker-vue/src/components/integrations/SkillResourcePanel.vue
+++ b/beaker-vue/src/components/integrations/SkillResourcePanel.vue
@@ -79,8 +79,18 @@
+
+
+ Failed to load resource content.
+
+
(() => {
return focusedResource.value ? languageForPath(resourceLabel(focusedResource.value)) : 'markdown';
});
+const renderedContent = computed(() =>
+ renderMarkdown(draftContent.value));
+
+// Markdown resources open in a rendered view by default, with a toggle back
+// to the raw source (an editor for editable skills, read-only otherwise).
+// Non-markdown resources always use the code editor.
+const showRendered = ref(true);
+
+const isMarkdownPath = (path: string): boolean => /\.(md|markdown)$/i.test(path);
+
+const focusedIsMarkdown = computed(() =>
+ viewState.value.view === 'focused'
+ && focusedResource.value !== undefined
+ && isMarkdownPath(resourceLabel(focusedResource.value)));
+
+const showRenderedView = computed(() =>
+ focusedIsMarkdown.value && showRendered.value);
+
+// A failed or still-running load means draftContent does not reflect the
+// resource; saving then would overwrite the real content with an empty string.
+const loadFailed = ref(false);
+
const canSave = computed(() => {
if (viewState.value.view === 'new') {
return draftFilename.value.trim() !== "";
}
- return true;
+ return !loadingContent.value && !loadFailed.value;
});
+// Unsaved work in the focused editor or the new-resource form; guard against
+// silently discarding it when a row click, rendered link, or the center
+// viewer opens another resource.
+const draftDirty = computed(() => {
+ if (viewState.value.view === 'new') {
+ return draftFilename.value.trim() !== '' || draftContent.value !== '';
+ }
+ if (viewState.value.view === 'focused' && editable.value && !loadingContent.value && !loadFailed.value) {
+ const original = (focusedResource.value as SkillFileResource | SkillExampleResource | undefined)?.content ?? '';
+ return draftContent.value !== original;
+ }
+ return false;
+});
+
+const confirmDiscardDraft = (): boolean =>
+ !draftDirty.value || confirm('Discard unsaved changes?');
+
const backToList = () => {
viewState.value = { view: 'list' };
draftFilename.value = "";
@@ -242,6 +303,12 @@ const backToList = () => {
draftDir.value = "references";
};
+const onBackClick = () => {
+ if (confirmDiscardDraft()) {
+ backToList();
+ }
+};
+
const startNew = (resourceType: "skill_file" | "skill_example") => {
draftFilename.value = "";
draftContent.value = "";
@@ -250,7 +317,11 @@ const startNew = (resourceType: "skill_file" | "skill_example") => {
};
const openResource = async (resource: IntegrationResource) => {
+ if (!confirmDiscardDraft()) return;
viewState.value = { view: 'focused', resourceId: resource.resource_id };
+ showRendered.value = true;
+ loadFailed.value = false;
+ loadingContent.value = false;
const cached = (resource as SkillFileResource | SkillExampleResource).content;
if (cached !== undefined && cached !== null) {
draftContent.value = cached;
@@ -258,15 +329,27 @@ const openResource = async (resource: IntegrationResource) => {
}
draftContent.value = "";
loadingContent.value = true;
+ // Only the fetch for the currently focused resource may touch shared
+ // state: the user can focus another resource while this one is loading,
+ // and a late response must not leak into it (or be saved over it).
+ const isCurrent = () =>
+ viewState.value.view === 'focused' && viewState.value.resourceId === resource.resource_id;
try {
const fetched = await getResource(props.sessionId, model.value.selected, resource.resource_type, resource.resource_id);
const content = (fetched as any).content ?? "";
(resource as any).content = content;
- draftContent.value = content;
+ if (isCurrent()) {
+ draftContent.value = content;
+ }
} catch (e) {
console.error('Failed to load resource content:', e);
+ if (isCurrent()) {
+ loadFailed.value = true;
+ }
} finally {
- loadingContent.value = false;
+ if (isCurrent()) {
+ loadingContent.value = false;
+ }
}
};
@@ -302,6 +385,29 @@ const removeResource = async (resource: IntegrationResource) => {
delete selectedIntegration.value.resources[resource.resource_id];
};
+// Relative links between a skill's markdown files (e.g. `CROSS-REPOSITORY.md`
+// inside references/FILTERS.md) would 404 against the app's URL; open the
+// linked resource in this panel instead. External links behave normally.
+const onRenderedLinkClick = (event: MouseEvent) => {
+ const label = focusedLabel.value;
+ const basePath = label.includes('/') ? label.slice(0, label.lastIndexOf('/')) : '';
+ const resource = resourceFromLinkClick(event, selectedIntegration.value, basePath);
+ if (resource) {
+ openResource(resource);
+ }
+};
+
+// Focus a resource from outside the panel (e.g. clicking a resource in the
+// center skill viewer).
+const focusResource = (resourceId: string) => {
+ const resource = selectedIntegration.value?.resources?.[resourceId];
+ if (resource) {
+ openResource(resource);
+ }
+};
+
+defineExpose({ focusResource });
+
diff --git a/beaker-vue/src/index.scss b/beaker-vue/src/index.scss
index 0407dad2..e96c6991 100644
--- a/beaker-vue/src/index.scss
+++ b/beaker-vue/src/index.scss
@@ -10,3 +10,33 @@ body {
margin: 0;
padding: 0;
}
+
+// Shared typography for rendered (sanitized) markdown prose — skill
+// descriptions/instructions, resource files, MCP descriptions. Wide content
+// (code blocks, tables) scrolls inside its own box rather than stretching the
+// pane sideways; min-width: 0 lets the block shrink below its content's
+// intrinsic width inside flex column parents.
+.skill-description {
+ h1 { font-size: 1.25rem; margin-bottom: 1rem; }
+ h2 { font-size: 1.2rem; margin-bottom: 0.8rem; }
+ h3 { font-size: 1.15rem; margin-bottom: 0.8rem; }
+ p, ul, li { margin-bottom: 0.8rem; margin-top: 0rem; }
+ > *:first-child { margin-top: 0rem; }
+
+ min-width: 0;
+ max-width: 100%;
+ overflow-wrap: break-word;
+
+ pre {
+ max-width: 100%;
+ overflow-x: auto;
+ }
+ table {
+ display: block;
+ max-width: 100%;
+ overflow-x: auto;
+ }
+ img {
+ max-width: 100%;
+ }
+}
diff --git a/beaker-vue/src/pages/IntegrationsInterface.vue b/beaker-vue/src/pages/IntegrationsInterface.vue
index da810e7c..62b93aad 100644
--- a/beaker-vue/src/pages/IntegrationsInterface.vue
+++ b/beaker-vue/src/pages/IntegrationsInterface.vue
@@ -67,6 +67,7 @@
:modifyIntegration="modifySelectedIntegration"
:deleteIntegration="deleteIntegrationById"
:fetchResources="fetchResourcesForSelectedIntegration"
+ @open-resource="openResourceInRightPanel"
/>
@@ -144,6 +145,7 @@
>
{
+ rightSideMenuRef.value?.selectPanel('examples');
+ nextTick(() => rightPanelRef.value?.focusResource?.(resourceId));
+};
const previewVisible = ref(false);
@@ -793,6 +803,11 @@ const restartSession = async () => {
}
max-width: 100%;
+ // Flex items floor at their content's intrinsic width (min-width:
+ // auto), so a long unbreakable line in rendered markdown (e.g. a code
+ // block) would stretch the whole fieldset past the pane. Allow the
+ // fieldset to shrink so wide content scrolls inside its own box.
+ min-width: 0;
.p-fieldset-legend {
max-width: 100%;
background: none;
diff --git a/beaker-vue/src/util/integration.ts b/beaker-vue/src/util/integration.ts
index f95e32f7..245527fa 100644
--- a/beaker-vue/src/util/integration.ts
+++ b/beaker-vue/src/util/integration.ts
@@ -205,6 +205,69 @@ export const isContextProvidedIntegration = (integration: Integration): boolean
export const getIntegrationProviderSlug = (integration: Integration) => integration.provider.split(":")[1]
+// True for hrefs that are relative paths within a skill (as opposed to
+// external URLs, mailto:, in-page anchors, or protocol-relative links).
+export const isRelativeHref = (href: string): boolean =>
+ !!href && !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(href);
+
+const joinSkillPath = (basePath: string, href: string): string => {
+ const resolved: string[] = [];
+ for (const segment of [...basePath.split('/'), ...href.split('/')]) {
+ if (segment === '' || segment === '.') continue;
+ if (segment === '..') resolved.pop();
+ else resolved.push(segment);
+ }
+ return resolved.join('/');
+};
+
+// Resolve a relative href authored inside a skill's markdown to another
+// resource of the same integration. `basePath` is the directory of the file
+// containing the link ("" for SKILL.md at the skill root), so sibling links
+// like `CROSS-REPOSITORY.md` inside references/FILTERS.md resolve correctly;
+// links that don't resolve against the base fall back to the skill root,
+// since files commonly use root-relative paths like `references/X.md`.
+// Returns undefined when the link doesn't point at a known resource.
+export const resolveResourceFromHref = (
+ integration: Integration | undefined,
+ href: string,
+ basePath: string = "",
+): IntegrationResource | undefined => {
+ let cleaned = href.split(/[?#]/)[0];
+ if (!cleaned) return undefined;
+ try {
+ // Markdown links percent-encode spaces and non-ASCII characters.
+ cleaned = decodeURIComponent(cleaned);
+ } catch {
+ // Malformed escape sequence; match against the raw path.
+ }
+ const findByPath = (path: string): IntegrationResource | undefined =>
+ Object.values(integration?.resources ?? {}).find((r) =>
+ (r.resource_type === 'skill_file' && (r as SkillFileResource).relative_path === path)
+ || (r.resource_type === 'skill_example' && `examples/${(r as SkillExampleResource).filename}` === path));
+ const resource = findByPath(joinSkillPath(basePath, cleaned));
+ if (resource || !basePath) {
+ return resource;
+ }
+ return findByPath(joinSkillPath("", cleaned));
+};
+
+// Handle a click inside rendered skill markdown. A click on a relative link
+// (which would otherwise 404 against the app's URL) is prevented and resolved
+// to the resource it points at, if any; clicks elsewhere, and on external/
+// mailto/in-page links, are left alone and return undefined.
+export const resourceFromLinkClick = (
+ event: MouseEvent,
+ integration: Integration | undefined,
+ basePath: string = "",
+): IntegrationResource | undefined => {
+ const anchor = (event.target as HTMLElement).closest?.('a');
+ if (!anchor) return undefined;
+ const href = anchor.getAttribute('href') ?? '';
+ if (!isRelativeHref(href)) return undefined;
+ event.preventDefault();
+ return resolveResourceFromHref(integration, href, basePath);
+};
+
// Per-datatype display metadata. Single source of truth for how each
// integration datatype is presented in the UI; extend this interface as more
// per-type presentation data is needed.
diff --git a/beaker-vue/src/util/markdown.ts b/beaker-vue/src/util/markdown.ts
index 6e7d2625..edd0849f 100644
--- a/beaker-vue/src/util/markdown.ts
+++ b/beaker-vue/src/util/markdown.ts
@@ -1,5 +1,6 @@
import { marked, type TokenizerAndRendererExtension, type Tokens } from 'marked';
import katex from 'katex';
+import DOMPurify from 'dompurify';
import 'katex/dist/katex.min.css';
// KaTeX support for `marked`.
@@ -131,4 +132,17 @@ export const registerMarkdownExtensions = (): void => {
registerMarkdownExtensions();
+/**
+ * Render untrusted markdown to sanitized HTML safe for `v-html`. Integration
+ * content (skill instructions, resource files, MCP descriptions) can arrive
+ * from remote URLs or uploaded archives, so it must never reach the DOM as
+ * raw HTML.
+ */
+export const renderMarkdown = (markdown: string | null | undefined): string => {
+ if (!markdown) {
+ return "";
+ }
+ return DOMPurify.sanitize(marked.parse(markdown) as string);
+};
+
export { marked };