From acbbe26455085cb01f53050ddac46037210f75e8 Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Fri, 14 Aug 2026 08:21:09 -0400 Subject: [PATCH 1/4] Add themed Mermaid diagrams --- AGENTS.md | 133 ++++++++ CLAUDE.md | 120 +------- app/components/content/Mermaid.vue | 477 +++++++++++++++++++++++++++++ content/mermaid-playground.md | 68 ++++ nuxt.config.ts | 6 + package.json | 3 + pnpm-lock.yaml | 24 ++ tests/components/Mermaid.test.ts | 148 +++++++++ 8 files changed, 860 insertions(+), 119 deletions(-) create mode 100644 AGENTS.md mode change 100644 => 120000 CLAUDE.md create mode 100644 app/components/content/Mermaid.vue create mode 100644 content/mermaid-playground.md create mode 100644 tests/components/Mermaid.test.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8a10ad6b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,133 @@ +# AGENTS.md + +This file provides guidance to coding agents working in this repository. + +## Project Overview + +Directus documentation site built with Nuxt 4 and `@nuxt/content`. Markdown files in `/content` are rendered as pages. Deployed to Vercel on merge to main, served at `https://directus.com/docs` (note the `/docs` base URL in `nuxt.config.ts`). + +## Commands + +```bash +pnpm install # Install dependencies (requires Node.js >=22.18, pnpm 10.29.2) +pnpm dev # Dev server at http://localhost:3000/docs +pnpm build # Production build +pnpm generate # Static site generation (used for Vercel deploy) +pnpm preview # Preview production build locally +pnpm test:mermaid # Mermaid component and export tests +``` + +`pnpm dev` fails with `Invalid URL` unless `DIRECTUS_URL` is set, because the Nuxt server passes it to `createDirectus()` during render. Copy any missing environment variables from `.env.example` to `.env` before the first run. For content-only work, `DIRECTUS_URL` is the sole required value; the remaining variables emit warnings and disable search, analytics, and the assistant. + +Component tests use Vitest and Nuxt Test Utils. Linting is via `@nuxt/eslint` (run through Nuxt's built-in integration). + +## Architecture + +### Content System + +All documentation lives in `/content` as Markdown with YAML frontmatter. Two collections defined in `content.config.ts`: +- `landing` — just `index.md` +- `content` — everything else, with schema requiring `title` (and optional `description`, `authors`, `technologies`, `links`, `icon`) + +Reusable content fragments live in `/content/_partials/` and are included via the `Partial` component. + +### Routing + +- `app/pages/[...slug].vue` — catch-all for content pages +- `app/pages/api/[tag].vue` — OpenAPI-generated API reference (spec comes from the `@directus/openapi` package via `scripts/generate-api-reference.ts`; fixes to endpoint docs belong in the [directus/openapi](https://github.com/directus/openapi) repo) +- `app/pages/tutorials/` — tutorial section with nested routes + +### Custom Markdown Components + +Common Vue components in `app/components/content/` are available in markdown via MDC syntax: + +| Component | MDC Usage | +|---|---| +| `TwoUp` | `::two-up` | +| `ShinyGrid` | `::shiny-grid` | +| `ShinyCard` | `:::shiny-card` | +| `Example` | `:::example` | +| `Faq` | `:::faq` | +| `Chat` | `:::chat` | +| `Mermaid` | `::mermaid{title="Diagram title" filename="diagram-filename"}` | +| `VideoEmbed` | `:video-embed{video-id="..."}` | +| `DocCliSnippet` | `:doc-cli-snippet{command="..."}` | +| `Partial` | `:partial{content="path/to/partial"}` | +| `CtaCloud` | `:cta-cloud` | +| `ProductLink` | `:product-link` | +| `ProseImg` | Overrides default `` in prose | + +Put Mermaid source in a fenced `mermaid` block inside the component. The component renders a themed, interactive diagram with zoom, pan, reset, and self-contained PNG and SVG downloads. + +````mdc +::mermaid{title="Directus request flow" filename="directus-request-flow"} +```mermaid +flowchart LR + Client --> Directus + Directus --> Database +``` +:: +```` + +### Key Config Files + +- `nuxt.config.ts` — modules, prerendering rules, ESLint config, base URL +- `content.config.ts` — content collection schemas (Zod) +- `app/app.config.ts` — navigation structure, UI theme (purple primary), footer links +- `.env.example` — required env vars: Algolia, Directus URL, GTM, Nuxt UI Pro license, PostHog + +### Modules & Integrations + +Nuxt modules: `@nuxt/ui-pro`, `@nuxt/content`, `@nuxtjs/robots`, `@nuxtjs/sitemap`, `@nuxtjs/algolia` (conditional on env vars), `@vueuse/nuxt`, `@nuxt/scripts`. Custom PostHog module in `/modules/posthog/`. + +## Code Style + +- Tabs for indentation (spaces for `.md` and `.yml` — see `.editorconfig`) +- Semicolons required +- ESLint stylistic rules enforced via `@nuxt/eslint` config in `nuxt.config.ts` +- TypeScript throughout + +## Tone of Voice for Documentation Content + +Matching the existing tone is mission-critical. All new or edited content in `/content` must follow these rules: + +### Voice & Person +- Always address the reader as "you" (second person) +- Use active voice — "Create a collection" not "A collection should be created" +- Use imperative mood for instructions — "Run the following command" not "You might want to run" +- Be direct and confident. No hedging ("you might want to", "you could consider") — just tell the reader what to do + +### Formality +- Semi-formal: professional and authoritative, but not stiff or corporate +- Assume the reader is a competent developer — don't over-explain basic concepts +- Contractions are acceptable in explanatory prose ("you'll", "don't", "can't") but keep step-by-step instructions slightly more formal ("you will need" over "you'll need") + +### Sentence Structure +- Keep sentences short to medium length — concise and scannable +- Prefer bullet points and numbered lists to break down processes +- Lead with context ("why") before diving into instructions ("how") +- One idea per sentence. Break complex thoughts into smaller pieces + +### Technical Writing Conventions +- Inline code for: `collection_names`, `field_names`, env vars, API endpoints, file paths +- **Bold** for UI elements ("Click **Create Field**") and key terms on first introduction +- Introduce concepts with a plain-language definition before going deeper — "Collections are database tables with additional metadata and configuration used by Directus." +- Use callout boxes for warnings and important notes, not inline ALL-CAPS or exclamation marks + +### Structure +- Start guides with a "Before You Start" section listing prerequisites +- Use "Next Steps" sections to point to related content +- Use transitions like "Now that..." to connect sections +- Every explanation should tie to a concrete action or use case — minimize abstract theory + +### Things to Avoid +- Filler phrases ("In order to", "It should be noted that", "As a matter of fact") +- Marketing language or hype ("powerful", "revolutionary", "seamless") +- Passive voice in instructions +- Walls of text — if a paragraph exceeds 3-4 sentences, break it up or use a list +- AI-isms ("I'd be happy to help", "Great question!", "Certainly!") +- Telltale AI writing patterns: em dashes (—) used as general-purpose punctuation, "delve", "leverage", "utilize", "straightforward", "it's worth noting", "key" as an adjective. Use normal dashes (-) or rewrite the sentence instead + +## Hosting + +The docs website is hosted as a nested path on the main Directus marketing website https://directus.com/docs. The rest of the Directus website is a separate repo. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8ea1b59f..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,119 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -Directus documentation site built with Nuxt 3 and `@nuxt/content`. Markdown files in `/content` are rendered as pages. Deployed to Vercel on merge to main, served at `https://directus.com/docs` (note the `/docs` base URL in `nuxt.config.ts`). - -## Commands - -```bash -pnpm install # Install dependencies (requires Node.js >=22.18, pnpm 10.29.2) -pnpm dev # Dev server at http://localhost:3000/docs -pnpm build # Production build -pnpm generate # Static site generation (used for Vercel deploy) -pnpm preview # Preview production build locally -``` - -`pnpm dev` fails with `Invalid URL` unless `DIRECTUS_URL` is set, because the Nuxt server passes it to `createDirectus()` during render. Copy any missing environment variables from `.env.example` to `.env` before the first run. For content-only work, `DIRECTUS_URL` is the sole required value; the remaining variables emit warnings and disable search, analytics, and the assistant. - -There is no test runner configured. Linting is via `@nuxt/eslint` (run through Nuxt's built-in integration). - -## Architecture - -### Content System - -All documentation lives in `/content` as Markdown with YAML frontmatter. Two collections defined in `content.config.ts`: -- `landing` — just `index.md` -- `content` — everything else, with schema requiring `title` (and optional `description`, `authors`, `technologies`, `links`, `icon`) - -Reusable content fragments live in `/content/_partials/` and are included via the `Partial` component. - -### Routing - -- `app/pages/[...slug].vue` — catch-all for content pages -- `app/pages/api/[tag].vue` — OpenAPI-generated API reference (spec comes from the `@directus/openapi` package via `scripts/generate-api-reference.ts`; fixes to endpoint docs belong in the [directus/openapi](https://github.com/directus/openapi) repo) -- `app/pages/tutorials/` — tutorial section with nested routes - -### Custom Markdown Components - -12 Vue components in `app/components/content/` are available in markdown via MDC syntax: - -| Component | MDC Usage | -|---|---| -| `TwoUp` | `::two-up` | -| `ShinyGrid` | `::shiny-grid` | -| `ShinyCard` | `:::shiny-card` | -| `Example` | `:::example` | -| `Faq` | `:::faq` | -| `Chat` | `:::chat` | -| `VideoEmbed` | `:video-embed{video-id="..."}` | -| `DocCliSnippet` | `:doc-cli-snippet{command="..."}` | -| `Partial` | `:partial{content="path/to/partial"}` | -| `CtaCloud` | `:cta-cloud` | -| `ProductLink` | `:product-link` | -| `ProseImg` | Overrides default `` in prose | - -### Key Config Files - -- `nuxt.config.ts` — modules, prerendering rules, ESLint config, base URL -- `content.config.ts` — content collection schemas (Zod) -- `app/app.config.ts` — navigation structure, UI theme (purple primary), footer links -- `.env.example` — required env vars: Algolia, Directus URL, GTM, Nuxt UI Pro license, PostHog - -### Modules & Integrations - -Nuxt modules: `@nuxt/ui-pro`, `@nuxt/content`, `@nuxtjs/robots`, `@nuxtjs/sitemap`, `@nuxtjs/algolia` (conditional on env vars), `@vueuse/nuxt`, `@nuxt/scripts`. Custom PostHog module in `/modules/posthog/`. - -## Code Style - -- Tabs for indentation (spaces for `.md` and `.yml` — see `.editorconfig`) -- Semicolons required -- ESLint stylistic rules enforced via `@nuxt/eslint` config in `nuxt.config.ts` -- TypeScript throughout - -## Tone of Voice for Documentation Content - -Matching the existing tone is mission-critical. All new or edited content in `/content` must follow these rules: - -### Voice & Person -- Always address the reader as "you" (second person) -- Use active voice — "Create a collection" not "A collection should be created" -- Use imperative mood for instructions — "Run the following command" not "You might want to run" -- Be direct and confident. No hedging ("you might want to", "you could consider") — just tell the reader what to do - -### Formality -- Semi-formal: professional and authoritative, but not stiff or corporate -- Assume the reader is a competent developer — don't over-explain basic concepts -- Contractions are acceptable in explanatory prose ("you'll", "don't", "can't") but keep step-by-step instructions slightly more formal ("you will need" over "you'll need") - -### Sentence Structure -- Keep sentences short to medium length — concise and scannable -- Prefer bullet points and numbered lists to break down processes -- Lead with context ("why") before diving into instructions ("how") -- One idea per sentence. Break complex thoughts into smaller pieces - -### Technical Writing Conventions -- Inline code for: `collection_names`, `field_names`, env vars, API endpoints, file paths -- **Bold** for UI elements ("Click **Create Field**") and key terms on first introduction -- Introduce concepts with a plain-language definition before going deeper — "Collections are database tables with additional metadata and configuration used by Directus." -- Use callout boxes for warnings and important notes, not inline ALL-CAPS or exclamation marks - -### Structure -- Start guides with a "Before You Start" section listing prerequisites -- Use "Next Steps" sections to point to related content -- Use transitions like "Now that..." to connect sections -- Every explanation should tie to a concrete action or use case — minimize abstract theory - -### Things to Avoid -- Filler phrases ("In order to", "It should be noted that", "As a matter of fact") -- Marketing language or hype ("powerful", "revolutionary", "seamless") -- Passive voice in instructions -- Walls of text — if a paragraph exceeds 3-4 sentences, break it up or use a list -- AI-isms ("I'd be happy to help", "Great question!", "Certainly!") -- Telltale AI writing patterns: em dashes (—) used as general-purpose punctuation, "delve", "leverage", "utilize", "straightforward", "it's worth noting", "key" as an adjective. Use normal dashes (-) or rewrite the sentence instead - -## Hosting - -The docs website is hosted as a nested path on the main Directus marketing website https://directus.com/docs. The rest of the Directus website is a separate repo. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/app/components/content/Mermaid.vue b/app/components/content/Mermaid.vue new file mode 100644 index 00000000..8d576ac7 --- /dev/null +++ b/app/components/content/Mermaid.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/content/mermaid-playground.md b/content/mermaid-playground.md new file mode 100644 index 00000000..2da887ec --- /dev/null +++ b/content/mermaid-playground.md @@ -0,0 +1,68 @@ +--- +stableId: 04c88d97-65b0-4cf0-a5eb-1a046f034207 +title: Mermaid Playground +description: Test the themed Mermaid component, navigation controls, and diagram exports. +navigation: false +--- + +Use the toolbar to zoom, reset the view, or download each diagram as PNG or SVG. Drag the canvas to pan. With the canvas focused, use the arrow keys to pan, `+` and `-` to zoom, and `0` to reset. + +## Flowchart + +::mermaid{title="Directus request flow" filename="directus-request-flow"} +```mermaid +flowchart LR + App[Client application] --> SDK[Directus SDK] + SDK --> Auth{Authenticated?} + Auth -->|Yes| API[Directus API] + Auth -->|No| Login[Sign in] + Login --> API + API --> Access[Access control] + Access --> Database[(Database)] + Access --> Storage[(File storage)] + Access --> Flows[Event flows] +``` +:: + +## Sequence diagram + +::mermaid{title="Item creation" filename="item-creation"} +```mermaid +sequenceDiagram + Client->>Directus: POST /items/articles + Directus->>Policy: Check create access + Policy-->>Directus: Allowed fields + Directus->>Database: Insert article + Database-->>Directus: Created item + Directus-->>Client: 200 OK +``` +:: + +## State diagram + +::mermaid{title="Content workflow" filename="content-workflow"} +```mermaid +stateDiagram-v2 + [*] --> Draft + Draft --> InReview: submit + InReview --> Draft: request changes + InReview --> Published: approve + Published --> Archived: archive + Archived --> Draft: restore +``` +:: + +## XY chart + +Hover over the bars or line points to test interactive tooltips. + +::mermaid{title="API requests" filename="api-requests"} +```mermaid +xychart-beta + title "API requests" + x-axis [Mon, Tue, Wed, Thu, Fri] + y-axis "Requests" 0 --> 500 + bar [240, 320, 280, 410, 390] + line [210, 270, 310, 360, 430] +``` +:: diff --git a/nuxt.config.ts b/nuxt.config.ts index eab1ad05..25ec3300 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -169,6 +169,12 @@ export default defineNuxtConfig({ transpile: ['shiki'], }, + vite: { + optimizeDeps: { + include: ['debug'], + }, + }, + routeRules: { ...loadRedirectRouteRules(), '/api/**': { prerender: true }, diff --git a/package.json b/package.json index a2a9e975..50e5e041 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "typecheck:scripts": "tsc -p scripts/tsconfig.json", "index:docs": "node scripts/index-docs.ts", "typesense:cleanup-preview": "node scripts/cleanup-typesense-preview.ts", + "test:mermaid": "vitest run tests/components/Mermaid.test.ts", "test:search": "vitest run tests/scripts/index-docs-chunker.test.ts tests/scripts/cleanup-typesense-preview.test.ts tests/components/DocsSearchPalette.test.ts tests/shared/parseTypesenseUrl.test.ts tests/lib/typesenseAlias.test.ts tests/services/typesenseService.test.ts tests/utils/highlightHtml.test.ts" }, "dependencies": { @@ -23,6 +24,7 @@ "@directus/openapi": "0.4.0", "@directus/sdk": "^21.2.2", "@directus/vue-split-panel": "^0.8.9", + "@fontsource-variable/inter": "^5.3.0", "@iconify-json/lucide": "1.2.111", "@iconify-json/material-symbols": "1.2.68", "@iconify-json/ph": "^1.2.2", @@ -44,6 +46,7 @@ "@vueuse/core": "14.2.1", "@vueuse/nuxt": "14.2.1", "ai": "6.0.185", + "beautiful-mermaid": "1.1.3", "h3": "1.15.11", "nuxt": "4.4.2", "nuxt-llms": "0.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 056c1efb..c6f83f9d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@directus/vue-split-panel': specifier: ^0.8.9 version: 0.8.13(vue@3.5.33(typescript@6.0.3)) + '@fontsource-variable/inter': + specifier: ^5.3.0 + version: 5.3.0 '@iconify-json/lucide': specifier: 1.2.111 version: 1.2.111 @@ -87,6 +90,9 @@ importers: ai: specifier: 6.0.185 version: 6.0.185(zod@4.4.3) + beautiful-mermaid: + specifier: 1.1.3 + version: 1.1.3 h3: specifier: 1.15.11 version: 1.15.11 @@ -836,6 +842,9 @@ packages: '@floating-ui/vue@1.1.11': resolution: {integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==} + '@fontsource-variable/inter@5.3.0': + resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} + '@google/genai@1.52.0': resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} engines: {node: '>=20.0.0'} @@ -3874,6 +3883,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + beautiful-mermaid@1.1.3: + resolution: {integrity: sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg==} + better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} @@ -4442,6 +4454,9 @@ packages: electron-to-chromium@1.5.344: resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + embla-carousel-auto-height@8.6.0: resolution: {integrity: sha512-/HrJQOEM6aol/oF33gd2QlINcXy3e19fJWvHDuHWp2bpyTa+2dm9tVVJak30m2Qy6QyQ6Fc8DkImtv7pxWOJUQ==} peerDependencies: @@ -8722,6 +8737,8 @@ snapshots: - '@vue/composition-api' - vue + '@fontsource-variable/inter@5.3.0': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))': dependencies: google-auth-library: 10.6.2 @@ -12083,6 +12100,11 @@ snapshots: baseline-browser-mapping@2.10.21: {} + beautiful-mermaid@1.1.3: + dependencies: + elkjs: 0.11.1 + entities: 7.0.1 + better-sqlite3@11.10.0: dependencies: bindings: 1.5.0 @@ -12624,6 +12646,8 @@ snapshots: electron-to-chromium@1.5.344: {} + elkjs@0.11.1: {} + embla-carousel-auto-height@8.6.0(embla-carousel@8.6.0): dependencies: embla-carousel: 8.6.0 diff --git a/tests/components/Mermaid.test.ts b/tests/components/Mermaid.test.ts new file mode 100644 index 00000000..21fbfc31 --- /dev/null +++ b/tests/components/Mermaid.test.ts @@ -0,0 +1,148 @@ +import { h, nextTick } from 'vue'; +import { flushPromises } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mountSuspended, mockNuxtImport } from '@nuxt/test-utils/runtime'; +import Mermaid from '../../app/components/content/Mermaid.vue'; + +mockNuxtImport('useColorMode', () => () => ({ value: 'light' })); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const diagram = `flowchart LR + Client --> Directus`; +const xyChart = `xychart-beta + x-axis [Mon, Tue, Wed] + bar [120, 180, 160] + line [100, 150, 190]`; +const tooltipStub = { template: '' }; + +describe('Mermaid', () => { + it('renders Mermaid source passed as a prop', async () => { + const wrapper = await mountSuspended(Mermaid, { + props: { code: diagram, title: 'Request flow' }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + expect(wrapper.text()).toContain('Request flow'); + expect(wrapper.find('.mermaid-canvas svg').exists()).toBe(true); + expect(wrapper.find('.mermaid-canvas').html()).not.toContain('@import url'); + }); + + it('reads source from an MDC code block slot', async () => { + const wrapper = await mountSuspended(Mermaid, { + slots: { + default: () => h('pre', { code: diagram }, [h('code', diagram)]), + }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + expect(wrapper.find('.mermaid-canvas svg').exists()).toBe(true); + }); + + it('renders XY charts used for interactive tooltips', async () => { + const wrapper = await mountSuspended(Mermaid, { + props: { code: xyChart }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + expect(wrapper.get('.mermaid-canvas svg').attributes('data-xychart-colors')).toBe('1'); + }); + + it('zooms, pans with the keyboard, and resets', async () => { + const wrapper = await mountSuspended(Mermaid, { + props: { code: diagram }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + await wrapper.get('[aria-label="Zoom in"]').trigger('click'); + expect(wrapper.get('.mermaid-canvas').attributes('style')).toContain('scale(1.2)'); + + await wrapper.get('[role="img"]').trigger('keydown', { key: 'ArrowRight' }); + expect(wrapper.get('.mermaid-canvas').attributes('style')).toContain('translate3d(32px, 0px, 0)'); + + await wrapper.get('[aria-label="Reset view"]').trigger('click'); + await nextTick(); + expect(wrapper.get('.mermaid-canvas').attributes('style')).toContain('translate3d(0px, 0px, 0) scale(1)'); + }); + + it('shows renderer errors without breaking the page', async () => { + const wrapper = await mountSuspended(Mermaid, { + props: { code: 'not a diagram' }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + expect(wrapper.get('[role="alert"]').text()).toContain('Invalid mermaid header'); + expect(wrapper.find('.mermaid-canvas').exists()).toBe(false); + }); + + it('downloads the rendered SVG', async () => { + const createObjectUrl = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:diagram'); + const revokeObjectUrl = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const wrapper = await mountSuspended(Mermaid, { + props: { code: diagram, filename: 'request-flow' }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + await wrapper.get('[aria-label="Download diagram"]').trigger('click'); + await nextTick(); + const svgItem = [...document.querySelectorAll('[role="menuitem"]')] + .find(item => item.textContent?.includes('Download SVG')); + expect(svgItem).toBeDefined(); + svgItem?.click(); + await flushPromises(); + + await vi.waitFor(() => { + expect(createObjectUrl.mock.calls.some(([value]) => value.type.includes('svg'))).toBe(true); + }); + const blob = createObjectUrl.mock.calls.find(([value]) => value.type.includes('svg'))?.[0]; + expect(blob).toBeInstanceOf(Blob); + expect(blob?.type).toBe('image/svg+xml;charset=utf-8'); + const exportedSvg = await blob?.text(); + expect(exportedSvg).toContain('@font-face{font-family:\'Inter\''); + expect(exportedSvg).toContain('data:font/woff2;base64,'); + expect(exportedSvg?.match(/data:font\/woff2;base64,/g)).toHaveLength(1); + expect(exportedSvg).toContain('fill="#ffffff"'); + expect(exportedSvg).not.toContain('var('); + expect(exportedSvg).not.toContain('color-mix('); + expect(click).toHaveBeenCalledOnce(); + expect(revokeObjectUrl).toHaveBeenCalledWith('blob:diagram'); + }); + + it('converts the rendered SVG to PNG before downloading', async () => { + const createObjectUrl = vi.spyOn(URL, 'createObjectURL') + .mockImplementation(blob => blob.type === 'image/png' ? 'blob:png' : 'blob:svg'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const drawImage = vi.fn(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(callback => callback(new Blob(['png'], { type: 'image/png' }))); + vi.stubGlobal('Image', class { + onload: (() => void) | null = null; + + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } + }); + const wrapper = await mountSuspended(Mermaid, { + props: { code: diagram, filename: 'request-flow' }, + global: { stubs: { UTooltip: tooltipStub } }, + }); + + await wrapper.get('[aria-label="Download diagram"]').trigger('click'); + await nextTick(); + const pngItem = [...document.querySelectorAll('[role="menuitem"]')] + .find(item => item.textContent?.includes('Download PNG')); + expect(pngItem).toBeDefined(); + pngItem?.click(); + await flushPromises(); + + await vi.waitFor(() => expect(drawImage).toHaveBeenCalledOnce()); + const svgBlob = createObjectUrl.mock.calls.find(([value]) => value.type.includes('svg'))?.[0]; + expect(await svgBlob?.text()).toContain('data:font/woff2;base64,'); + expect(createObjectUrl.mock.calls.some(([value]) => value.type === 'image/png')).toBe(true); + }); +}); From 12a8447568628a6d99d7938937fbf7f755a95cfa Mon Sep 17 00:00:00 2001 From: bryantgillespie Date: Fri, 14 Aug 2026 10:47:29 -0400 Subject: [PATCH 2/4] Address Mermaid review feedback --- app/components/content/Mermaid.vue | 4 +++- tests/components/Mermaid.test.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/components/content/Mermaid.vue b/app/components/content/Mermaid.vue index 8d576ac7..2282daa7 100644 --- a/app/components/content/Mermaid.vue +++ b/app/components/content/Mermaid.vue @@ -226,7 +226,9 @@ function download(blob: Blob, extension: 'svg' | 'png') { const link = document.createElement('a'); link.href = url; link.download = `${props.filename}.${extension}`; + document.body.appendChild(link); link.click(); + link.remove(); URL.revokeObjectURL(url); } @@ -434,7 +436,7 @@ const downloadItems = [