Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: table of contents and syntax-highlighted code blocks' (#214) from feat/toc-and-syntax-highlight into main

scott fd371142 f47dd678

+490 -12
+9
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.20.0] — 2026-04-01 9 + 10 + ### Added 11 + - Syntax highlighting in code blocks via lowlight with 30+ common languages (#293) 12 + - Table of contents slash command that auto-generates navigable heading outline (#292) 13 + - Language label badge on code blocks showing detected/set language 14 + - OkLCH-based syntax color theme for both light and dark modes 15 + - TOC styling with left border, nested indentation, and hover effects 16 + 8 17 ## [0.19.0] — 2026-04-01 9 18 10 19 ### Added
+86 -3
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.15.1", 3 + "version": "0.19.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.15.1", 9 + "version": "0.19.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 + "@tiptap/extension-code-block-lowlight": "^2.27.2", 12 13 "@tiptap/extension-collaboration": "^2.11.0", 13 14 "@tiptap/extension-collaboration-cursor": "^2.11.0", 14 15 "@tiptap/extension-color": "^2.11.0", ··· 38 39 "express": "^4.21.0", 39 40 "html2pdf.js": "^0.14.0", 40 41 "lib0": "^0.2.99", 42 + "lowlight": "^3.3.0", 41 43 "mammoth": "^1.12.0", 42 44 "markdown-it": "^14.1.1", 43 45 "turndown": "^7.2.2", ··· 2120 2122 "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", 2121 2123 "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", 2122 2124 "license": "MIT", 2125 + "peer": true, 2123 2126 "funding": { 2124 2127 "type": "github", 2125 2128 "url": "https://github.com/sponsors/ueberdosis" ··· 2129 2132 "@tiptap/pm": "^2.7.0" 2130 2133 } 2131 2134 }, 2135 + "node_modules/@tiptap/extension-code-block-lowlight": { 2136 + "version": "2.27.2", 2137 + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.27.2.tgz", 2138 + "integrity": "sha512-v6NKStBbQ/XCc1NnCi3ObsL1DsxadSIBtUQNA/B+urkPgn5LEy72HAGlf0xwjRaNkAGSaTASLKmc84L5q5zlGQ==", 2139 + "license": "MIT", 2140 + "funding": { 2141 + "type": "github", 2142 + "url": "https://github.com/sponsors/ueberdosis" 2143 + }, 2144 + "peerDependencies": { 2145 + "@tiptap/core": "^2.7.0", 2146 + "@tiptap/extension-code-block": "^2.7.0", 2147 + "@tiptap/pm": "^2.7.0", 2148 + "highlight.js": "^11", 2149 + "lowlight": "^2 || ^3" 2150 + } 2151 + }, 2132 2152 "node_modules/@tiptap/extension-collaboration": { 2133 2153 "version": "2.27.2", 2134 2154 "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-2.27.2.tgz", ··· 2765 2785 "@types/node": "*" 2766 2786 } 2767 2787 }, 2788 + "node_modules/@types/hast": { 2789 + "version": "3.0.4", 2790 + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", 2791 + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", 2792 + "license": "MIT", 2793 + "dependencies": { 2794 + "@types/unist": "*" 2795 + } 2796 + }, 2768 2797 "node_modules/@types/http-cache-semantics": { 2769 2798 "version": "4.2.0", 2770 2799 "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", ··· 2915 2944 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", 2916 2945 "license": "MIT", 2917 2946 "optional": true 2947 + }, 2948 + "node_modules/@types/unist": { 2949 + "version": "3.0.3", 2950 + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", 2951 + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", 2952 + "license": "MIT" 2918 2953 }, 2919 2954 "node_modules/@types/verror": { 2920 2955 "version": "1.10.11", ··· 4666 4701 "node": ">= 0.8" 4667 4702 } 4668 4703 }, 4704 + "node_modules/dequal": { 4705 + "version": "2.0.3", 4706 + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", 4707 + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", 4708 + "license": "MIT", 4709 + "engines": { 4710 + "node": ">=6" 4711 + } 4712 + }, 4669 4713 "node_modules/destroy": { 4670 4714 "version": "1.2.0", 4671 4715 "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", ··· 4692 4736 "dev": true, 4693 4737 "license": "MIT", 4694 4738 "optional": true 4739 + }, 4740 + "node_modules/devlop": { 4741 + "version": "1.1.0", 4742 + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", 4743 + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", 4744 + "license": "MIT", 4745 + "dependencies": { 4746 + "dequal": "^2.0.0" 4747 + }, 4748 + "funding": { 4749 + "type": "github", 4750 + "url": "https://github.com/sponsors/wooorm" 4751 + } 4695 4752 }, 4696 4753 "node_modules/dingbat-to-unicode": { 4697 4754 "version": "1.0.1", ··· 6099 6156 "node": ">= 0.4" 6100 6157 } 6101 6158 }, 6159 + "node_modules/highlight.js": { 6160 + "version": "11.11.1", 6161 + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", 6162 + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", 6163 + "license": "BSD-3-Clause", 6164 + "peer": true, 6165 + "engines": { 6166 + "node": ">=12.0.0" 6167 + } 6168 + }, 6102 6169 "node_modules/hosted-git-info": { 6103 6170 "version": "4.1.0", 6104 6171 "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", ··· 6521 6588 "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", 6522 6589 "dev": true, 6523 6590 "license": "MIT", 6524 - "peer": true, 6525 6591 "bin": { 6526 6592 "jiti": "lib/jiti-cli.mjs" 6527 6593 } ··· 6923 6989 "license": "MIT", 6924 6990 "engines": { 6925 6991 "node": ">=8" 6992 + } 6993 + }, 6994 + "node_modules/lowlight": { 6995 + "version": "3.3.0", 6996 + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", 6997 + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", 6998 + "license": "MIT", 6999 + "peer": true, 7000 + "dependencies": { 7001 + "@types/hast": "^3.0.0", 7002 + "devlop": "^1.0.0", 7003 + "highlight.js": "~11.11.0" 7004 + }, 7005 + "funding": { 7006 + "type": "github", 7007 + "url": "https://github.com/sponsors/wooorm" 6926 7008 } 6927 7009 }, 6928 7010 "node_modules/lru-cache": { ··· 7781 7863 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", 7782 7864 "dev": true, 7783 7865 "license": "MIT", 7866 + "peer": true, 7784 7867 "engines": { 7785 7868 "node": ">=12" 7786 7869 },
+6 -4
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.19.0", 3 + "version": "0.20.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js", ··· 18 18 }, 19 19 "dependencies": { 20 20 "@tiptap/core": "^2.11.0", 21 + "@tiptap/extension-code-block-lowlight": "^2.27.2", 21 22 "@tiptap/extension-collaboration": "^2.11.0", 22 23 "@tiptap/extension-collaboration-cursor": "^2.11.0", 23 24 "@tiptap/extension-color": "^2.11.0", ··· 47 48 "express": "^4.21.0", 48 49 "html2pdf.js": "^0.14.0", 49 50 "lib0": "^0.2.99", 51 + "lowlight": "^3.3.0", 50 52 "mammoth": "^1.12.0", 51 53 "markdown-it": "^14.1.1", 52 54 "turndown": "^7.2.2", ··· 63 65 "@types/node": "^25.5.0", 64 66 "@types/ws": "^8.18.1", 65 67 "concurrently": "^9.1.0", 68 + "electron": "^41.1.0", 69 + "electron-builder": "^26.8.1", 66 70 "fake-indexeddb": "^6.2.5", 67 71 "jsdom": "^29.0.0", 68 72 "jszip": "^3.10.1", 69 73 "tsx": "^4.21.0", 70 74 "typescript": "^5.9.3", 71 75 "vite": "^6.0.0", 72 - "vitest": "^4.1.0", 73 - "electron": "^41.1.0", 74 - "electron-builder": "^26.8.1" 76 + "vitest": "^4.1.0" 75 77 } 76 78 }
+148
src/css/app.css
··· 8374 8374 width: 160px; 8375 8375 } 8376 8376 } 8377 + 8378 + /* ============================================================ 8379 + Syntax Highlighting (lowlight / highlight.js class-based) 8380 + ============================================================ */ 8381 + 8382 + .tiptap pre { 8383 + position: relative; 8384 + } 8385 + 8386 + .tiptap pre code .hljs-comment, 8387 + .tiptap pre code .hljs-quote { 8388 + color: var(--color-text-faint); 8389 + font-style: italic; 8390 + } 8391 + 8392 + .tiptap pre code .hljs-keyword, 8393 + .tiptap pre code .hljs-selector-tag, 8394 + .tiptap pre code .hljs-built_in, 8395 + .tiptap pre code .hljs-type { 8396 + color: oklch(0.55 0.15 310); 8397 + } 8398 + [data-theme="dark"] .tiptap pre code .hljs-keyword, 8399 + [data-theme="dark"] .tiptap pre code .hljs-selector-tag, 8400 + [data-theme="dark"] .tiptap pre code .hljs-built_in, 8401 + [data-theme="dark"] .tiptap pre code .hljs-type { 8402 + color: oklch(0.72 0.14 310); 8403 + } 8404 + 8405 + .tiptap pre code .hljs-string, 8406 + .tiptap pre code .hljs-addition { 8407 + color: oklch(0.50 0.12 145); 8408 + } 8409 + [data-theme="dark"] .tiptap pre code .hljs-string, 8410 + [data-theme="dark"] .tiptap pre code .hljs-addition { 8411 + color: oklch(0.70 0.12 145); 8412 + } 8413 + 8414 + .tiptap pre code .hljs-number, 8415 + .tiptap pre code .hljs-literal { 8416 + color: oklch(0.52 0.14 55); 8417 + } 8418 + [data-theme="dark"] .tiptap pre code .hljs-number, 8419 + [data-theme="dark"] .tiptap pre code .hljs-literal { 8420 + color: oklch(0.72 0.12 55); 8421 + } 8422 + 8423 + .tiptap pre code .hljs-title, 8424 + .tiptap pre code .hljs-section { 8425 + color: oklch(0.50 0.14 250); 8426 + } 8427 + [data-theme="dark"] .tiptap pre code .hljs-title, 8428 + [data-theme="dark"] .tiptap pre code .hljs-section { 8429 + color: oklch(0.72 0.12 250); 8430 + } 8431 + 8432 + .tiptap pre code .hljs-name, 8433 + .tiptap pre code .hljs-selector-id, 8434 + .tiptap pre code .hljs-selector-class { 8435 + color: oklch(0.48 0.12 195); 8436 + } 8437 + [data-theme="dark"] .tiptap pre code .hljs-name, 8438 + [data-theme="dark"] .tiptap pre code .hljs-selector-id, 8439 + [data-theme="dark"] .tiptap pre code .hljs-selector-class { 8440 + color: oklch(0.68 0.1 195); 8441 + } 8442 + 8443 + .tiptap pre code .hljs-attr, 8444 + .tiptap pre code .hljs-attribute { 8445 + color: oklch(0.55 0.1 80); 8446 + } 8447 + [data-theme="dark"] .tiptap pre code .hljs-attr, 8448 + [data-theme="dark"] .tiptap pre code .hljs-attribute { 8449 + color: oklch(0.72 0.1 80); 8450 + } 8451 + 8452 + .tiptap pre code .hljs-deletion { 8453 + color: oklch(0.55 0.15 25); 8454 + } 8455 + [data-theme="dark"] .tiptap pre code .hljs-deletion { 8456 + color: oklch(0.70 0.14 25); 8457 + } 8458 + 8459 + .tiptap pre code .hljs-regexp, 8460 + .tiptap pre code .hljs-link { 8461 + color: oklch(0.52 0.12 170); 8462 + } 8463 + 8464 + .tiptap pre code .hljs-meta, 8465 + .tiptap pre code .hljs-meta .hljs-keyword, 8466 + .tiptap pre code .hljs-meta .hljs-string { 8467 + color: var(--color-text-muted); 8468 + } 8469 + 8470 + .tiptap pre code .hljs-emphasis { 8471 + font-style: italic; 8472 + } 8473 + 8474 + .tiptap pre code .hljs-strong { 8475 + font-weight: 700; 8476 + } 8477 + 8478 + /* Code block language label */ 8479 + .tiptap pre[data-language]::before { 8480 + content: attr(data-language); 8481 + position: absolute; 8482 + top: 0.25rem; 8483 + right: 0.5rem; 8484 + font-size: 0.65rem; 8485 + font-family: var(--font-mono); 8486 + color: var(--color-text-faint); 8487 + text-transform: uppercase; 8488 + letter-spacing: 0.05em; 8489 + pointer-events: none; 8490 + } 8491 + 8492 + /* ============================================================ 8493 + Table of Contents 8494 + ============================================================ */ 8495 + 8496 + .toc-list { 8497 + list-style: none; 8498 + padding-left: 0; 8499 + margin: 1em 0; 8500 + border-left: 2px solid var(--color-border); 8501 + padding-left: var(--space-md); 8502 + } 8503 + 8504 + .toc-list ul { 8505 + list-style: none; 8506 + padding-left: var(--space-lg); 8507 + margin: 0; 8508 + } 8509 + 8510 + .toc-list li { 8511 + margin: 0.25em 0; 8512 + } 8513 + 8514 + .toc-list a { 8515 + color: var(--color-text-muted); 8516 + text-decoration: none; 8517 + font-size: 0.9rem; 8518 + transition: color 0.15s; 8519 + } 8520 + 8521 + .toc-list a:hover { 8522 + color: var(--color-teal); 8523 + text-decoration: underline; 8524 + }
+8
src/docs/extensions/slash-commands.ts
··· 13 13 import type { Editor } from '@tiptap/core'; 14 14 import Suggestion from '@tiptap/suggestion'; 15 15 import type { SlashCommandsConfig, SlashCommandExecutableItem, CommandExecutor, SuggestionCallbackProps } from '../types.js'; 16 + import { generateTocHtml } from '../table-of-contents.js'; 16 17 17 18 interface SlashCommandItemWithId { 18 19 id: string; ··· 129 130 }, 130 131 pageBreak: (editor: Editor) => { 131 132 editor.chain().focus().insertPageBreak().run(); 133 + }, 134 + tableOfContents: (editor: Editor) => { 135 + const html = editor.getHTML(); 136 + const tocHtml = generateTocHtml(html); 137 + if (tocHtml) { 138 + editor.chain().focus().insertContent(tocHtml).run(); 139 + } 132 140 }, 133 141 toggle: (editor: Editor) => { 134 142 editor.chain().focus().insertToggleBlock().run();
+7 -1
src/docs/main.ts
··· 8 8 import * as Y from 'yjs'; 9 9 import { Editor } from '@tiptap/core'; 10 10 import StarterKit from '@tiptap/starter-kit'; 11 + import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'; 12 + import { common, createLowlight } from 'lowlight'; 11 13 import Underline from '@tiptap/extension-underline'; 12 14 import Link from '@tiptap/extension-link'; 13 15 import { ResizableImage } from './extensions/resizable-image.js'; ··· 162 164 const editor = new Editor({ 163 165 element: document.getElementById('editor'), 164 166 extensions: [ 165 - StarterKit.configure({ history: false }), 167 + StarterKit.configure({ history: false, codeBlock: false }), 168 + CodeBlockLowlight.configure({ 169 + lowlight: createLowlight(common), 170 + defaultLanguage: 'plaintext', 171 + }), 166 172 Underline, 167 173 Link.configure({ openOnClick: false }), 168 174 ResizableImage,
+8
src/docs/slash-menu.ts
··· 174 174 shortcut: null, 175 175 }, 176 176 { 177 + id: 'tableOfContents', 178 + name: 'Table of Contents', 179 + description: 'Auto-generated heading outline', 180 + category: 'advanced', 181 + icon: '\uD83D\uDCCB', 182 + shortcut: null, 183 + }, 184 + { 177 185 id: 'pageBreak', 178 186 name: 'Page Break', 179 187 description: 'Insert a page break',
+98
src/docs/table-of-contents.ts
··· 1 + /** 2 + * Table of Contents — generates navigable TOC HTML from document headings. 3 + * 4 + * Pure logic module: heading extraction, TOC HTML generation. 5 + * DOM rendering and insertion handled via slash command executor in main.ts. 6 + */ 7 + 8 + import { extractHeadings, type MinimapHeading } from './minimap.js'; 9 + 10 + export interface TocEntry { 11 + level: number; 12 + text: string; 13 + id: string; 14 + /** Nesting depth relative to the shallowest heading (0-based) */ 15 + depth: number; 16 + } 17 + 18 + /** 19 + * Build a flat list of TOC entries from HTML, with relative depth computed. 20 + */ 21 + export function buildTocEntries(html: string): TocEntry[] { 22 + const headings = extractHeadings(html); 23 + if (headings.length === 0) return []; 24 + 25 + const minLevel = Math.min(...headings.map(h => h.level)); 26 + 27 + return headings.map(h => ({ 28 + level: h.level, 29 + text: h.text, 30 + id: h.id, 31 + depth: h.level - minLevel, 32 + })); 33 + } 34 + 35 + /** 36 + * Generate an anchor-safe ID from heading text. 37 + * Lowercases, replaces spaces with hyphens, strips non-alphanumeric chars. 38 + */ 39 + export function slugify(text: string): string { 40 + return text 41 + .toLowerCase() 42 + .replace(/\s+/g, '-') 43 + .replace(/[^a-z0-9-]/g, '') 44 + .replace(/-+/g, '-') 45 + .replace(/^-|-$/g, ''); 46 + } 47 + 48 + /** 49 + * Generate TOC as a nested HTML list (<ul>) with anchor links. 50 + * Each link points to the heading's id (or a slugified fallback). 51 + */ 52 + export function generateTocHtml(html: string): string { 53 + const entries = buildTocEntries(html); 54 + if (entries.length === 0) return ''; 55 + 56 + let result = '<ul class="toc-list">'; 57 + let prevDepth = 0; 58 + 59 + for (let i = 0; i < entries.length; i++) { 60 + const entry = entries[i]; 61 + const anchor = entry.id || slugify(entry.text); 62 + const depth = entry.depth; 63 + 64 + if (depth > prevDepth) { 65 + // Open nested lists for each level deeper 66 + for (let d = prevDepth; d < depth; d++) { 67 + result += '<ul>'; 68 + } 69 + } else if (depth < prevDepth) { 70 + // Close nested lists for each level shallower 71 + for (let d = prevDepth; d > depth; d--) { 72 + result += '</li></ul>'; 73 + } 74 + result += '</li>'; 75 + } else if (i > 0) { 76 + result += '</li>'; 77 + } 78 + 79 + result += `<li><a href="#${anchor}">${escapeHtml(entry.text)}</a>`; 80 + prevDepth = depth; 81 + } 82 + 83 + // Close remaining open tags 84 + for (let d = prevDepth; d > 0; d--) { 85 + result += '</li></ul>'; 86 + } 87 + result += '</li></ul>'; 88 + 89 + return result; 90 + } 91 + 92 + function escapeHtml(text: string): string { 93 + return text 94 + .replace(/&/g, '&amp;') 95 + .replace(/</g, '&lt;') 96 + .replace(/>/g, '&gt;') 97 + .replace(/"/g, '&quot;'); 98 + }
+6 -4
tests/slash-commands.test.ts
··· 104 104 105 105 it('filters by name prefix (case-insensitive)', () => { 106 106 const result = filterCommands('head'); 107 - expect(result.length).toBeGreaterThanOrEqual(3); // heading 1, 2, 3 107 + expect(result.length).toBeGreaterThanOrEqual(3); // heading 1, 2, 3 (+ TOC via description) 108 108 for (const item of result) { 109 - expect(item.name.toLowerCase()).toContain('head'); 109 + const matchesName = item.name.toLowerCase().includes('head'); 110 + const matchesDesc = item.description.toLowerCase().includes('head'); 111 + expect(matchesName || matchesDesc).toBe(true); 110 112 } 111 113 }); 112 114 ··· 355 357 state.open(); 356 358 state.setQuery('heading'); 357 359 const grouped = state.getGroupedItems(); 358 - // Should only contain text category (headings) 359 - expect(grouped.length).toBe(1); 360 + // Text category has headings; Advanced has TOC (description mentions "heading") 361 + expect(grouped.length).toBeGreaterThanOrEqual(1); 360 362 expect(grouped[0].label).toBe('Text'); 361 363 }); 362 364 });
+114
tests/table-of-contents.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { buildTocEntries, slugify, generateTocHtml } from '../src/docs/table-of-contents.js'; 3 + 4 + describe('slugify', () => { 5 + it('lowercases and replaces spaces with hyphens', () => { 6 + expect(slugify('Hello World')).toBe('hello-world'); 7 + }); 8 + 9 + it('strips non-alphanumeric characters', () => { 10 + expect(slugify('What is E2EE?')).toBe('what-is-e2ee'); 11 + }); 12 + 13 + it('collapses consecutive hyphens', () => { 14 + expect(slugify('foo bar')).toBe('foo-bar'); 15 + }); 16 + 17 + it('trims leading/trailing hyphens', () => { 18 + expect(slugify(' -Hello- ')).toBe('hello'); 19 + }); 20 + 21 + it('returns empty string for empty input', () => { 22 + expect(slugify('')).toBe(''); 23 + }); 24 + }); 25 + 26 + describe('buildTocEntries', () => { 27 + it('returns empty array for empty HTML', () => { 28 + expect(buildTocEntries('')).toEqual([]); 29 + }); 30 + 31 + it('returns empty array for HTML with no headings', () => { 32 + expect(buildTocEntries('<p>Just a paragraph</p>')).toEqual([]); 33 + }); 34 + 35 + it('extracts a single heading', () => { 36 + const entries = buildTocEntries('<h1>Title</h1>'); 37 + expect(entries).toEqual([ 38 + { level: 1, text: 'Title', id: '', depth: 0 }, 39 + ]); 40 + }); 41 + 42 + it('computes relative depth from shallowest heading', () => { 43 + const html = '<h2>Section</h2><h3>Subsection</h3><h4>Sub-sub</h4>'; 44 + const entries = buildTocEntries(html); 45 + expect(entries).toEqual([ 46 + { level: 2, text: 'Section', id: '', depth: 0 }, 47 + { level: 3, text: 'Subsection', id: '', depth: 1 }, 48 + { level: 4, text: 'Sub-sub', id: '', depth: 2 }, 49 + ]); 50 + }); 51 + 52 + it('preserves heading ids', () => { 53 + const html = '<h1 id="intro">Introduction</h1>'; 54 + const entries = buildTocEntries(html); 55 + expect(entries[0].id).toBe('intro'); 56 + }); 57 + 58 + it('strips inline HTML from heading text', () => { 59 + const html = '<h2>Hello <strong>World</strong></h2>'; 60 + const entries = buildTocEntries(html); 61 + expect(entries[0].text).toBe('Hello World'); 62 + }); 63 + }); 64 + 65 + describe('generateTocHtml', () => { 66 + it('returns empty string for no headings', () => { 67 + expect(generateTocHtml('<p>text</p>')).toBe(''); 68 + }); 69 + 70 + it('generates flat list for same-level headings', () => { 71 + const html = '<h2>A</h2><h2>B</h2><h2>C</h2>'; 72 + const toc = generateTocHtml(html); 73 + expect(toc).toContain('<ul class="toc-list">'); 74 + expect(toc).toContain('<a href="#a">A</a>'); 75 + expect(toc).toContain('<a href="#b">B</a>'); 76 + expect(toc).toContain('<a href="#c">C</a>'); 77 + // Should not have nested <ul> (only the outer one) 78 + const innerUls = (toc.match(/<ul>/g) || []).length; 79 + expect(innerUls).toBe(0); // no inner <ul>, only the outer <ul class="toc-list"> 80 + }); 81 + 82 + it('generates nested list for hierarchical headings', () => { 83 + const html = '<h1>Title</h1><h2>Section</h2><h3>Sub</h3>'; 84 + const toc = generateTocHtml(html); 85 + // Should have nested <ul> tags for h2 and h3 86 + expect(toc).toContain('<ul>'); 87 + expect(toc).toContain('<a href="#title">Title</a>'); 88 + expect(toc).toContain('<a href="#section">Section</a>'); 89 + expect(toc).toContain('<a href="#sub">Sub</a>'); 90 + }); 91 + 92 + it('uses existing id when available', () => { 93 + const html = '<h1 id="custom-id">Title</h1>'; 94 + const toc = generateTocHtml(html); 95 + expect(toc).toContain('href="#custom-id"'); 96 + }); 97 + 98 + it('escapes HTML entities in heading text', () => { 99 + const html = '<h1>A &amp; B &lt;C&gt;</h1>'; 100 + const toc = generateTocHtml(html); 101 + // The heading text after stripping tags is "A & B <C>" which should be re-escaped 102 + expect(toc).toContain('&amp;'); 103 + }); 104 + 105 + it('handles heading level jumps gracefully', () => { 106 + // h1 → h3 (skips h2), should still nest correctly 107 + const html = '<h1>Title</h1><h3>Deep</h3>'; 108 + const toc = generateTocHtml(html); 109 + expect(toc).toContain('<a href="#title">Title</a>'); 110 + expect(toc).toContain('<a href="#deep">Deep</a>'); 111 + // Should produce valid HTML (nested ulss for the gap) 112 + expect(toc.split('<ul').length).toBeGreaterThan(2); 113 + }); 114 + });