diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
new file mode 100644
index 00000000..0fec74d6
--- /dev/null
+++ b/docs/DEPLOYMENT.md
@@ -0,0 +1,487 @@
+# Deployment Guide
+
+This guide covers deploying `do-knowledge-studio` to various environments.
+
+## Overview
+
+`do-knowledge-studio` is a client-side application that can be deployed to:
+- Static hosting (Netlify, Vercel, GitHub Pages, etc.)
+- Self-hosted servers (Nginx, Apache, Caddy)
+- CDN (Cloudflare, AWS CloudFront)
+
+The application uses:
+- SQLite WASM (in-browser, no backend needed)
+- OPFS (Origin Private File System) for data persistence
+- Static assets (no server-side processing)
+
+## Build Process
+
+### Production Build
+
+```bash
+pnpm run build
+```
+
+This creates an optimized production build in the `dist/` directory.
+
+### Build Output
+
+```
+dist/
+├── index.html
+├── assets/
+│ ├── index-[hash].js
+│ ├── index-[hash].css
+│ ├── sqlite3.wasm
+│ ├── sqlite3-worker1-bundler-friendly.js
+│ └── [other assets]
+└── [other files]
+```
+
+## Deployment Options
+
+### Option 1: Static Hosting (Recommended)
+
+#### Netlify
+
+1. **Via Git**:
+ - Connect your repository
+ - Build command: `pnpm run build`
+ - Publish directory: `dist`
+ - Node version: 22
+
+2. **Via Netlify CLI**:
+ ```bash
+ npm install -g netlify-cli
+ pnpm run build
+ netlify deploy --prod --dir=dist
+ ```
+
+3. **Configuration** (`netlify.toml`):
+ ```toml
+ [build]
+ command = "pnpm run build"
+ publish = "dist"
+
+ [[headers]]
+ for = "/*.wasm"
+ [headers.values]
+ Content-Type = "application/wasm"
+ ```
+
+#### Vercel
+
+1. **Via Git**:
+ - Import your repository
+ - Framework preset: Vite
+ - Build command: `pnpm run build`
+ - Output directory: `dist`
+
+2. **Via Vercel CLI**:
+ ```bash
+ npm install -g vercel
+ pnpm run build
+ vercel --prod
+ ```
+
+#### GitHub Pages
+
+1. **Add to `package.json`**:
+ ```json
+ {
+ "homepage": "https://[username].github.io/do-knowledge-studio"
+ }
+ ```
+
+2. **Build and deploy**:
+ ```bash
+ pnpm run build
+ pnpm run deploy
+ ```
+
+3. **GitHub Actions** (`.github/workflows/deploy.yml`):
+ ```yaml
+ name: Deploy to GitHub Pages
+ on:
+ push:
+ branches: [main]
+ jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ with:
+ node-version: 22
+ - run: pnpm install
+ - run: pnpm run build
+ - uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./dist
+ ```
+
+### Option 2: Self-Hosted
+
+#### Nginx
+
+1. **Build the application**:
+ ```bash
+ pnpm run build
+ ```
+
+2. **Copy to server**:
+ ```bash
+ scp -r dist/* user@server:/var/www/dks/
+ ```
+
+3. **Nginx configuration** (`/etc/nginx/sites-available/dks`):
+ ```nginx
+ server {
+ listen 80;
+ server_name your-domain.com;
+ root /var/www/dks;
+ index index.html;
+
+ # Security headers
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header Referrer-Policy "no-referrer-when-downgrade" always;
+ add_header Content-Security-Policy "default-src 'self' 'wasm-unsafe-eval'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;" always;
+
+ # WASM MIME type
+ types {
+ application/wasm wasm;
+ }
+
+ # SPA routing
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Cache static assets
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # No cache for index.html
+ location = /index.html {
+ add_header Cache-Control "no-cache, no-store, must-revalidate";
+ }
+
+ # Gzip compression
+ gzip on;
+ gzip_types text/css application/javascript application/wasm application/json;
+ gzip_min_length 1000;
+ }
+ ```
+
+4. **Enable and reload**:
+ ```bash
+ sudo ln -s /etc/nginx/sites-available/dks /etc/nginx/sites-enabled/
+ sudo nginx -t
+ sudo systemctl reload nginx
+ ```
+
+#### Apache
+
+1. **Build the application**:
+ ```bash
+ pnpm run build
+ ```
+
+2. **Copy to server**:
+ ```bash
+ scp -r dist/* user@server:/var/www/dks/
+ ```
+
+3. **Apache configuration** (`/etc/apache2/sites-available/dks.conf`):
+ ```apache
+
, , are stripped but content is preserved.
-
describe('MarkdownRenderer', () => {
- it('renders plain text in a paragraph', () => {
- const { container } = render( );
- expect(container.textContent).toContain('Hello world');
- });
-
- it('renders bold text via **syntax**', () => {
- const { container } = render( );
- const strong = container.querySelector('strong');
- expect(strong?.textContent).toBe('bold');
- });
-
- it('renders italic text via *syntax*', () => {
- const { container } = render( );
- const em = container.querySelector('em');
- expect(em?.textContent).toBe('italic');
+ it('should render heading levels h1-h6', () => {
+ const { container } = render( );
+ expect(container.querySelector('h1')).toBeTruthy();
+ expect(container.querySelector('h1')?.textContent).toBe('Heading 1');
});
- it('renders bold italic text via ***syntax***', () => {
- const { container } = render( );
- const strong = container.querySelector('strong');
- const em = container.querySelector('em');
- expect(strong).toBeDefined();
- expect(em).toBeDefined();
+ it('should render h2', () => {
+ const { container } = render( );
+ expect(container.querySelector('h2')).toBeTruthy();
});
- it('renders inline code content (tag stripped by sanitizer)', () => {
- const { container } = render( );
- // is stripped by sanitizeHtml but text content is preserved
- expect(container.textContent).toContain('console.log');
+ it('should render h3', () => {
+ const { container } = render( );
+ expect(container.querySelector('h3')).toBeTruthy();
});
- it('renders links with target=_blank and rel=noopener', () => {
- const { container } = render( );
- const link = container.querySelector('a');
- expect(link?.getAttribute('href')).toBe('https://example.com');
- expect(link?.getAttribute('target')).toBe('_blank');
- expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
- expect(link?.textContent).toBe('click');
- });
-
- it('renders strikethrough content (del tag stripped by sanitizer)', () => {
- const { container } = render( );
- // is stripped by sanitizeHtml but text content is preserved
- expect(container.textContent).toContain('deleted');
+ it('should render unordered lists', () => {
+ const { container } = render(
+
+ );
+ const list = container.querySelector('ul');
+ expect(list).toBeTruthy();
+ const items = container.querySelectorAll('li');
+ expect(items.length).toBe(3);
});
- it('renders h1 header', () => {
- const { container } = render( );
- const h1 = container.querySelector('h1');
- expect(h1?.textContent).toBe('H1');
+ it('should render ordered lists', () => {
+ const { container } = render(
+
+ );
+ const list = container.querySelector('ol');
+ expect(list).toBeTruthy();
+ const items = container.querySelectorAll('li');
+ expect(items.length).toBe(3);
});
- it('renders h2 header', () => {
- const { container } = render( );
- const h2 = container.querySelector('h2');
- expect(h2?.textContent).toBe('H2');
+ it('should render fenced code blocks', () => {
+ const { container } = render(
+
+ );
+ const pre = container.querySelector('pre');
+ expect(pre).toBeTruthy();
+ const code = container.querySelector('code');
+ expect(code).toBeTruthy();
});
- it('renders h3 header', () => {
- const { container } = render( );
- const h3 = container.querySelector('h3');
- expect(h3?.textContent).toBe('H3');
+ it('should render inline bold', () => {
+ const { container } = render( );
+ const strong = container.querySelector('strong');
+ expect(strong).toBeTruthy();
+ expect(strong?.textContent).toBe('bold text');
});
- it('renders unordered lists', () => {
- const { container } = render( );
- const ul = container.querySelector('ul');
- expect(ul).toBeDefined();
- const items = container.querySelectorAll('li');
- expect(items.length).toBe(2);
- expect(items[0].textContent).toBe('item 1');
- expect(items[1].textContent).toBe('item 2');
+ it('should render inline italic', () => {
+ const { container } = render( );
+ const em = container.querySelector('em');
+ expect(em).toBeTruthy();
+ expect(em?.textContent).toBe('italic text');
});
- it('renders ordered lists', () => {
- const { container } = render( );
- const ol = container.querySelector('ol');
- expect(ol).toBeDefined();
- const items = container.querySelectorAll('li');
- expect(items.length).toBe(2);
- expect(items[0].textContent).toBe('first');
- expect(items[1].textContent).toBe('second');
+ it('should render strikethrough', () => {
+ const { container } = render( );
+ const del = container.querySelector('del');
+ expect(del).toBeTruthy();
+ expect(del?.textContent).toBe('strikethrough');
});
- it('renders code block content (pre tag stripped by sanitizer)', () => {
- const { container } = render( );
- // are stripped by sanitizeHtml but text content is preserved
- expect(container.textContent).toContain('const x = 1;');
+ it('should render inline code', () => {
+ const { container } = render( );
+ // The markdown renderer wraps inline code in tags
+ // Check if the content contains the code element
+ const content = container.querySelector('.markdown-content');
+ expect(content).toBeTruthy();
+ expect(content?.innerHTML).toContain('');
});
- it('escapes HTML in code blocks to prevent XSS', () => {
- const { container } = render(alert("xss")```'} />);
- // The raw HTML should be escaped and never rendered as actual HTML
- expect(container.innerHTML).not.toContain('" />
+ );
+ const script = container.querySelector('script');
+ expect(script).toBeFalsy();
});
- it('handles empty content', () => {
+ it('should handle empty input', () => {
const { container } = render( );
- expect(container.querySelector('.markdown-content')).toBeDefined();
+ expect(container.querySelector('.markdown-content')).toBeTruthy();
});
- it('renders underscore bold __text__', () => {
- const { container } = render( );
- const strong = container.querySelector('strong');
- expect(strong?.textContent).toBe('bold');
+ it('should handle unclosed code blocks', () => {
+ const { container } = render(
+
+ );
+ // The unclosed code block should still render as a paragraph
+ const p = container.querySelector('p');
+ expect(p).toBeTruthy();
});
- it('renders underscore italic _text_', () => {
- const { container } = render( );
+ it('should render bold and italic together', () => {
+ const { container } = render(
+
+ );
+ const strong = container.querySelector('strong');
const em = container.querySelector('em');
- expect(em?.textContent).toBe('italic');
+ expect(strong).toBeTruthy();
+ expect(em).toBeTruthy();
});
});
diff --git a/src/lib/mindmap-tree.ts b/src/lib/mindmap-tree.ts
new file mode 100644
index 00000000..c7c57aad
--- /dev/null
+++ b/src/lib/mindmap-tree.ts
@@ -0,0 +1,59 @@
+import type { Entity, Link } from './validation';
+
+interface MindMapNode {
+ id: string;
+ topic: string;
+ children: MindMapNode[];
+}
+
+/**
+ * Builds a tree structure from entities and links for mind map visualization.
+ *
+ * @param currentId - The ID of the current entity to start from
+ * @param depth - Current depth in the tree
+ * @param maxDepth - Maximum depth to traverse
+ * @param entities - Array of all entities
+ * @param links - Array of all links between entities
+ * @param relationFilter - Filter for link relations ('all' for no filter)
+ * @returns Tree node or null if entity not found or depth exceeded
+ */
+export function buildTree(
+ currentId: string,
+ depth: number,
+ maxDepth: number,
+ entities: Entity[],
+ links: Link[],
+ relationFilter: string,
+): MindMapNode | null {
+ const entity = entities.find(e => e.id === currentId);
+ if (!entity || depth > maxDepth) return null;
+
+ const childrenLinks = links.filter(l =>
+ l.source_id === currentId &&
+ (relationFilter === 'all' || l.relation === relationFilter)
+ );
+
+ return {
+ id: entity.id || `node-${Math.random()}`,
+ topic: entity.name,
+ children: childrenLinks
+ .map(l => buildTree(l.target_id, depth + 1, maxDepth, entities, links, relationFilter))
+ .filter((n): n is MindMapNode => n !== null)
+ };
+}
+
+/**
+ * Adds ARIA attributes to mind map nodes for accessibility.
+ *
+ * @param container - The container element containing mind map nodes
+ */
+export function addAriaToNodes(container: HTMLElement): void {
+ const topics = container.querySelectorAll('me-tpc');
+ topics.forEach(tpc => {
+ const parent = tpc.closest('me-parent');
+ if (parent && !parent.hasAttribute('role')) {
+ parent.setAttribute('role', 'treeitem');
+ parent.setAttribute('aria-label', tpc.textContent?.trim() || 'Mind map node');
+ }
+ });
+}
diff --git a/src/lib/security.ts b/src/lib/security.ts
index 0d959903..9bf78f6d 100644
--- a/src/lib/security.ts
+++ b/src/lib/security.ts
@@ -2,7 +2,7 @@ import DOMPurify from 'dompurify';
export function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, {
- ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
+ ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'ul', 'ol', 'li', 'p', 'br', 'span', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'code', 'pre', 'del'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i,
});
diff --git a/vitest.config.ts b/vitest.config.ts
index 8f57d022..add5cd2a 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -39,10 +39,10 @@ export default defineConfig({
'src/features/export/pdf-styles.ts',
],
thresholds: {
- branches: 30,
- functions: 35,
- lines: 40,
- statements: 40,
+ branches: 33,
+ functions: 38,
+ lines: 43,
+ statements: 42,
},
},
},