Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ pnpm dev

The dev server pipes hot reloads into the extension. Load the unpacked folder shown in the terminal (usually `dist`) via Chrome's Extensions page while Developer Mode is enabled.

### Testing

| Command | Description |
| --- | --- |
| `pnpm test` | Run the unit and component suite with Vitest. |
| `pnpm test:watch` | Start Vitest in watch mode with the interactive UI. |
| `pnpm test:coverage` | Generate coverage reports with V8 (HTML + LCOV in `coverage/`). |
| `pnpm test:e2e` | Execute the Playwright end-to-end flow against a mocked ChatGPT page. |

The Vitest environment uses `happy-dom` alongside Testing Library helpers and a mocked `chrome` namespace so background, content script, and UI logic can share fixtures. End-to-end tests require Chromium with extension support; Playwright automatically builds and packages the extension via `pnpm zip` before launching the browser.

### Options & Settings

- Open `options.html` during development (served at `http://localhost:5173/options.html`) to manage the whitelist or disable auto rendering.
Expand All @@ -30,7 +41,11 @@ The dev server pipes hot reloads into the extension. Load the unpacked folder sh
pnpm run build
```

The production bundle lands in `dist/`. Package it manually or use `pnpm run zip` for a distributable archive.
The production bundle lands in `build/`. Package it manually or use `pnpm run zip` for a distributable archive located in `package/`.

## Releases

Release Please manages versioning and release notes. Check the autogenerated root `CHANGELOG.md` or the GitHub Releases tab for the latest history.

---

Expand Down
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ Additional scripts live in `package.json`, including `pnpm build` for production
- [Extension Flow](./extension-flow.md)
- [UI Guidelines](./ui-guidelines.md)
- [Testing Guide](./testing.md)
- [Changelog](./changelog.md)

Release notes are generated automatically via release-please and published with GitHub Releases; there is no manual changelog in this folder.

## Doc Conventions

Expand Down
9 changes: 6 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

CoderChart follows the typical MV3 separation: a background service worker maintains defaults, the content script renders diagrams in-page, and the options UI manages configuration. Shared utilities in `src/shared` allow all surfaces to agree on settings and URL matching.

For a user-centric walkthrough of the lifecycle, see [`extension-flow.md`](./extension-flow.md).

## Runtime Components

- **Background (`src/background/index.ts`)**
Expand All @@ -25,7 +27,7 @@ CoderChart follows the typical MV3 separation: a background service worker maint

```mermaid
flowchart TD
A[Load content script] --> B[getSettings()]
A[Load content script] --> B[Fetch settings]
B --> C{autoRender enabled?}
C -- No --> D[Stay idle]
C -- Yes --> E{URL matches whitelist?}
Expand Down Expand Up @@ -55,5 +57,6 @@ flowchart TD

## Known Constraints

- Builds occasionally report Rollup chunk-size warnings; adjust splitting before release if bundle size grows.
- Rendering currently assumes Mermaid syntax; invalid diagrams surface an inline error but do not retry automatically.
- Clearing all host patterns in options is not persisted: `normalizeSettings` restores the default ChatGPT domains, so disable `autoRender` to pause rendering globally.
- PNG export relies on drawing the generated SVG into a canvas; diagrams that pull in external assets or exceed canvas limits can fail to export and log a warning.
- Mermaid parse failures surface inline with a copyable fix prompt, but there is still no automatic retry or self-healing.
11 changes: 0 additions & 11 deletions docs/changelog.md

This file was deleted.

27 changes: 14 additions & 13 deletions docs/extension-flow.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
# Extension Flow

This guide walks through the end-to-end experience from installation to exporting rendered diagrams.
This guide outlines the user-visible lifecycle. For component responsibilities and deeper mechanics, see [`architecture.md`](./architecture.md).

## Narrative Flow
## Lifecycle Overview

1. **Install:** User loads the unpacked build or installs from the Chrome Web Store. The background script seeds default settings in `chrome.storage.sync`.
2. **Grant permissions:** Chrome applies host permissions declared in `manifest.ts` (ChatGPT domains and localhost during development).
3. **Open supported page:** When the user opens chatgpt.com or another whitelisted host, the content script loads, retrieves settings, and checks the URL against the whitelist.
4. **Render diagrams:** Matching Mermaid code fences trigger the inline renderer. Mermaid initialises with theme selection, generates SVG, and injects a container with toolbar controls.
5. **Adjust settings:** Users can visit `options.html` to disable auto-rendering, edit host patterns, or reset to defaults. Changes sync across devices.
6. **Use toolbar:** Inline buttons allow collapsing/expanding the diagram, scrolling back to the source code block, and exporting as SVG or PNG.
7. **Download pipeline:** SVG downloads reuse the cached render, while PNG downloads convert the SVG via an off-screen canvas before prompting a file save.
8. **Keep updated:** A MutationObserver re-runs rendering when the chat adds new content, and storage listeners reconfigure the script when settings change.
| Step | Trigger | Primary owner | Notes |
| --- | --- | --- | --- |
| Install or upgrade | Extension installed from source or Web Store | Background service worker | Seeds defaults and handles migrations (`architecture.md` → Runtime Components). |
| Host access granted | Chrome applies `manifest.ts` host permissions | Chrome / MV3 platform | Controls where the content script can run (`architecture.md` → Runtime Components). |
| Page activation | User opens a whitelisted URL | Content script | Fetches settings, checks URL against patterns, and, if allowed, initialises Mermaid (`architecture.md` → Data Flow Sequence). |
| Inline rendering | Mermaid blocks detected | Content script + Mermaid runtime | Injects managed containers, themes Mermaid, and caches SVG output (`architecture.md` → Mermaid Rendering & Downloads). |
| Settings changes | Options UI edits or resets settings | Options UI + shared settings utils | Saves updates through `saveSettings`; listeners reconcile state across surfaces (`architecture.md` → Settings Synchronisation). |
| Continuous updates | Chat adds new content or settings sync changes arrive | Content script | MutationObserver re-runs detection; storage listeners reapply activation rules (`architecture.md` → Settings Synchronisation). |

## Sequence Diagram

Expand All @@ -21,7 +21,8 @@ sequenceDiagram
participant C as Chrome
participant B as Background
participant CS as Content Script
participant Opt as Options UI
participant Options as Options UI
participant Toolbar as Download Toolbar

U->>C: Install extension
C->>B: fire onInstalled
Expand All @@ -40,8 +41,8 @@ sequenceDiagram
U->>Toolbar: Click download SVG/PNG
Toolbar->>CS: fetch cached SVG / convert to PNG
CS->>U: Trigger file download
U->>Opt: Open options page
Opt->>chrome.storage.sync: save updated settings
U->>Options: Open options page
Options->>chrome.storage.sync: save updated settings
chrome.storage.sync-->>CS: onChanged event
CS->>CS: Apply settings (activate/deactivate/re-render)
```
19 changes: 17 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@
"build": "tsc && vite build",
"preview": "vite preview",
"fmt": "prettier --write '**/*.{tsx,ts,json,css,scss,md}'",
"zip": "npm run build && node src/zip.js"
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"zip": "pnpm run build && node src/zip.js"
},
"dependencies": {
"react": "^18.2.0",
Expand All @@ -39,6 +43,17 @@
"gulp-zip": "^6.0.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2",
"vite": "^5.4.10"
"vite": "^5.4.10",
"@playwright/test": "^1.48.0",
"@testing-library/dom": "^9.3.4",
"@testing-library/jest-dom": "^6.4.5",
"@testing-library/react": "^14.2.1",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^14.5.2",
"@vitest/ui": "^1.6.0",
"happy-dom": "^13.8.3",
"playwright": "^1.48.0",
"vitest": "^1.6.0",
"vitest-fetch-mock": "^0.4.1"
}
}
40 changes: 40 additions & 0 deletions src/background/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DEFAULT_SETTINGS } from '../../shared/settings'
import { chromeMock, emitRuntimeInstalled, setChromeStorageSync } from 'test/mocks/chrome'

const SETTINGS_KEY = 'settings'

describe('background script', () => {
beforeEach(() => {
vi.resetModules()
})

it('initialises default settings on fresh install', async () => {
setChromeStorageSync({})

await import('../index')

await emitRuntimeInstalled({ reason: 'install' })

expect(chromeMock.storage.sync.set).toHaveBeenCalledWith({ [SETTINGS_KEY]: DEFAULT_SETTINGS })
})

it('normalises existing settings when present', async () => {
setChromeStorageSync({
[SETTINGS_KEY]: {
autoRender: 'nope',
hostPatterns: [' https://chatgpt.com/* ', ''],
},
})

await import('../index')

await emitRuntimeInstalled({ reason: 'update', previousVersion: '0.9.0' })

const payload = chromeMock.storage.sync.set.mock.calls.at(-1)?.[0] as Record<string, unknown>
expect(payload?.[SETTINGS_KEY]).toEqual({
autoRender: true,
hostPatterns: ['https://chatgpt.com/*'],
})
})
})
42 changes: 42 additions & 0 deletions src/contentScript/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { waitFor } from '@testing-library/dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { setChromeStorageSync } from 'test/mocks/chrome'

const mermaidInitialize = vi.fn()
const mermaidRender = vi.fn().mockResolvedValue({
svg: '<svg width="100" height="60"></svg>',
})

vi.mock('mermaid', () => ({
default: {
initialize: mermaidInitialize,
render: mermaidRender,
},
}))

describe('content script', () => {
beforeEach(() => {
vi.resetModules()
mermaidInitialize.mockClear()
mermaidRender.mockClear()
document.body.innerHTML = '<pre><code>graph TD; A-->B;</code></pre>'
setChromeStorageSync({
settings: {
autoRender: true,
hostPatterns: ['https://chatgpt.com/*'],
},
})
window.history.replaceState({}, '', 'https://chatgpt.com/conversation')
})

it('renders detected mermaid blocks when active', async () => {
await import('../index')

await waitFor(() => {
expect(document.querySelector('svg')).toBeInTheDocument()
})

expect(mermaidInitialize).toHaveBeenCalled()
expect(mermaidRender).toHaveBeenCalledWith(expect.stringMatching(/^coderchart-/), expect.stringContaining('graph TD'))
})
})
13 changes: 11 additions & 2 deletions src/contentScript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { doesUrlMatchPatterns } from '../shared/url'
const BLOCK_DATA_STATUS = 'coderchartStatus'
const BLOCK_DATA_SOURCE = 'coderchartSource'
const PNG_PREPARING_LABEL = 'Preparing PNG…'
const PROMPT_RESET_DELAY_MS = 2000
let diagramCounter = 0
let blockIdentifierCounter = 0
let observer: MutationObserver | null = null
Expand Down Expand Up @@ -381,7 +382,11 @@ function ensureContainer(pre: HTMLElement): BlockRegistryEntry {

container.append(body)

pre.insertAdjacentElement('afterend', container)
if (typeof pre.insertAdjacentElement === 'function') {
pre.insertAdjacentElement('afterend', container)
} else if (pre.parentNode) {
pre.parentNode.insertBefore(container, pre.nextSibling)
}

const entry: BlockRegistryEntry = {
id: blockId,
Expand Down Expand Up @@ -480,6 +485,9 @@ function isDarkMode(): boolean {
if (document.documentElement.classList.contains('dark')) {
return true
}
if (typeof window.matchMedia !== 'function') {
return false
}
return window.matchMedia('(prefers-color-scheme: dark)').matches
}

Expand Down Expand Up @@ -737,9 +745,10 @@ function createErrorNotice(doc: Document, err: unknown, source: string): HTMLEle
}

setTimeout(() => {
if (!promptButton.isConnected) return
promptButton.disabled = false
promptButton.textContent = defaultLabel
}, 2000)
}, PROMPT_RESET_DELAY_MS)
})

promptSection.append(promptButton, promptStatus, promptPreview)
Expand Down
24 changes: 20 additions & 4 deletions src/options/Options.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,31 @@
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { DEFAULT_SETTINGS, ExtensionSettings, getSettings, saveSettings, normalizeSettings } from '../shared/settings'

import './Options.css'

type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'

const defaultState = cloneSettings(DEFAULT_SETTINGS)
const SAVE_STATUS_RESET_DELAY_MS = 2400

export const Options = () => {
const [settings, setSettings] = useState<ExtensionSettings>(defaultState)
const [newPattern, setNewPattern] = useState('')
const [status, setStatus] = useState<SaveStatus>('idle')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const resetStatusTimeout = useRef<number | null>(null)

useEffect(() => {
let mounted = true
getSettings()
getSettings({ throwOnError: true })
.then((loaded) => {
if (!mounted) return
setSettings(cloneSettings(loaded))
})
.catch((error) => {
console.warn('Failed to load settings', error)
if (mounted) {
setSettings(cloneSettings(DEFAULT_SETTINGS))
setErrorMessage('Unable to load settings. Using defaults.')
}
})
Expand All @@ -31,6 +34,14 @@ export const Options = () => {
}
}, [])

useEffect(() => {
return () => {
if (resetStatusTimeout.current !== null) {
window.clearTimeout(resetStatusTimeout.current)
}
}
}, [])

const normalizedPatterns = useMemo(() => settings.hostPatterns.map((pattern) => pattern.trim()), [settings.hostPatterns])

const canAddPattern = useMemo(() => {
Expand Down Expand Up @@ -74,16 +85,21 @@ export const Options = () => {

const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
if (resetStatusTimeout.current !== null) {
window.clearTimeout(resetStatusTimeout.current)
resetStatusTimeout.current = null
}
setStatus('saving')
setErrorMessage(null)
try {
const normalized = normalizeSettings(settings)
await saveSettings(normalized)
setSettings(cloneSettings(normalized))
setStatus('saved')
setTimeout(() => {
resetStatusTimeout.current = window.setTimeout(() => {
setStatus('idle')
}, 2400)
resetStatusTimeout.current = null
}, SAVE_STATUS_RESET_DELAY_MS)
} catch (error) {
console.error('Failed to save settings', error)
setStatus('error')
Expand Down
Loading