diff --git a/package-lock.json b/package-lock.json index 84b35be..0b1a2ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,16 +8,17 @@ "name": "mdview", "version": "0.3.4", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@jamesainslie/docx": "^9.5.1-svg.1", "dompurify": "^3.0.6", - "highlight.js": "^11.9.0", "markdown-it-anchor": "^8.6.7", "markdown-it-attrs": "^4.1.6", "markdown-it-emoji": "^3.0.0", "markdown-it-footnote": "^4.0.0", - "markdown-it-task-lists": "^2.1.1", - "panzoom": "^9.4.3" + "markdown-it-task-lists": "^2.1.1" }, "devDependencies": { "@crxjs/vite-plugin": "^2.0.0", @@ -34,7 +35,6 @@ "jsdom": "^27.2.0", "lint-staged": "^16.2.7", "markdown-it": "^14.1.0", - "mermaid": "^11.12.1", "playwright": "^1.48.0", "prettier": "^3.1.0", "sharp": "^0.34.5", @@ -68,7 +68,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", - "dev": true, "license": "MIT", "dependencies": { "package-manager-detector": "^1.3.0", @@ -139,6 +138,7 @@ "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", @@ -154,6 +154,7 @@ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -162,14 +163,12 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", - "dev": true, "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", "integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@chevrotain/gast": "11.0.3", @@ -181,7 +180,6 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz", "integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@chevrotain/types": "11.0.3", @@ -192,21 +190,18 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz", "integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz", "integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==", - "dev": true, "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz", "integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==", - "dev": true, "license": "Apache-2.0" }, "node_modules/@crxjs/vite-plugin": { @@ -322,7 +317,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -366,7 +360,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1085,14 +1078,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, "license": "MIT" }, "node_modules/@iconify/utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", - "dev": true, "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", @@ -1679,6 +1670,14 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mdview/chrome-ext": { + "resolved": "packages/chrome-ext", + "link": true + }, + "node_modules/@mdview/core": { + "resolved": "packages/core", + "link": true + }, "node_modules/@mermaid-js/mermaid-cli": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/@mermaid-js/mermaid-cli/-/mermaid-cli-11.12.0.tgz", @@ -1742,7 +1741,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz", "integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==", - "dev": true, "license": "MIT", "dependencies": { "langium": "3.3.1" @@ -1792,6 +1790,7 @@ "integrity": "sha512-aBSREisdsGH890S2rQqK82qmQYU3uFpSH8wcZWHgHzl3LfzsxAKbLNiAG9mO8v1Y0UICBeClICxPJvyr0rcuxg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "debug": "^4.4.0", "extract-zip": "^2.0.1", @@ -2286,7 +2285,8 @@ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -2314,7 +2314,6 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-array": "*", @@ -2353,14 +2352,12 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-axis": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -2370,7 +2367,6 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -2380,21 +2376,18 @@ "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-contour": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-array": "*", @@ -2405,21 +2398,18 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-dispatch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-drag": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -2429,21 +2419,18 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-fetch": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-dsv": "*" @@ -2453,21 +2440,18 @@ "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-format": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-geo": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/geojson": "*" @@ -2477,14 +2461,12 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-color": "*" @@ -2494,35 +2476,30 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-polygon": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-quadtree": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-random": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-time": "*" @@ -2532,21 +2509,18 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-shape": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-path": "*" @@ -2556,28 +2530,24 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-time-format": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "dev": true, "license": "MIT" }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-selection": "*" @@ -2587,7 +2557,6 @@ "version": "3.0.8", "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", - "dev": true, "license": "MIT", "dependencies": { "@types/d3-interpolate": "*", @@ -2639,7 +2608,6 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, "license": "MIT" }, "node_modules/@types/har-format": { @@ -2679,7 +2647,6 @@ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz", "integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==", "license": "MIT", - "peer": true, "dependencies": { "@types/linkify-it": "^3", "@types/mdurl": "^1" @@ -2729,6 +2696,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "@types/node": "*" } @@ -2775,7 +2743,6 @@ "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -3148,9 +3115,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3329,6 +3294,7 @@ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.0.1" }, @@ -3352,6 +3318,7 @@ "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "react-native-b4a": "*" }, @@ -3374,6 +3341,7 @@ "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "peerDependencies": { "bare-abort-controller": "*" }, @@ -3390,6 +3358,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -3416,6 +3385,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "engines": { "bare": ">=1.14.0" } @@ -3427,6 +3397,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "bare-os": "^3.0.1" } @@ -3438,6 +3409,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "streamx": "^2.21.0" }, @@ -3461,6 +3433,7 @@ "dev": true, "license": "Apache-2.0", "optional": true, + "peer": true, "dependencies": { "bare-path": "^3.0.0" } @@ -3492,6 +3465,7 @@ "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" } @@ -3575,6 +3549,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -3586,6 +3561,7 @@ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "*" } @@ -3685,9 +3661,7 @@ "version": "11.0.3", "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz", "integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==", - "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", @@ -3701,7 +3675,6 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz", "integrity": "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==", - "dev": true, "license": "MIT", "dependencies": { "lodash-es": "^4.17.21" @@ -3754,6 +3727,7 @@ "integrity": "sha512-6CJWHkNRoyZyjV9Rwv2lYONZf1Xm0IuDyNq97nwSsxxP3wf5Bwy15K5rOvVKMtJ127jJBmxFUanSAOjgFRxgrA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "mitt": "3.0.1", "zod": "3.23.8" @@ -3860,6 +3834,7 @@ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -3933,7 +3908,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -3950,7 +3924,6 @@ "version": "0.1.8", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -3970,7 +3943,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", - "dev": true, "license": "MIT", "dependencies": { "layout-base": "^1.0.0" @@ -3982,6 +3954,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4094,9 +4067,7 @@ "version": "3.33.1", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10" } @@ -4105,7 +4076,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", - "dev": true, "license": "MIT", "dependencies": { "cose-base": "^1.0.0" @@ -4118,7 +4088,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz", "integrity": "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==", - "dev": true, "license": "MIT", "dependencies": { "cose-base": "^2.2.0" @@ -4131,7 +4100,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-2.2.0.tgz", "integrity": "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==", - "dev": true, "license": "MIT", "dependencies": { "layout-base": "^2.0.0" @@ -4141,14 +4109,12 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz", "integrity": "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==", - "dev": true, "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "3", @@ -4190,7 +4156,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "dev": true, "license": "ISC", "dependencies": { "internmap": "1 - 2" @@ -4203,7 +4168,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4213,7 +4177,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4230,7 +4193,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", - "dev": true, "license": "ISC", "dependencies": { "d3-path": "1 - 3" @@ -4243,7 +4205,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4253,7 +4214,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "^3.2.0" @@ -4266,7 +4226,6 @@ "version": "6.0.4", "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", - "dev": true, "license": "ISC", "dependencies": { "delaunator": "5" @@ -4279,7 +4238,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4289,7 +4247,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4303,7 +4260,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", - "dev": true, "license": "ISC", "dependencies": { "commander": "7", @@ -4329,7 +4285,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12" @@ -4339,7 +4294,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", - "dev": true, "license": "ISC", "dependencies": { "d3-dsv": "1 - 3" @@ -4352,7 +4306,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4367,7 +4320,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4377,7 +4329,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.5.0 - 3" @@ -4390,7 +4341,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4400,7 +4350,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -4413,7 +4362,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4423,7 +4371,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4433,7 +4380,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4443,7 +4389,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4453,7 +4398,6 @@ "version": "0.12.3", "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "d3-array": "1 - 2", @@ -4464,7 +4408,6 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "internmap": "^1.0.0" @@ -4474,14 +4417,12 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/d3-sankey/node_modules/d3-shape": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "d3-path": "1" @@ -4491,14 +4432,12 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", - "dev": true, "license": "ISC" }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", @@ -4515,7 +4454,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -4529,9 +4467,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -4540,7 +4476,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "dev": true, "license": "ISC", "dependencies": { "d3-path": "^3.1.0" @@ -4553,7 +4488,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "dev": true, "license": "ISC", "dependencies": { "d3-array": "2 - 3" @@ -4566,7 +4500,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "dev": true, "license": "ISC", "dependencies": { "d3-time": "1 - 3" @@ -4579,7 +4512,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4589,7 +4521,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -4609,7 +4540,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4626,7 +4556,6 @@ "version": "7.0.13", "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", - "dev": true, "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -4639,6 +4568,7 @@ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 14" } @@ -4661,7 +4591,6 @@ "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -4702,6 +4631,7 @@ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", @@ -4715,7 +4645,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", - "dev": true, "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" @@ -4852,7 +4781,8 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/encoding-sniffer": { "version": "0.2.1", @@ -4874,6 +4804,7 @@ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "once": "^1.4.0" } @@ -4896,6 +4827,7 @@ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -4919,6 +4851,7 @@ "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-arrayish": "^0.2.1" } @@ -5029,6 +4962,7 @@ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -5052,6 +4986,7 @@ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", @@ -5075,7 +5010,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -5204,6 +5138,7 @@ "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -5298,6 +5233,7 @@ "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "bare-events": "^2.7.0" } @@ -5318,6 +5254,7 @@ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", @@ -5345,7 +5282,8 @@ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-glob": { "version": "3.3.3", @@ -5417,6 +5355,7 @@ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pend": "~1.2.0" } @@ -5539,6 +5478,7 @@ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -5562,6 +5502,7 @@ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pump": "^3.0.0" }, @@ -5591,6 +5532,7 @@ "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", @@ -5714,7 +5656,6 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", - "dev": true, "license": "MIT" }, "node_modules/has-flag": { @@ -5860,7 +5801,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -5977,7 +5917,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -5989,6 +5928,7 @@ "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 12" } @@ -5998,7 +5938,8 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -6045,6 +5986,7 @@ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -6108,7 +6050,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6148,7 +6089,8 @@ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/js-yaml": { "version": "4.1.1", @@ -6254,7 +6196,8 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-schema-traverse": { "version": "0.4.1", @@ -6299,7 +6242,6 @@ "version": "0.16.25", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz", "integrity": "sha512-woHRUZ/iF23GBP1dkDQMh1QBad9dmr8/PAwNA54VrSOVYgI12MAcE14TqnDdQOdzyEonGzMepYnqBMYdsoAr8Q==", - "dev": true, "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -6316,7 +6258,6 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -6335,14 +6276,12 @@ "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", - "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==", - "dev": true + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, "node_modules/langium": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz", "integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==", - "dev": true, "license": "MIT", "dependencies": { "chevrotain": "~11.0.3", @@ -6359,7 +6298,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", - "dev": true, "license": "MIT" }, "node_modules/levn": { @@ -6579,7 +6517,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -6700,6 +6637,7 @@ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -6719,7 +6657,6 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "license": "MIT", - "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -6776,7 +6713,6 @@ "version": "16.4.2", "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", - "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -6812,9 +6748,7 @@ "version": "11.12.1", "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.1.tgz", "integrity": "sha512-UlIZrRariB11TY1RtTgUWp65tphtBv4CSq7vyS2ZZ2TgoMjs2nloq+wFqxiwcxlhHUvs7DPGgMjs2aeQxz5h9g==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", @@ -6892,13 +6826,13 @@ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", - "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.15.0", @@ -6971,6 +6905,7 @@ "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -7111,6 +7046,7 @@ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", @@ -7131,6 +7067,7 @@ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" @@ -7143,7 +7080,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", - "dev": true, "license": "MIT" }, "node_modules/pako": { @@ -7182,6 +7118,7 @@ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -7252,7 +7189,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", - "dev": true, "license": "MIT" }, "node_modules/path-exists": { @@ -7306,7 +7242,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, "license": "MIT" }, "node_modules/pend": { @@ -7314,7 +7249,8 @@ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/picocolors": { "version": "1.1.1", @@ -7487,7 +7423,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.1.8", @@ -7531,14 +7466,12 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==", - "dev": true, "license": "MIT" }, "node_modules/points-on-path": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", - "dev": true, "license": "MIT", "dependencies": { "path-data-parser": "0.1.0", @@ -7565,7 +7498,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7764,6 +7696,7 @@ "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.4.0" } @@ -7774,6 +7707,7 @@ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -7793,7 +7727,8 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pump": { "version": "3.0.3", @@ -7801,6 +7736,7 @@ "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -7855,6 +7791,7 @@ "integrity": "sha512-3HZ2/7hdDKZvZQ7dhhITOUg4/wOrDRjyK2ZBllRB0ZCOi9u0cwq1ACHDjBB+nX+7+kltHjQvBRdeY7+W0T+7Gg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@puppeteer/browsers": "2.6.1", "chromium-bidi": "0.11.0", @@ -7922,7 +7859,6 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7933,7 +7869,6 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8005,6 +7940,7 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8132,7 +8068,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", - "dev": true, "license": "Unlicense" }, "node_modules/rollup": { @@ -8155,7 +8090,6 @@ "version": "4.6.6", "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz", "integrity": "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==", - "dev": true, "license": "MIT", "dependencies": { "hachure-fill": "^0.5.2", @@ -8192,7 +8126,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/rxjs": { @@ -8225,7 +8158,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sax": { @@ -8423,6 +8355,7 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -8434,6 +8367,7 @@ "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" @@ -8449,6 +8383,7 @@ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -8475,6 +8410,7 @@ "dev": true, "license": "BSD-3-Clause", "optional": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8519,6 +8455,7 @@ "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", @@ -8550,6 +8487,7 @@ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -8589,7 +8527,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", - "dev": true, "license": "MIT" }, "node_modules/sucrase": { @@ -8682,7 +8619,6 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -8721,6 +8657,7 @@ "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" @@ -8736,6 +8673,7 @@ "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", @@ -8748,6 +8686,7 @@ "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "b4a": "^1.6.4" } @@ -8797,7 +8736,8 @@ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinybench": { "version": "2.9.0", @@ -8810,7 +8750,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -8857,7 +8796,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8951,7 +8889,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.10" @@ -8977,7 +8914,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -9471,7 +9407,8 @@ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/typescript": { "version": "5.9.3", @@ -9479,7 +9416,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9498,7 +9434,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true, "license": "MIT" }, "node_modules/unbzip2-stream": { @@ -9507,6 +9442,7 @@ "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" @@ -9569,7 +9505,6 @@ "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "dev": true, "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -9585,7 +9520,6 @@ "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -9694,7 +9628,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9846,7 +9779,6 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", - "dev": true, "license": "MIT", "engines": { "node": ">=14.0.0" @@ -9856,7 +9788,6 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", - "dev": true, "license": "MIT", "dependencies": { "vscode-languageserver-protocol": "3.17.5" @@ -9869,7 +9800,6 @@ "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", - "dev": true, "license": "MIT", "dependencies": { "vscode-jsonrpc": "8.2.0", @@ -9880,21 +9810,18 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "dev": true, "license": "MIT" }, "node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "dev": true, "license": "MIT" }, "node_modules/vscode-uri": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz", "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", - "dev": true, "license": "MIT" }, "node_modules/w3c-xmlserializer": { @@ -10012,6 +9939,7 @@ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -10094,6 +10022,7 @@ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=10" } @@ -10104,7 +10033,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -10118,6 +10046,7 @@ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -10137,6 +10066,7 @@ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -10147,6 +10077,7 @@ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" @@ -10171,9 +10102,54 @@ "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "packages/chrome-ext": { + "name": "@mdview/chrome-ext", + "version": "0.3.4", + "dependencies": { + "@mdview/core": "*" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.287" + } + }, + "packages/chrome-ext/node_modules/@types/chrome": { + "version": "0.0.287", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz", + "integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "packages/core": { + "name": "@mdview/core", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@jamesainslie/docx": "^9.5.1-svg.1", + "dompurify": "^3.0.6", + "highlight.js": "^11.9.0", + "markdown-it": "^14.1.0", + "markdown-it-anchor": "^8.6.7", + "markdown-it-attrs": "^4.1.6", + "markdown-it-emoji": "^3.0.0", + "markdown-it-footnote": "^4.0.0", + "markdown-it-task-lists": "^2.1.1", + "mermaid": "^11.12.1", + "panzoom": "^9.4.3" + }, + "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/markdown-it": "^13.0.9" + } } } } diff --git a/package.json b/package.json index 8371654..fb87289 100644 --- a/package.json +++ b/package.json @@ -3,28 +3,30 @@ "version": "0.3.4", "description": "Beautiful Chrome extension for viewing Markdown files with themes, syntax highlighting, and interactive Mermaid diagrams", "type": "module", + "workspaces": [ + "packages/*" + ], "scripts": { - "dev": "vite build --watch", - "build": "node scripts/update-manifest-version.js && tsc && vite build && node scripts/fix-manifest.js", + "dev": "npm -w @mdview/chrome-ext run dev", + "build": "npm -w @mdview/chrome-ext run build", + "build:ext": "npm -w @mdview/chrome-ext run build", "export:docx": "tsx scripts/export-docx-mermaid-cli.ts", "preview": "vite preview", "test": "vitest", "test:ci": "vitest --run", "test:e2e": "playwright test", - "lint": "eslint src --ext ts,tsx", - "format": "prettier --write \"src/**/*.{ts,tsx,css}\"", + "lint": "eslint packages --ext ts,tsx", + "format": "prettier --write \"packages/*/src/**/*.{ts,tsx,css}\"", "prepare": "husky" }, "dependencies": { "@jamesainslie/docx": "^9.5.1-svg.1", "dompurify": "^3.0.6", - "highlight.js": "^11.9.0", "markdown-it-anchor": "^8.6.7", "markdown-it-attrs": "^4.1.6", "markdown-it-emoji": "^3.0.0", "markdown-it-footnote": "^4.0.0", - "markdown-it-task-lists": "^2.1.1", - "panzoom": "^9.4.3" + "markdown-it-task-lists": "^2.1.1" }, "devDependencies": { "@crxjs/vite-plugin": "^2.0.0", @@ -41,7 +43,6 @@ "jsdom": "^27.2.0", "lint-staged": "^16.2.7", "markdown-it": "^14.1.0", - "mermaid": "^11.12.1", "playwright": "^1.48.0", "prettier": "^3.1.0", "sharp": "^0.34.5", @@ -65,9 +66,12 @@ "author": "James Ainslie", "license": "MIT", "lint-staged": { - "{src,tests}/**/*.{ts,tsx}": [ + "packages/*/src/**/*.{ts,tsx}": [ "eslint --fix", "prettier --write" + ], + "tests/**/*.{ts,tsx}": [ + "prettier --write" ] } } diff --git a/packages/chrome-ext/package.json b/packages/chrome-ext/package.json new file mode 100644 index 0000000..3de5e54 --- /dev/null +++ b/packages/chrome-ext/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mdview/chrome-ext", + "version": "0.3.4", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "@mdview/core": "*" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.287" + } +} diff --git a/public/icons/icon128.png b/packages/chrome-ext/public/icons/icon128.png similarity index 100% rename from public/icons/icon128.png rename to packages/chrome-ext/public/icons/icon128.png diff --git a/public/icons/icon128.svg b/packages/chrome-ext/public/icons/icon128.svg similarity index 100% rename from public/icons/icon128.svg rename to packages/chrome-ext/public/icons/icon128.svg diff --git a/public/icons/icon16.png b/packages/chrome-ext/public/icons/icon16.png similarity index 100% rename from public/icons/icon16.png rename to packages/chrome-ext/public/icons/icon16.png diff --git a/public/icons/icon16.svg b/packages/chrome-ext/public/icons/icon16.svg similarity index 100% rename from public/icons/icon16.svg rename to packages/chrome-ext/public/icons/icon16.svg diff --git a/public/icons/icon32.png b/packages/chrome-ext/public/icons/icon32.png similarity index 100% rename from public/icons/icon32.png rename to packages/chrome-ext/public/icons/icon32.png diff --git a/public/icons/icon32.svg b/packages/chrome-ext/public/icons/icon32.svg similarity index 100% rename from public/icons/icon32.svg rename to packages/chrome-ext/public/icons/icon32.svg diff --git a/public/icons/icon48.png b/packages/chrome-ext/public/icons/icon48.png similarity index 100% rename from public/icons/icon48.png rename to packages/chrome-ext/public/icons/icon48.png diff --git a/public/icons/icon48.svg b/packages/chrome-ext/public/icons/icon48.svg similarity index 100% rename from public/icons/icon48.svg rename to packages/chrome-ext/public/icons/icon48.svg diff --git a/public/manifest.json b/packages/chrome-ext/public/manifest.json similarity index 100% rename from public/manifest.json rename to packages/chrome-ext/public/manifest.json diff --git a/scripts/fix-manifest.js b/packages/chrome-ext/scripts/fix-manifest.js similarity index 100% rename from scripts/fix-manifest.js rename to packages/chrome-ext/scripts/fix-manifest.js diff --git a/scripts/generate-icons.cjs b/packages/chrome-ext/scripts/generate-icons.cjs similarity index 100% rename from scripts/generate-icons.cjs rename to packages/chrome-ext/scripts/generate-icons.cjs diff --git a/scripts/generate-icons.js b/packages/chrome-ext/scripts/generate-icons.js similarity index 100% rename from scripts/generate-icons.js rename to packages/chrome-ext/scripts/generate-icons.js diff --git a/scripts/svg-to-png.cjs b/packages/chrome-ext/scripts/svg-to-png.cjs similarity index 100% rename from scripts/svg-to-png.cjs rename to packages/chrome-ext/scripts/svg-to-png.cjs diff --git a/scripts/update-manifest-version.js b/packages/chrome-ext/scripts/update-manifest-version.js similarity index 100% rename from scripts/update-manifest-version.js rename to packages/chrome-ext/scripts/update-manifest-version.js diff --git a/src/background/service-worker.ts b/packages/chrome-ext/src/background/service-worker.ts similarity index 96% rename from src/background/service-worker.ts rename to packages/chrome-ext/src/background/service-worker.ts index 53e85a8..fa96e89 100644 --- a/src/background/service-worker.ts +++ b/packages/chrome-ext/src/background/service-worker.ts @@ -3,8 +3,8 @@ * Handles state management, message passing, coordination, and cache management */ -import type { AppState, ThemeName } from '../types'; -import { CacheManager } from '../core/cache-manager'; +import type { AppState, ThemeName, CachedResult } from '@mdview/core'; +import { CacheManager } from '@mdview/core'; import { debug } from '../utils/debug-logger'; // Default state @@ -151,12 +151,14 @@ function setupContextMenu(): void { // Handle context menu clicks chrome.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId === 'mdview-add-comment' && tab?.id) { - void chrome.tabs.sendMessage(tab.id, { - type: 'ADD_COMMENT', - payload: { selectionText: info.selectionText }, - }).catch(() => { - // Tab may not have content script - }); + void chrome.tabs + .sendMessage(tab.id, { + type: 'ADD_COMMENT', + payload: { selectionText: info.selectionText }, + }) + .catch(() => { + // Tab may not have content script + }); } }); @@ -299,7 +301,7 @@ chrome.runtime.onMessage.addListener( case 'CACHE_SET': { const payload = message.payload as { key: string; - result: import('../types').CachedResult; + result: CachedResult; filePath: string; contentHash: string; theme: ThemeName; @@ -367,7 +369,7 @@ chrome.runtime.onMessage.addListener( case 'GET_USERNAME': { try { - const result = await chrome.runtime.sendNativeMessage( + const result: unknown = await chrome.runtime.sendNativeMessage( 'com.mdview.filewriter', { action: 'get_username' } ); @@ -382,7 +384,7 @@ chrome.runtime.onMessage.addListener( case 'WRITE_FILE': { const payload = message.payload as { path: string; content: string }; try { - const result = await chrome.runtime.sendNativeMessage( + const result: unknown = await chrome.runtime.sendNativeMessage( 'com.mdview.filewriter', { action: 'write', diff --git a/packages/chrome-ext/src/comments/comment-manager.ts b/packages/chrome-ext/src/comments/comment-manager.ts new file mode 100644 index 0000000..39e6ca6 --- /dev/null +++ b/packages/chrome-ext/src/comments/comment-manager.ts @@ -0,0 +1,92 @@ +/** + * Comment Manager — Chrome extension shim + * + * Re-exports the core CommentManager from @mdview/core, pre-configured + * with Chrome extension adapters that delegate to chrome.runtime.sendMessage + * for file writes (WRITE_FILE) and username lookup (GET_USERNAME). + * + * This shim will be removed once the extension is fully migrated to core. + */ + +import { + CommentManager as CoreCommentManager, + type CommentManagerAdapters, + type FileAdapter, + type IdentityAdapter, + type FileWriteResult, + type FileChangeInfo, +} from '@mdview/core'; + +/** + * Type-safe helper to send a Chrome runtime message and parse the response. + */ +async function sendChromeMessage(message: Record): Promise { + const result: unknown = await chrome.runtime.sendMessage(message); + return result as T | undefined; +} + +/** + * Chrome extension FileAdapter — delegates to the service worker + * via chrome.runtime.sendMessage({ type: 'WRITE_FILE', ... }). + */ +class ChromeFileAdapter implements FileAdapter { + async writeFile(path: string, content: string): Promise { + const response = await sendChromeMessage<{ success?: boolean; error?: string }>({ + type: 'WRITE_FILE', + payload: { path, content }, + }); + + if (response?.error) { + return { success: false, error: response.error }; + } + return { success: true }; + } + + readFile(_path: string): Promise { + return Promise.reject(new Error('readFile not supported via Chrome messaging')); + } + + checkChanged(_url: string, _lastHash: string): Promise { + return Promise.resolve({ changed: false }); + } + + watch(_path: string, _callback: () => void): () => void { + return () => {}; + } +} + +/** + * Chrome extension IdentityAdapter — delegates to the native host + * via chrome.runtime.sendMessage({ type: 'GET_USERNAME' }). + */ +class ChromeIdentityAdapter implements IdentityAdapter { + async getUsername(): Promise { + try { + const resp = await sendChromeMessage<{ username?: string }>({ + type: 'GET_USERNAME', + }); + return resp?.username ?? ''; + } catch { + // Native host may not be installed + return ''; + } + } +} + +/** + * CommentManager pre-configured with Chrome extension adapters. + * Drop-in replacement for the previous monolithic CommentManager. + */ +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging +export class CommentManager extends CoreCommentManager { + constructor(adapters?: CommentManagerAdapters) { + // If no adapters provided, use Chrome-backed defaults + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const resolved: CommentManagerAdapters = adapters ?? { + file: new ChromeFileAdapter(), + identity: new ChromeIdentityAdapter(), + }; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + super(resolved); + } +} diff --git a/src/content/content-script.ts b/packages/chrome-ext/src/content/content-script.ts similarity index 98% rename from src/content/content-script.ts rename to packages/chrome-ext/src/content/content-script.ts index 2d0c722..3e4d5f5 100644 --- a/src/content/content-script.ts +++ b/packages/chrome-ext/src/content/content-script.ts @@ -6,9 +6,9 @@ // CSS is imported but only activated when we add .mdview-active to body import './content.css'; import { FileScanner } from '../utils/file-scanner'; -import type { AppState } from '../types'; +import type { AppState } from '@mdview/core'; import { debug } from '../utils/debug-logger'; -import { TocRenderer } from '../ui/toc-renderer'; +import { TocRenderer } from '@mdview/core'; import type { ExportUI } from '../ui/export-ui'; import type { CommentManager } from '../comments/comment-manager'; @@ -266,7 +266,10 @@ class MDViewContentScript { } // Setup Comments (only for local files, default enabled) - if (this.state?.preferences.commentsEnabled !== false && window.location.protocol === 'file:') { + if ( + this.state?.preferences.commentsEnabled !== false && + window.location.protocol === 'file:' + ) { await this.setupComments(content, filePath); } @@ -608,7 +611,7 @@ class MDViewContentScript { debug.info('MDView', `[ContentScript] Received request to apply theme: ${themeName}`); const { themeEngine } = await import('../core/theme-engine'); debug.debug('MDView', `[ContentScript] Theme engine imported, calling applyTheme`); - await themeEngine.applyTheme(themeName as import('../types').ThemeName); + await themeEngine.applyTheme(themeName as import('@mdview/core').ThemeName); debug.info('MDView', '[ContentScript] Theme applied successfully via engine'); } catch (error) { debug.error('MDView', '[ContentScript] Failed to apply theme:', error); @@ -903,7 +906,12 @@ class MDViewContentScript { debug.info('MDView', 'Setting up Comments...'); const { CommentManager } = await import('../comments/comment-manager'); this.commentManager = new CommentManager(); - await this.commentManager.initialize(markdown, filePath, this.state!.preferences); + const preferences = this.state?.preferences; + if (!preferences) { + debug.warn('MDView', 'Cannot initialize comments: state not loaded'); + return; + } + await this.commentManager.initialize(markdown, filePath, preferences); debug.info('MDView', 'Comments initialized'); } catch (error) { debug.error('MDView', 'Failed to setup comments:', error); diff --git a/src/content/content.css b/packages/chrome-ext/src/content/content.css similarity index 100% rename from src/content/content.css rename to packages/chrome-ext/src/content/content.css diff --git a/packages/chrome-ext/src/core/render-pipeline.ts b/packages/chrome-ext/src/core/render-pipeline.ts new file mode 100644 index 0000000..b477e03 --- /dev/null +++ b/packages/chrome-ext/src/core/render-pipeline.ts @@ -0,0 +1,53 @@ +/** + * Render Pipeline (Chrome Extension Shim) + * + * Re-exports the platform-agnostic RenderPipeline from @mdview/core, + * pre-configured with a Chrome MessagingAdapter that delegates + * to chrome.runtime.sendMessage. + */ + +import { + RenderPipeline as CoreRenderPipeline, + type RenderOptions, + type RenderProgress, + type ProgressCallback, + type RenderPipelineOptions, + type MessagingAdapter, + type IPCMessage, +} from '@mdview/core'; + +// --------------------------------------------------------------------------- +// Chrome MessagingAdapter — bridges core pipeline to chrome.runtime +// --------------------------------------------------------------------------- + +class ChromeMessagingAdapter implements MessagingAdapter { + async send(message: IPCMessage): Promise { + return chrome.runtime.sendMessage(message); + } +} + +// --------------------------------------------------------------------------- +// Re-export the pipeline class (consumers can still `new RenderPipeline()`) +// --------------------------------------------------------------------------- + +/** + * Chrome-flavored RenderPipeline. + * + * Extends the core pipeline by injecting a ChromeMessagingAdapter so that + * cache operations (CACHE_GENERATE_KEY, CACHE_GET, CACHE_SET) are routed + * through the Chrome extension service worker. + */ +export class RenderPipeline extends CoreRenderPipeline { + constructor(options?: RenderPipelineOptions) { + super({ + messaging: new ChromeMessagingAdapter(), + ...options, + }); + } +} + +// Re-export types so existing consumers keep working +export type { RenderOptions, RenderProgress, ProgressCallback }; + +// Export singleton +export const renderPipeline = new RenderPipeline(); diff --git a/packages/chrome-ext/src/core/theme-engine.ts b/packages/chrome-ext/src/core/theme-engine.ts new file mode 100644 index 0000000..c511353 --- /dev/null +++ b/packages/chrome-ext/src/core/theme-engine.ts @@ -0,0 +1,204 @@ +/** + * Theme Engine — Chrome extension shim + * + * Wraps the platform-agnostic ThemeEngine from @mdview/core, + * wired up with a Chrome StorageAdapter and DOM-specific methods + * (applyTheme, watchSystemTheme, updateSyntaxTheme). + */ + +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */ + +import type { Theme, ThemeName, StorageAdapter, ThemeOverrides } from '@mdview/core'; +import { ThemeEngine as CoreThemeEngine } from '@mdview/core'; +import { debug } from '../utils/debug-logger'; + +export type { ThemeInfo } from '@mdview/core'; + +// --------------------------------------------------------------------------- +// Chrome StorageAdapter +// --------------------------------------------------------------------------- + +const chromeStorageAdapter: StorageAdapter = { + async getSync(keys: string | string[]): Promise> { + return chrome.storage.sync.get(keys) as Promise>; + }, + async setSync(data: Record): Promise { + await chrome.storage.sync.set(data); + }, + async getLocal(keys: string | string[]): Promise> { + return chrome.storage.local.get(keys) as Promise>; + }, + async setLocal(data: Record): Promise { + await chrome.storage.local.set(data); + }, +}; + +// --------------------------------------------------------------------------- +// Chrome ThemeEngine — composition over the core engine +// --------------------------------------------------------------------------- + +export class ThemeEngine { + private core: InstanceType; + + constructor() { + this.core = new CoreThemeEngine(chromeStorageAdapter); + } + + /** + * Load theme by name (delegated to core) + */ + async loadTheme(themeName: ThemeName): Promise { + return this.core.loadTheme(themeName); + } + + /** + * Get currently applied theme + */ + getCurrentTheme(): Theme | null { + return this.core.getCurrentTheme(); + } + + /** + * Get list of available themes + */ + getAvailableThemes(): Array<{ + name: ThemeName; + displayName: string; + variant: 'light' | 'dark'; + preview?: string; + }> { + return this.core.getAvailableThemes(); + } + + /** + * Compile theme to CSS variables (delegated to core) + */ + compileToCSSVariables( + theme: Theme, + overrides: Partial = {} + ): Record { + return this.core.compileToCSSVariables(theme, overrides); + } + + /** + * Apply theme to document (DOM-specific) + */ + async applyTheme(theme: Theme | ThemeName): Promise { + debug.log( + 'ThemeEngine', + `applyTheme called with:`, + typeof theme === 'string' ? theme : theme.name + ); + + // Load theme if name provided + const themeObj: Theme = typeof theme === 'string' ? await this.loadTheme(theme) : theme; + debug.log('ThemeEngine', `Theme loaded/resolved:`, themeObj.name); + + // Fetch overrides from storage via the core adapter path + const overrides: ThemeOverrides = await this.core.getStorageOverrides(); + + // Compile to CSS variables with overrides + const cssVars = this.compileToCSSVariables(themeObj, overrides); + + // Apply transition class + const root = document.documentElement; + root.classList.add('theme-transitioning'); + + // Update CSS variables on both :root and documentElement for immediate effect + debug.log( + 'ThemeEngine', + `Applying ${Object.keys(cssVars).length} CSS variables to :root and html` + ); + Object.entries(cssVars).forEach(([key, value]) => { + root.style.setProperty(key, value); + // Force immediate style recalculation for critical color variables + if (key === '--md-bg' || key === '--md-fg') { + document.documentElement.style.setProperty(key, value); + document.body.style.setProperty(key, value); + } + }); + + // Apply max-width logic + if (overrides.useMaxWidth) { + // If "Use Full Width" is checked, set max-width to none or 100% + root.style.setProperty('--md-max-width', '100%'); + } else if (overrides.maxWidth) { + // Apply custom max-width if set + root.style.setProperty('--md-max-width', `${overrides.maxWidth}px`); + } else { + // Default + root.style.setProperty('--md-max-width', '980px'); + } + + // Set data attributes + root.setAttribute('data-theme', themeObj.name); + root.setAttribute('data-theme-variant', themeObj.variant); + + // Apply background color directly to body and html to prevent white flash + document.documentElement.style.backgroundColor = themeObj.colors.background; + document.body.style.backgroundColor = themeObj.colors.background; + document.documentElement.style.color = themeObj.colors.foreground; + document.body.style.color = themeObj.colors.foreground; + + // Update syntax theme + debug.log('ThemeEngine', `Updating syntax theme to: ${themeObj.syntaxTheme}`); + await this.updateSyntaxTheme(themeObj.syntaxTheme); + + // Update mermaid theme + try { + const { mermaidRenderer } = await import('@mdview/core'); + mermaidRenderer.updateTheme(themeObj.mermaidTheme); + } catch (error) { + debug.error('ThemeEngine', 'Failed to update mermaid theme:', error); + } + + // Remove transition class after a brief moment (non-blocking) + requestAnimationFrame(() => { + requestAnimationFrame(() => { + root.classList.remove('theme-transitioning'); + }); + }); + + // Save current theme + this.core.setCurrentTheme(themeObj); + + debug.log('ThemeEngine', `Successfully applied theme: ${themeObj.name}`); + } + + /** + * Watch system theme preference + */ + watchSystemTheme(callback: (isDark: boolean) => void): () => void { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handler = (e: MediaQueryListEvent | MediaQueryList) => { + callback(e.matches); + }; + + // Initial call + handler(mediaQuery); + + // Listen for changes + mediaQuery.addEventListener('change', handler); + + // Return cleanup function + return () => { + mediaQuery.removeEventListener('change', handler); + }; + } + + /** + * Update syntax highlighting theme + */ + private async updateSyntaxTheme(syntaxTheme: string): Promise { + try { + const { syntaxHighlighter } = await import('@mdview/core'); + syntaxHighlighter.setTheme(syntaxTheme); + } catch (error) { + debug.error('ThemeEngine', 'Failed to update syntax theme:', error); + } + } +} + +// Export singleton +export const themeEngine = new ThemeEngine(); diff --git a/src/native-host/host-logic.ts b/packages/chrome-ext/src/native-host/host-logic.ts similarity index 90% rename from src/native-host/host-logic.ts rename to packages/chrome-ext/src/native-host/host-logic.ts index fc6b91d..a8e158f 100644 --- a/src/native-host/host-logic.ts +++ b/packages/chrome-ext/src/native-host/host-logic.ts @@ -98,12 +98,10 @@ export function handleMessage(msg: HostMessage | null | string): HostResponse { const existingBound = findContentBoundary(existing, V1_SEP, V2_PREFIX); const newBound = findContentBoundary(newContent, V1_SEP, V2_PREFIX); - const existingBody = existingBound !== -1 - ? existing.substring(0, existingBound).trimEnd() - : existing.trimEnd(); - const newBody = newBound !== -1 - ? newContent.substring(0, newBound).trimEnd() - : newContent.trimEnd(); + const existingBody = + existingBound !== -1 ? existing.substring(0, existingBound).trimEnd() : existing.trimEnd(); + const newBody = + newBound !== -1 ? newContent.substring(0, newBound).trimEnd() : newContent.trimEnd(); // Strip both v1 and v2 marker formats for comparison const V1_REF = /\[\^comment-\d+\]/g; @@ -113,7 +111,8 @@ export function handleMessage(msg: HostMessage | null | string): HostResponse { if (existingClean !== newClean) { return { - error: 'Refused: write would modify document content beyond comment markers. Only comment changes are allowed.', + error: + 'Refused: write would modify document content beyond comment markers. Only comment changes are allowed.', }; } diff --git a/src/native-host/manifest.json b/packages/chrome-ext/src/native-host/manifest.json similarity index 100% rename from src/native-host/manifest.json rename to packages/chrome-ext/src/native-host/manifest.json diff --git a/src/options/options.css b/packages/chrome-ext/src/options/options.css similarity index 100% rename from src/options/options.css rename to packages/chrome-ext/src/options/options.css diff --git a/src/options/options.html b/packages/chrome-ext/src/options/options.html similarity index 100% rename from src/options/options.html rename to packages/chrome-ext/src/options/options.html diff --git a/src/options/options.ts b/packages/chrome-ext/src/options/options.ts similarity index 99% rename from src/options/options.ts rename to packages/chrome-ext/src/options/options.ts index 5f97676..92e5388 100644 --- a/src/options/options.ts +++ b/packages/chrome-ext/src/options/options.ts @@ -3,7 +3,7 @@ * Manages settings page UI and interactions */ -import type { AppState, ThemeName, LogLevel, PaperSize } from '../types'; +import type { AppState, ThemeName, LogLevel, PaperSize } from '@mdview/core'; import { debug } from '../utils/debug-logger'; class OptionsManager { @@ -363,7 +363,7 @@ class OptionsManager { const previewEl = document.getElementById('filename-preview-text'); if (previewEl) { // Dynamic import to avoid circular dependency - import('../utils/filename-generator') + import('@mdview/core') .then(({ FilenameGenerator }) => { const preview = FilenameGenerator.generate({ title: 'My Document', diff --git a/src/popup/popup.css b/packages/chrome-ext/src/popup/popup.css similarity index 100% rename from src/popup/popup.css rename to packages/chrome-ext/src/popup/popup.css diff --git a/src/popup/popup.html b/packages/chrome-ext/src/popup/popup.html similarity index 100% rename from src/popup/popup.html rename to packages/chrome-ext/src/popup/popup.html diff --git a/src/popup/popup.ts b/packages/chrome-ext/src/popup/popup.ts similarity index 99% rename from src/popup/popup.ts rename to packages/chrome-ext/src/popup/popup.ts index 8871ceb..15b1b40 100644 --- a/src/popup/popup.ts +++ b/packages/chrome-ext/src/popup/popup.ts @@ -3,7 +3,7 @@ * Manages popup UI and interactions */ -import type { AppState, ThemeName, LogLevel } from '../types'; +import type { AppState, ThemeName, LogLevel } from '@mdview/core'; import { debug } from '../utils/debug-logger'; class PopupManager { diff --git a/packages/chrome-ext/src/ui/export-ui.ts b/packages/chrome-ext/src/ui/export-ui.ts new file mode 100644 index 0000000..426cf17 --- /dev/null +++ b/packages/chrome-ext/src/ui/export-ui.ts @@ -0,0 +1,40 @@ +// Re-export shim: export-ui has moved to @mdview/core +// This shim provides Chrome extension MessagingAdapter and platform-specific factories. +// It will be removed in Phase 2.9 + +import type { ExportUIOptions, MessagingAdapter, IPCMessage } from '@mdview/core'; +import { ExportUI as CoreExportUI, type CoreExportUIOptions } from '@mdview/core'; + +/** Chrome extension MessagingAdapter using chrome.runtime.sendMessage */ +class ChromeMessagingAdapter implements MessagingAdapter { + async send(message: IPCMessage): Promise { + return chrome.runtime.sendMessage(message); + } +} + +/** + * ExportUI wrapper that injects Chrome-specific dependencies. + * + * Maintains the same constructor signature as the original so that + * existing call-sites (e.g. content-script.ts) continue to work + * without modification. + */ +export class ExportUI extends CoreExportUI { + constructor(options?: ExportUIOptions) { + const coreOptions: CoreExportUIOptions = { + ...options, + messaging: new ChromeMessagingAdapter(), + factories: { + createExportController: async () => { + const mod = await import('@mdview/core'); + return new mod.ExportController(); + }, + createPDFGenerator: async () => { + const mod = await import('@mdview/core'); + return new mod.PDFGenerator(); + }, + }, + }; + super(coreOptions); + } +} diff --git a/packages/chrome-ext/src/utils/debug-logger.ts b/packages/chrome-ext/src/utils/debug-logger.ts new file mode 100644 index 0000000..eeb9192 --- /dev/null +++ b/packages/chrome-ext/src/utils/debug-logger.ts @@ -0,0 +1,59 @@ +/** + * Debug Logger — Chrome extension shim + * + * Re-exports the platform-agnostic debug-logger from @mdview/core, + * pre-configured with a Chrome StorageAdapter that reads from + * chrome.storage.sync. Existing callers continue to import + * `debug` and `debugLogger` without any changes. + */ + +import type { StorageAdapter, LogLevel } from '@mdview/core'; +import { DebugLogger as CoreDebugLogger } from '@mdview/core'; + +// --------------------------------------------------------------------------- +// Chrome StorageAdapter (sync-only for preferences) +// --------------------------------------------------------------------------- + +class ChromeStorageAdapter implements StorageAdapter { + async getSync(keys: string | string[]): Promise> { + return chrome.storage.sync.get(keys) as Promise>; + } + + async setSync(data: Record): Promise { + await chrome.storage.sync.set(data); + } + + async getLocal(keys: string | string[]): Promise> { + return chrome.storage.local.get(keys) as Promise>; + } + + async setLocal(data: Record): Promise { + await chrome.storage.local.set(data); + } +} + +// --------------------------------------------------------------------------- +// Singleton with Chrome adapter +// --------------------------------------------------------------------------- + +const chromeAdapter = new ChromeStorageAdapter(); +export const debugLogger = new CoreDebugLogger(chromeAdapter); + +// Export convenience functions — identical shape to the original +export const debug = { + log: (context: string, ...args: unknown[]) => debugLogger.log(context, ...args), + debug: (context: string, ...args: unknown[]) => debugLogger.debug(context, ...args), + info: (context: string, ...args: unknown[]) => debugLogger.info(context, ...args), + warn: (context: string, ...args: unknown[]) => debugLogger.warn(context, ...args), + error: (context: string, ...args: unknown[]) => debugLogger.error(context, ...args), + group: (context: string, label: string) => debugLogger.group(context, label), + groupEnd: () => debugLogger.groupEnd(), + table: (context: string, data: unknown) => debugLogger.table(context, data), + time: (context: string, label: string) => debugLogger.time(context, label), + timeEnd: (context: string, label: string) => debugLogger.timeEnd(context, label), + setLogLevel: (level: LogLevel) => debugLogger.setLogLevel(level), + getLogLevel: () => debugLogger.getLogLevel(), + // Compat + setDebugMode: (enabled: boolean) => debugLogger.setLogLevel(enabled ? 'debug' : 'error'), + isDebugEnabled: () => debugLogger.getLogLevel() === 'debug', +}; diff --git a/packages/chrome-ext/src/utils/file-scanner.ts b/packages/chrome-ext/src/utils/file-scanner.ts new file mode 100644 index 0000000..163d6d8 --- /dev/null +++ b/packages/chrome-ext/src/utils/file-scanner.ts @@ -0,0 +1,185 @@ +/** + * Re-export shim: delegates to @mdview/core FileScanner and adds + * Chrome extension-specific methods (window.location, chrome.runtime). + */ + +import { FileScanner as CoreFileScanner } from '@mdview/core'; +import type { FileAdapter, FileChangeInfo } from '@mdview/core'; +import { debug } from './debug-logger'; + +/** + * Browser-aware FileScanner that extends core with Chrome-specific functionality. + * + * Pure utility methods (hasMarkdownExtension, generateHash, validateFileSize, + * formatFileSize, getFileSize) are inherited from the core class. + * + * Browser-specific methods (isSiteBlocked, isMarkdownFile, getCurrentSiteIdentifier, + * getFilePath, readFileContent, watchFile) are defined here. + */ +export class FileScanner extends CoreFileScanner { + private static readonly MARKDOWN_MIME_TYPES_BROWSER = ['text/markdown', 'text/x-markdown']; + + /** + * Check if the current site is in the blocklist. + * Uses window.location for URL detection (Chrome extension only). + */ + static isSiteBlocked(blocklist: string[]): boolean { + if (!blocklist || blocklist.length === 0) { + return false; + } + + const url = window.location.href; + const hostname = window.location.hostname; + const pathname = window.location.pathname; + + return this.isSiteBlockedFromList(blocklist, url, hostname, pathname); + } + + /** + * Get the current site identifier for display (hostname or file path). + */ + static getCurrentSiteIdentifier(): string { + const url = window.location.href; + + if (url.startsWith('file://')) { + // For local files, show a truncated path + const path = window.location.pathname; + const parts = path.split('/'); + if (parts.length > 3) { + return '.../' + parts.slice(-2).join('/'); + } + return path; + } + + // For web, show hostname + return window.location.hostname; + } + + /** + * Check if the current page is a markdown file + */ + static isMarkdownFile(): boolean { + const url = window.location.href; + const pathname = window.location.pathname; + + // Check protocol + const isLocal = url.startsWith('file://'); + const isWeb = url.startsWith('http://') || url.startsWith('https://'); + + if (!isLocal && !isWeb) { + return false; + } + + // Check by MIME type first if available (authoritative for web) + const contentType = document.contentType; + if (contentType && this.MARKDOWN_MIME_TYPES_BROWSER.includes(contentType)) { + return true; + } + + // For web, if content type is explicitly HTML, do not activate + // (prevents rendering on GitHub UI pages which end in .md) + if (isWeb && (contentType === 'text/html' || contentType === 'application/xhtml+xml')) { + return false; + } + + // Check by extension + if (this.hasMarkdownExtension(pathname)) { + return true; + } + + return false; + } + + /** + * Get the current file path + */ + static getFilePath(): string { + return window.location.pathname; + } + + /** + * Read file content from the page or source. + * For file:// URLs, Chrome displays the content in a
 tag initially.
+   */
+  static readFileContent(forceFetch = false): string {
+    // For file:// URLs, Chrome displays the content in a 
 tag
+    // Initial load check
+    if (!forceFetch) {
+      const pre = document.querySelector('pre');
+      if (pre) {
+        return pre.textContent || '';
+      }
+      // If body is not cleared yet
+      if (!document.getElementById('mdview-container')) {
+        return document.body.textContent || '';
+      }
+    }
+
+    return document.body.textContent || '';
+  }
+
+  /**
+   * Monitor file changes using polling via background script delegation.
+   * This preserves the original Chrome extension signature while delegating
+   * to the core FileScanner with a Chrome FileAdapter.
+   */
+  static watchFile(initialHash: string, callback: () => void, interval = 1000): () => void {
+    const fileUrl = window.location.href;
+
+    debug.info('FileScanner', `Starting background-delegated file watcher for ${fileUrl}`);
+
+    // Create a Chrome-specific file adapter that wraps chrome.runtime.sendMessage
+    // and also handles extension context invalidation errors
+    const chromeAdapter: FileAdapter = {
+      writeFile() {
+        return Promise.resolve({ success: false, error: 'Not supported via Chrome messaging' });
+      },
+      readFile() {
+        return Promise.reject(new Error('Not supported via Chrome messaging'));
+      },
+      async checkChanged(url: string, lastHash: string): Promise {
+        try {
+          const response: unknown = await chrome.runtime.sendMessage({
+            type: 'CHECK_FILE_CHANGED',
+            payload: { url, lastHash },
+          });
+          const typed = response as {
+            error?: string;
+            changed?: boolean;
+            newHash?: string;
+          };
+          return {
+            changed: typed.changed ?? false,
+            newHash: typed.newHash,
+            error: typed.error,
+          };
+        } catch (error) {
+          // Handle extension context invalidation
+          const errorStr = String(error).toLowerCase();
+          const errorMessage = error instanceof Error ? error.message.toLowerCase() : '';
+
+          const isContextInvalid =
+            errorStr.includes('context invalidated') ||
+            errorStr.includes('extension context') ||
+            errorMessage.includes('context invalidated');
+
+          if (isContextInvalid) {
+            debug.warn('FileScanner', 'Extension context invalidated.');
+          } else {
+            debug.error('FileScanner', 'Error communicating with background:', error);
+          }
+
+          throw error;
+        }
+      },
+      watch() {
+        return () => {};
+      },
+    };
+
+    return CoreFileScanner.watchFile(fileUrl, initialHash, callback, {
+      fileAdapter: chromeAdapter,
+      interval,
+    });
+  }
+}
diff --git a/packages/chrome-ext/tsconfig.json b/packages/chrome-ext/tsconfig.json
new file mode 100644
index 0000000..3fda8ee
--- /dev/null
+++ b/packages/chrome-ext/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "composite": true,
+    "rootDir": "src",
+    "baseUrl": ".",
+    "paths": {},
+    "types": ["chrome", "vite/client", "vitest/globals"]
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "dist"]
+}
diff --git a/vite.config.ts b/packages/chrome-ext/vite.config.ts
similarity index 100%
rename from vite.config.ts
rename to packages/chrome-ext/vite.config.ts
diff --git a/packages/core/package.json b/packages/core/package.json
new file mode 100644
index 0000000..f10326c
--- /dev/null
+++ b/packages/core/package.json
@@ -0,0 +1,34 @@
+{
+  "name": "@mdview/core",
+  "version": "0.0.1",
+  "description": "Core rendering engine for mdview",
+  "type": "module",
+  "exports": {
+    ".": "./src/index.ts",
+    "./utils/debug-logger": "./src/utils/debug-logger.ts",
+    "./adapters": "./src/adapters.ts"
+  },
+  "scripts": {
+    "test": "vitest",
+    "test:ci": "vitest --run"
+  },
+  "dependencies": {
+    "@jamesainslie/docx": "^9.5.1-svg.1",
+    "dompurify": "^3.0.6",
+    "highlight.js": "^11.9.0",
+    "markdown-it": "^14.1.0",
+    "markdown-it-anchor": "^8.6.7",
+    "markdown-it-attrs": "^4.1.6",
+    "markdown-it-emoji": "^3.0.0",
+    "markdown-it-footnote": "^4.0.0",
+    "markdown-it-task-lists": "^2.1.1",
+    "mermaid": "^11.12.1",
+    "panzoom": "^9.4.3"
+  },
+  "devDependencies": {
+    "@types/dompurify": "^3.0.5",
+    "@types/markdown-it": "^13.0.9"
+  },
+  "author": "James Ainslie",
+  "license": "MIT"
+}
diff --git a/packages/core/src/__tests__/adapters.test.ts b/packages/core/src/__tests__/adapters.test.ts
new file mode 100644
index 0000000..6798c74
--- /dev/null
+++ b/packages/core/src/__tests__/adapters.test.ts
@@ -0,0 +1,205 @@
+import {
+  NoopStorageAdapter,
+  NoopMessagingAdapter,
+  NoopFileAdapter,
+  NoopIdentityAdapter,
+  NoopExportAdapter,
+  createNoopAdapters,
+} from '../adapters';
+import type {
+  StorageAdapter,
+  MessagingAdapter,
+  FileAdapter,
+  IdentityAdapter,
+  ExportAdapter,
+  PlatformAdapters,
+} from '../adapters';
+
+describe('Platform Adapters', () => {
+  describe('StorageAdapter (NoopStorageAdapter)', () => {
+    let storage: StorageAdapter;
+
+    beforeEach(() => {
+      storage = new NoopStorageAdapter();
+    });
+
+    it('returns empty object for unknown sync keys', async () => {
+      const result = await storage.getSync('nonexistent');
+      expect(result).toEqual({});
+    });
+
+    it('stores and retrieves sync values', async () => {
+      await storage.setSync({ theme: 'github-dark', fontSize: 14 });
+      const result = await storage.getSync(['theme', 'fontSize']);
+      expect(result).toEqual({ theme: 'github-dark', fontSize: 14 });
+    });
+
+    it('returns empty object for unknown local keys', async () => {
+      const result = await storage.getLocal(['missing']);
+      expect(result).toEqual({});
+    });
+
+    it('stores and retrieves local values', async () => {
+      await storage.setLocal({ scrollPos: 500 });
+      const result = await storage.getLocal('scrollPos');
+      expect(result).toEqual({ scrollPos: 500 });
+    });
+
+    it('handles string key argument for getSync', async () => {
+      await storage.setSync({ preferences: { logLevel: 'debug' } });
+      const result = await storage.getSync('preferences');
+      expect(result).toEqual({ preferences: { logLevel: 'debug' } });
+    });
+  });
+
+  describe('MessagingAdapter (NoopMessagingAdapter)', () => {
+    let messaging: MessagingAdapter;
+
+    beforeEach(() => {
+      messaging = new NoopMessagingAdapter();
+    });
+
+    it('returns empty object for any message', async () => {
+      const result = await messaging.send({ type: 'GET_STATE' });
+      expect(result).toEqual({});
+    });
+
+    it('handles messages with payloads', async () => {
+      const result = await messaging.send({
+        type: 'CACHE_GET',
+        payload: { key: 'test-key' },
+      });
+      expect(result).toEqual({});
+    });
+  });
+
+  describe('FileAdapter (NoopFileAdapter)', () => {
+    let file: FileAdapter;
+
+    beforeEach(() => {
+      file = new NoopFileAdapter();
+    });
+
+    it('writeFile returns failure', async () => {
+      const result = await file.writeFile('/tmp/test.md', '# Hello');
+      expect(result.success).toBe(false);
+      expect(result.error).toBeDefined();
+    });
+
+    it('readFile throws', async () => {
+      await expect(file.readFile('/tmp/test.md')).rejects.toThrow(
+        'No file adapter configured'
+      );
+    });
+
+    it('checkChanged returns unchanged', async () => {
+      const result = await file.checkChanged('file:///test.md', 'abc123');
+      expect(result.changed).toBe(false);
+    });
+
+    it('watch returns a no-op unsubscribe function', () => {
+      const unsubscribe = file.watch('/tmp', () => {});
+      expect(typeof unsubscribe).toBe('function');
+      unsubscribe(); // should not throw
+    });
+  });
+
+  describe('IdentityAdapter (NoopIdentityAdapter)', () => {
+    let identity: IdentityAdapter;
+
+    beforeEach(() => {
+      identity = new NoopIdentityAdapter();
+    });
+
+    it('returns empty string username', async () => {
+      const username = await identity.getUsername();
+      expect(username).toBe('');
+    });
+  });
+
+  describe('ExportAdapter (NoopExportAdapter)', () => {
+    let exporter: ExportAdapter;
+
+    beforeEach(() => {
+      exporter = new NoopExportAdapter();
+    });
+
+    it('saveFile completes without error', async () => {
+      await expect(
+        exporter.saveFile({
+          filename: 'test.docx',
+          mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+          data: new Blob(['test']),
+        })
+      ).resolves.toBeUndefined();
+    });
+
+    it('printToPDF is not defined on noop adapter', () => {
+      expect(exporter.printToPDF).toBeUndefined();
+    });
+  });
+
+  describe('createNoopAdapters()', () => {
+    it('returns a complete PlatformAdapters bundle', () => {
+      const adapters: PlatformAdapters = createNoopAdapters();
+      expect(adapters.storage).toBeInstanceOf(NoopStorageAdapter);
+      expect(adapters.messaging).toBeInstanceOf(NoopMessagingAdapter);
+      expect(adapters.file).toBeInstanceOf(NoopFileAdapter);
+      expect(adapters.identity).toBeInstanceOf(NoopIdentityAdapter);
+      expect(adapters.export).toBeInstanceOf(NoopExportAdapter);
+    });
+  });
+
+  describe('Custom mock adapters satisfy interfaces', () => {
+    it('custom StorageAdapter implementation works', async () => {
+      const store = new Map();
+      const custom: StorageAdapter = {
+        async getSync(keys) {
+          const keyList = Array.isArray(keys) ? keys : [keys];
+          const result: Record = {};
+          for (const k of keyList) {
+            if (store.has(k)) result[k] = store.get(k);
+          }
+          return result;
+        },
+        async setSync(data) {
+          for (const [k, v] of Object.entries(data)) store.set(k, v);
+        },
+        async getLocal(keys) {
+          return this.getSync(keys);
+        },
+        async setLocal(data) {
+          return this.setSync(data);
+        },
+      };
+
+      await custom.setSync({ key1: 'value1' });
+      expect(await custom.getSync('key1')).toEqual({ key1: 'value1' });
+    });
+
+    it('custom FileAdapter with in-memory fs works', async () => {
+      const files = new Map();
+      const custom: FileAdapter = {
+        async writeFile(path, content) {
+          files.set(path, content);
+          return { success: true };
+        },
+        async readFile(path) {
+          const content = files.get(path);
+          if (!content) throw new Error(`File not found: ${path}`);
+          return content;
+        },
+        async checkChanged(_url, _lastHash) {
+          return { changed: true, newHash: 'new-hash' };
+        },
+        watch(_path, _callback) {
+          return () => {};
+        },
+      };
+
+      await custom.writeFile('/test.md', '# Hello');
+      const content = await custom.readFile('/test.md');
+      expect(content).toBe('# Hello');
+    });
+  });
+});
diff --git a/packages/core/src/__tests__/barrel-export.test.ts b/packages/core/src/__tests__/barrel-export.test.ts
new file mode 100644
index 0000000..3881805
--- /dev/null
+++ b/packages/core/src/__tests__/barrel-export.test.ts
@@ -0,0 +1,138 @@
+/**
+ * Test that all public APIs are accessible via the @mdview/core barrel export.
+ */
+import {
+  // Version
+  VERSION,
+  // Themes
+  githubLight,
+  githubDark,
+  catppuccinLatte,
+  catppuccinFrappe,
+  catppuccinMacchiato,
+  catppuccinMocha,
+  monokai,
+  monokaiPro,
+  // Comment parsers (v2 annotation format)
+  detectFormat,
+  parseAnnotations,
+  // Comment serializers (v2 annotation format)
+  generateNextCommentId,
+  addComment,
+  addCommentAtOffset,
+  removeComment,
+  updateComment,
+  resolveComment,
+  updateCommentMetadata,
+  addReply,
+  toggleReaction,
+  // Comment parsers (v1 legacy)
+  parseComments,
+  // Comment context & utilities
+  computeCommentContext,
+  buildSourceMap,
+  findInsertionPoint,
+  QUICK_EMOJIS,
+  EMOJI_CATEGORIES,
+  searchEmojis,
+  // Utilities
+  splitIntoSections,
+  splitIntoChunks,
+  FilenameGenerator,
+  stripTableOfContents,
+  // Markdown converter
+  MarkdownConverter,
+  markdownConverter,
+  // Cache manager
+  CacheManager,
+  cacheManager,
+  // Frontmatter
+  extractFrontmatter,
+  renderFrontmatterHtml,
+  // Debug logger
+  DebugLogger,
+  createDebugLogger,
+  createDebug,
+} from '../index';
+
+describe('@mdview/core barrel export', () => {
+  it('exports VERSION', () => {
+    expect(VERSION).toBe('0.0.1');
+  });
+
+  it('exports all 8 themes', () => {
+    const themes = [
+      githubLight,
+      githubDark,
+      catppuccinLatte,
+      catppuccinFrappe,
+      catppuccinMacchiato,
+      catppuccinMocha,
+      monokai,
+      monokaiPro,
+    ];
+    for (const theme of themes) {
+      expect(theme).toHaveProperty('name');
+      expect(theme).toHaveProperty('colors');
+      expect(theme).toHaveProperty('variant');
+    }
+  });
+
+  it('exports v2 annotation parsers', () => {
+    expect(typeof detectFormat).toBe('function');
+    expect(typeof parseAnnotations).toBe('function');
+  });
+
+  it('exports v2 annotation serializer functions', () => {
+    expect(typeof generateNextCommentId).toBe('function');
+    expect(typeof addComment).toBe('function');
+    expect(typeof addCommentAtOffset).toBe('function');
+    expect(typeof removeComment).toBe('function');
+    expect(typeof updateComment).toBe('function');
+    expect(typeof resolveComment).toBe('function');
+    expect(typeof updateCommentMetadata).toBe('function');
+    expect(typeof addReply).toBe('function');
+    expect(typeof toggleReaction).toBe('function');
+  });
+
+  it('exports v1 legacy parser', () => {
+    expect(typeof parseComments).toBe('function');
+  });
+
+  it('exports comment context and utilities', () => {
+    expect(typeof computeCommentContext).toBe('function');
+    expect(typeof buildSourceMap).toBe('function');
+    expect(typeof findInsertionPoint).toBe('function');
+    expect(QUICK_EMOJIS).toBeDefined();
+    expect(EMOJI_CATEGORIES).toBeDefined();
+    expect(typeof searchEmojis).toBe('function');
+  });
+
+  it('exports utility functions', () => {
+    expect(typeof splitIntoSections).toBe('function');
+    expect(typeof splitIntoChunks).toBe('function');
+    expect(FilenameGenerator).toBeDefined();
+    expect(typeof stripTableOfContents).toBe('function');
+  });
+
+  it('exports markdown converter', () => {
+    expect(MarkdownConverter).toBeDefined();
+    expect(markdownConverter).toBeInstanceOf(MarkdownConverter);
+  });
+
+  it('exports cache manager', () => {
+    expect(CacheManager).toBeDefined();
+    expect(cacheManager).toBeInstanceOf(CacheManager);
+  });
+
+  it('exports frontmatter functions', () => {
+    expect(typeof extractFrontmatter).toBe('function');
+    expect(typeof renderFrontmatterHtml).toBe('function');
+  });
+
+  it('exports debug logger', () => {
+    expect(DebugLogger).toBeDefined();
+    expect(typeof createDebugLogger).toBe('function');
+    expect(typeof createDebug).toBe('function');
+  });
+});
diff --git a/packages/core/src/__tests__/comment-manager.test.ts b/packages/core/src/__tests__/comment-manager.test.ts
new file mode 100644
index 0000000..7aa0243
--- /dev/null
+++ b/packages/core/src/__tests__/comment-manager.test.ts
@@ -0,0 +1,393 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { CommentManager } from '../comments/comment-manager';
+import type { FileAdapter, IdentityAdapter, FileWriteResult } from '../adapters';
+import type { AppState } from '../types/index';
+
+/**
+ * Minimal mock DOM environment for CommentManager tests.
+ * The manager requires DOM elements (window, document, etc.) which
+ * jsdom provides. We mock the UI and highlighter dependencies since
+ * they are browser-specific and not extracted to core yet.
+ */
+
+// Mock the DOM-dependent comment UI modules (now in core/src/comments/)
+vi.mock('../comments/comment-ui', () => {
+  class MockCommentUI {
+    setCurrentAuthor = vi.fn();
+    renderCard = vi.fn().mockReturnValue(document.createElement('div'));
+    renderInputForm = vi.fn().mockReturnValue(document.createElement('div'));
+    renderReplyForm = vi.fn().mockReturnValue(document.createElement('div'));
+    renderEmojiPicker = vi.fn().mockReturnValue(document.createElement('div'));
+    showToast = vi.fn();
+    destroy = vi.fn();
+  }
+  return { CommentUI: MockCommentUI };
+});
+
+vi.mock('../comments/comment-highlight', () => {
+  class MockCommentHighlighter {
+    highlightComment = vi.fn();
+    clearActive = vi.fn();
+    setActive = vi.fn();
+    setResolved = vi.fn();
+    removeHighlight = vi.fn();
+    getHighlightElement = vi.fn().mockReturnValue(null);
+  }
+  return { CommentHighlighter: MockCommentHighlighter };
+});
+
+function createMockFileAdapter(
+  overrides: Partial = {}
+): FileAdapter {
+  return {
+    writeFile: vi.fn().mockResolvedValue({ success: true } as FileWriteResult),
+    readFile: vi.fn().mockResolvedValue(''),
+    checkChanged: vi.fn().mockResolvedValue({ changed: false }),
+    watch: vi.fn().mockReturnValue(() => {}),
+    ...overrides,
+  };
+}
+
+function createMockIdentityAdapter(
+  overrides: Partial = {}
+): IdentityAdapter {
+  return {
+    getUsername: vi.fn().mockResolvedValue('test-user'),
+    ...overrides,
+  };
+}
+
+function createMinimalPreferences(): AppState['preferences'] {
+  return {
+    theme: 'github-light',
+    autoTheme: false,
+    lightTheme: 'github-light',
+    darkTheme: 'github-dark',
+    syntaxTheme: 'default',
+    autoReload: false,
+    lineNumbers: false,
+    enableHtml: false,
+    syncTabs: false,
+    logLevel: 'none',
+    commentsEnabled: true,
+  };
+}
+
+const SAMPLE_MARKDOWN = `# Hello World
+
+This is a test document.
+
+Some content here.
+`;
+
+describe('CommentManager (core)', () => {
+  let manager: CommentManager;
+
+  beforeEach(() => {
+    // Set up a basic DOM container
+    const container = document.createElement('div');
+    container.id = 'mdview-container';
+    container.textContent = 'This is a test document. Some content here.';
+    document.body.appendChild(container);
+  });
+
+  afterEach(() => {
+    if (manager) {
+      manager.destroy();
+    }
+    document.body.innerHTML = '';
+  });
+
+  describe('with FileAdapter and IdentityAdapter', () => {
+    it('uses IdentityAdapter to get username when no author is configured', async () => {
+      const identity = createMockIdentityAdapter({
+        getUsername: vi.fn().mockResolvedValue('adapter-user'),
+      });
+      const file = createMockFileAdapter();
+
+      manager = new CommentManager({ file, identity });
+      const result = await manager.initialize(
+        SAMPLE_MARKDOWN,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      expect(identity.getUsername).toHaveBeenCalled();
+      expect(result).toBeDefined();
+      expect(result.comments).toEqual([]);
+    });
+
+    it('prefers preferences.commentAuthor over IdentityAdapter', async () => {
+      const identity = createMockIdentityAdapter({
+        getUsername: vi.fn().mockResolvedValue('adapter-user'),
+      });
+      const file = createMockFileAdapter();
+
+      manager = new CommentManager({ file, identity });
+      const prefs = createMinimalPreferences();
+      prefs.commentAuthor = 'pref-author';
+
+      await manager.initialize(SAMPLE_MARKDOWN, '/test/file.md', prefs);
+
+      // Should NOT call getUsername since author is already set
+      expect(identity.getUsername).not.toHaveBeenCalled();
+    });
+
+    it('uses FileAdapter.writeFile when adding a comment', async () => {
+      const file = createMockFileAdapter();
+      const identity = createMockIdentityAdapter();
+
+      manager = new CommentManager({ file, identity });
+      await manager.initialize(
+        SAMPLE_MARKDOWN,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      await manager.addComment('test document', 'My note');
+
+      expect(file.writeFile).toHaveBeenCalledWith(
+        '/test/file.md',
+        expect.stringContaining('My note')
+      );
+    });
+
+    it('uses FileAdapter.writeFile when editing a comment', async () => {
+      const mdWithComment = `# Hello World
+
+This is a test[@1] document.
+
+
+`;
+      const file = createMockFileAdapter();
+      const identity = createMockIdentityAdapter();
+
+      manager = new CommentManager({ file, identity });
+      await manager.initialize(
+        mdWithComment,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      await manager.editComment('comment-1', 'Updated note');
+
+      expect(file.writeFile).toHaveBeenCalledWith(
+        '/test/file.md',
+        expect.stringContaining('Updated note')
+      );
+    });
+
+    it('uses FileAdapter.writeFile when resolving a comment', async () => {
+      const mdWithComment = `# Hello
+
+Test[@1] content.
+
+
+`;
+      const file = createMockFileAdapter();
+      const identity = createMockIdentityAdapter();
+
+      manager = new CommentManager({ file, identity });
+      await manager.initialize(
+        mdWithComment,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      await manager.resolveComment('comment-1');
+
+      expect(file.writeFile).toHaveBeenCalled();
+    });
+
+    it('uses FileAdapter.writeFile when deleting a comment', async () => {
+      const mdWithComment = `# Hello
+
+Test[@1] content.
+
+
+`;
+      const file = createMockFileAdapter();
+      const identity = createMockIdentityAdapter();
+
+      manager = new CommentManager({ file, identity });
+      await manager.initialize(
+        mdWithComment,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      await manager.deleteComment('comment-1');
+
+      expect(file.writeFile).toHaveBeenCalled();
+    });
+
+    it('reports write errors from FileAdapter', async () => {
+      const file = createMockFileAdapter({
+        writeFile: vi.fn().mockResolvedValue({
+          success: false,
+          error: 'Permission denied',
+        }),
+      });
+      const identity = createMockIdentityAdapter();
+
+      manager = new CommentManager({ file, identity });
+      await manager.initialize(
+        SAMPLE_MARKDOWN,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      // Should not throw even if write fails
+      await expect(
+        manager.addComment('test', 'note')
+      ).resolves.not.toThrow();
+    });
+  });
+
+  describe('graceful degradation without adapters', () => {
+    it('initializes without any adapters', async () => {
+      manager = new CommentManager();
+      const result = await manager.initialize(
+        SAMPLE_MARKDOWN,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      expect(result).toBeDefined();
+      expect(result.comments).toEqual([]);
+    });
+
+    it('uses empty username when no adapter and no preference set', async () => {
+      manager = new CommentManager();
+      await manager.initialize(
+        SAMPLE_MARKDOWN,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      // Comments created should have empty author
+      const comments = manager.getComments();
+      expect(comments).toEqual([]);
+    });
+
+    it('addComment works without FileAdapter (skips write)', async () => {
+      manager = new CommentManager();
+      await manager.initialize(
+        SAMPLE_MARKDOWN,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      // Should not throw even without file adapter
+      await expect(
+        manager.addComment('test document', 'A note')
+      ).resolves.not.toThrow();
+
+      const comments = manager.getComments();
+      expect(comments).toHaveLength(1);
+      expect(comments[0].body).toBe('A note');
+    });
+
+    it('editComment works without FileAdapter', async () => {
+      const mdWithComment = `# Hello
+
+Test[@1] content.
+
+
+`;
+      manager = new CommentManager();
+      await manager.initialize(
+        mdWithComment,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      await expect(
+        manager.editComment('comment-1', 'Updated')
+      ).resolves.not.toThrow();
+    });
+
+    it('deleteComment works without FileAdapter', async () => {
+      const mdWithComment = `# Hello
+
+Test[@1] content.
+
+
+`;
+      manager = new CommentManager();
+      await manager.initialize(
+        mdWithComment,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      await expect(
+        manager.deleteComment('comment-1')
+      ).resolves.not.toThrow();
+
+      expect(manager.getComments()).toHaveLength(0);
+    });
+
+    it('resolveComment works without FileAdapter', async () => {
+      const mdWithComment = `# Hello
+
+Test[@1] content.
+
+
+`;
+      manager = new CommentManager();
+      await manager.initialize(
+        mdWithComment,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      await expect(
+        manager.resolveComment('comment-1')
+      ).resolves.not.toThrow();
+    });
+
+    it('provides partial adapters — only FileAdapter', async () => {
+      const file = createMockFileAdapter();
+
+      manager = new CommentManager({ file });
+      await manager.initialize(
+        SAMPLE_MARKDOWN,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      await manager.addComment('test document', 'note');
+
+      // File adapter should be used for write
+      expect(file.writeFile).toHaveBeenCalled();
+    });
+
+    it('provides partial adapters — only IdentityAdapter', async () => {
+      const identity = createMockIdentityAdapter({
+        getUsername: vi.fn().mockResolvedValue('identity-only-user'),
+      });
+
+      manager = new CommentManager({ identity });
+      await manager.initialize(
+        SAMPLE_MARKDOWN,
+        '/test/file.md',
+        createMinimalPreferences()
+      );
+
+      expect(identity.getUsername).toHaveBeenCalled();
+
+      // addComment should still work (no file write, but no crash)
+      await expect(
+        manager.addComment('test', 'note')
+      ).resolves.not.toThrow();
+    });
+  });
+
+  describe('isWriteInProgress', () => {
+    it('returns false initially', () => {
+      manager = new CommentManager();
+      expect(manager.isWriteInProgress()).toBe(false);
+    });
+  });
+});
diff --git a/packages/core/src/__tests__/comments/annotation-parser.test.ts b/packages/core/src/__tests__/comments/annotation-parser.test.ts
new file mode 100644
index 0000000..38552d4
--- /dev/null
+++ b/packages/core/src/__tests__/comments/annotation-parser.test.ts
@@ -0,0 +1,245 @@
+/**
+ * Tests for annotation parser — v2 format with v1 backward compatibility
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { parseAnnotations, detectFormat } from '../../comments/annotation-parser';
+
+describe('detectFormat', () => {
+  it('should detect v1 format from sentinel', () => {
+    const md = `# Hello\n\n\n[^comment-1]: \n    body`;
+    expect(detectFormat(md)).toBe('v1');
+  });
+
+  it('should detect v2 format from sentinel', () => {
+    const md = `# Hello\n\n`;
+    expect(detectFormat(md)).toBe('v2');
+  });
+
+  it('should return none when neither sentinel is present', () => {
+    expect(detectFormat('# Just a document\n\nSome text.')).toBe('none');
+  });
+
+  it('should return v1 when both sentinels present (v1 takes priority)', () => {
+    const md = `# Hello\n\n\n`;
+    expect(detectFormat(md)).toBe('v1');
+  });
+
+  it('should handle empty string', () => {
+    expect(detectFormat('')).toBe('none');
+  });
+});
+
+describe('parseAnnotations', () => {
+  describe('No annotations', () => {
+    it('should return unchanged markdown and empty array when no annotations exist', () => {
+      const markdown = `# Hello World\n\nThis is a paragraph.\n\n## Section Two\n\nMore content here.`;
+      const result = parseAnnotations(markdown);
+      expect(result.cleanedMarkdown).toBe(markdown);
+      expect(result.comments).toEqual([]);
+    });
+
+    it('should handle empty markdown', () => {
+      const result = parseAnnotations('');
+      expect(result.cleanedMarkdown).toBe('');
+      expect(result.comments).toEqual([]);
+    });
+  });
+
+  describe('v2 single annotation', () => {
+    it('should parse a single annotation with all fields', () => {
+      const markdown = `Some highlighted[@1] text in context.
+
+`;
+
+      const result = parseAnnotations(markdown);
+
+      expect(result.comments).toHaveLength(1);
+      const comment = result.comments[0];
+      expect(comment.id).toBe('comment-1');
+      expect(comment.selectedText).toBe('highlighted');
+      expect(comment.anchorPrefix).toBe('Some ');
+      expect(comment.anchorSuffix).toBe(' text');
+      expect(comment.body).toBe('This needs attention');
+      expect(comment.author).toBe('reviewer');
+      expect(comment.date).toBe('2026-03-03T14:30:00Z');
+      expect(comment.resolved).toBe(false);
+    });
+
+    it('should remove [@N] markers from cleaned markdown', () => {
+      const markdown = `Some highlighted[@1] text in context.
+
+`;
+
+      const result = parseAnnotations(markdown);
+      expect(result.cleanedMarkdown).toBe('Some highlighted text in context.');
+      expect(result.cleanedMarkdown).not.toContain('[@1]');
+      expect(result.cleanedMarkdown).not.toContain('mdview:annotations');
+    });
+  });
+
+  describe('v2 multiple annotations', () => {
+    it('should parse multiple annotations', () => {
+      const markdown = `First[@1] and second[@2] words.
+
+`;
+
+      const result = parseAnnotations(markdown);
+      expect(result.comments).toHaveLength(2);
+      expect(result.comments[0].id).toBe('comment-1');
+      expect(result.comments[0].author).toBe('alice');
+      expect(result.comments[0].body).toBe('First comment');
+      expect(result.comments[1].id).toBe('comment-2');
+      expect(result.comments[1].author).toBe('bob');
+      expect(result.comments[1].body).toBe('Second comment');
+    });
+
+    it('should remove all [@N] markers from cleaned markdown', () => {
+      const markdown = `First[@1] and second[@2] words.
+
+`;
+
+      const result = parseAnnotations(markdown);
+      expect(result.cleanedMarkdown).toBe('First and second words.');
+    });
+  });
+
+  describe('v2 resolved annotations', () => {
+    it('should parse resolved flag', () => {
+      const markdown = `Some text[@1] here.
+
+`;
+
+      const result = parseAnnotations(markdown);
+      expect(result.comments[0].resolved).toBe(true);
+    });
+
+    it('should default resolved to false when not specified', () => {
+      const markdown = `Some text[@1] here.
+
+`;
+
+      const result = parseAnnotations(markdown);
+      expect(result.comments[0].resolved).toBe(false);
+    });
+  });
+
+  describe('v2 edge cases', () => {
+    it('should handle malformed JSON gracefully (warn, return empty)', () => {
+      const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+      const markdown = `Text[@1] here.
+
+`;
+
+      const result = parseAnnotations(markdown);
+      expect(result.comments).toEqual([]);
+      expect(result.cleanedMarkdown).toBe('Text here.');
+      consoleSpy.mockRestore();
+    });
+
+    it('should handle empty annotations array', () => {
+      const markdown = `Some text here.
+
+`;
+
+      const result = parseAnnotations(markdown);
+      expect(result.comments).toEqual([]);
+      expect(result.cleanedMarkdown).toBe('Some text here.');
+    });
+  });
+
+  describe('v1 backward compatibility', () => {
+    it('should parse v1 format comments through delegation', () => {
+      const markdown = `Some highlighted text[^comment-1] in context.
+
+
+[^comment-1]: 
+    This API endpoint needs error handling
+    for the 404 case.`;
+
+      const result = parseAnnotations(markdown);
+
+      expect(result.comments).toHaveLength(1);
+      expect(result.comments[0].id).toBe('comment-1');
+      expect(result.comments[0].selectedText).toBe('text');
+      expect(result.comments[0].body).toBe('This API endpoint needs error handling\nfor the 404 case.');
+      expect(result.comments[0].author).toBe('reviewer');
+      expect(result.comments[0].date).toBe('2026-03-03T14:30:00Z');
+      expect(result.comments[0].resolved).toBe(false);
+    });
+
+    it('should remove v1 comment references from cleaned markdown', () => {
+      const markdown = `Some highlighted text[^comment-1] in context.
+
+
+[^comment-1]: 
+    This is a comment.`;
+
+      const result = parseAnnotations(markdown);
+      expect(result.cleanedMarkdown).toBe('Some highlighted text in context.');
+    });
+  });
+});
diff --git a/packages/core/src/__tests__/comments/annotation-serializer.test.ts b/packages/core/src/__tests__/comments/annotation-serializer.test.ts
new file mode 100644
index 0000000..04701d0
--- /dev/null
+++ b/packages/core/src/__tests__/comments/annotation-serializer.test.ts
@@ -0,0 +1,197 @@
+/**
+ * Tests for Annotation Serializer (v2 format)
+ *
+ * Verifies v2 annotation operations: generating IDs, adding, removing,
+ * updating, resolving comments, replies, reactions, and v1 migration.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  generateNextCommentId,
+  addComment,
+  addCommentAtOffset,
+  removeComment,
+  updateComment,
+  resolveComment,
+  updateCommentMetadata,
+  addReply,
+  toggleReaction,
+} from '../../comments/annotation-serializer';
+import { parseAnnotations } from '../../comments/annotation-parser';
+import { buildSourceMap } from '../../comments/source-position-map';
+import type { Comment, CommentContext, CommentReply } from '../../types/index';
+
+function makeComment(overrides: Partial = {}): Comment {
+  return {
+    id: 'comment-1',
+    selectedText: 'highlighted text',
+    body: 'This is a comment',
+    author: 'reviewer',
+    date: '2026-03-03T14:30:00Z',
+    resolved: false,
+    ...overrides,
+  };
+}
+
+describe('generateNextCommentId', () => {
+  it('should return "comment-1" for empty markdown', () => {
+    expect(generateNextCommentId('')).toBe('comment-1');
+  });
+
+  it('should return "comment-2" when annotation 1 exists', () => {
+    const md = `Text[@1] here.
+
+`;
+    expect(generateNextCommentId(md)).toBe('comment-2');
+  });
+});
+
+describe('addComment', () => {
+  it('should add a [@N] marker and annotation block', () => {
+    const md = 'Some highlighted text in context.\n';
+    const comment = makeComment();
+    const result = addComment(md, comment);
+
+    expect(result).toContain('Some highlighted text[@1] in context.');
+    expect(result).toContain('`;
+    const result = removeComment(md, 'comment-1');
+
+    expect(result).not.toContain('[@1]');
+    expect(result).not.toContain('This is a comment');
+    expect(result).toContain('Some highlighted text in context.');
+  });
+});
+
+describe('updateComment', () => {
+  it('should replace the body while keeping other fields unchanged', () => {
+    const md = `Some highlighted text[@1] in context.
+
+`;
+    const result = updateComment(md, 'comment-1', 'Updated body text');
+
+    expect(result).toContain('"body": "Updated body text"');
+    expect(result).not.toContain('Original body text');
+  });
+});
+
+describe('resolveComment', () => {
+  it('should set resolved:true in annotation JSON', () => {
+    const md = `Some highlighted text[@1] in context.
+
+`;
+    const result = resolveComment(md, 'comment-1');
+
+    expect(result).toContain('"resolved": true');
+  });
+});
+
+describe('addCommentAtOffset', () => {
+  it('should insert marker after bold text using source map', () => {
+    const md = 'This is **important** for the review.\n';
+    const sourceMap = buildSourceMap(md);
+    const comment = makeComment({ selectedText: 'important' });
+    const result = addCommentAtOffset(md, comment, sourceMap);
+
+    expect(result).toContain('**important**[@1]');
+    expect(result).toContain('`;
+
+  it('should add a reply to annotation thread', () => {
+    const reply: Omit = {
+      author: 'bob',
+      body: 'Good catch',
+      date: '2026-03-04T10:00:00Z',
+    };
+    const { markdown, replyId } = addReply(v2Md, 'comment-1', reply);
+
+    expect(replyId).toBe('reply-1');
+    expect(markdown).toContain('"thread"');
+    expect(markdown).toContain('"author": "bob"');
+  });
+});
+
+describe('toggleReaction', () => {
+  const v2Md = `Some highlighted text[@1] in context.
+
+`;
+
+  it('should add a reaction when none exist', () => {
+    const result = toggleReaction(v2Md, 'comment-1', '\u{1F44D}', 'bob');
+
+    expect(result).toContain('"reactions"');
+    const parsed = parseAnnotations(result);
+    expect(parsed.comments[0].reactions).toBeDefined();
+    expect(parsed.comments[0].reactions!['\u{1F44D}']).toContain('bob');
+  });
+});
+
+describe('round-trip: serialize then parse', () => {
+  it('should round-trip a basic comment', () => {
+    const md = 'Some highlighted text in context.\n';
+    const comment = makeComment();
+    const serialized = addComment(md, comment);
+    const parsed = parseAnnotations(serialized);
+
+    expect(parsed.comments).toHaveLength(1);
+    expect(parsed.comments[0].id).toBe('comment-1');
+    expect(parsed.comments[0].selectedText).toBe('highlighted text');
+    expect(parsed.comments[0].body).toBe('This is a comment');
+  });
+});
diff --git a/packages/core/src/__tests__/comments/comment-context.test.ts b/packages/core/src/__tests__/comments/comment-context.test.ts
new file mode 100644
index 0000000..1882eee
--- /dev/null
+++ b/packages/core/src/__tests__/comments/comment-context.test.ts
@@ -0,0 +1,103 @@
+/**
+ * Tests for Comment Context
+ *
+ * Verifies that computeCommentContext correctly derives positional context
+ * (line number, section heading, breadcrumb) from a character offset in
+ * raw markdown content.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { computeCommentContext } from '../../comments/comment-context';
+
+describe('computeCommentContext', () => {
+  it('should return line number and section for offset in a section', () => {
+    const md = [
+      '# Title',
+      '',
+      '## Installation',
+      '',
+      'Run npm install to get started.',
+    ].join('\n');
+
+    const offset = md.indexOf('npm');
+    const ctx = computeCommentContext(md, offset);
+
+    expect(ctx.line).toBe(5);
+    expect(ctx.section).toBe('Installation');
+    expect(ctx.sectionLevel).toBe(2);
+    expect(ctx.breadcrumb).toEqual(['Title', 'Installation']);
+  });
+
+  it('should handle offset before any heading (preamble)', () => {
+    const md = [
+      'Some preamble text here.',
+      '',
+      '# First Heading',
+      '',
+      'Content under heading.',
+    ].join('\n');
+
+    const offset = md.indexOf('preamble');
+    const ctx = computeCommentContext(md, offset);
+
+    expect(ctx.line).toBe(1);
+    expect(ctx.section).toBeUndefined();
+    expect(ctx.sectionLevel).toBeUndefined();
+    expect(ctx.breadcrumb).toEqual([]);
+  });
+
+  it('should build nested breadcrumb (h1 > h2 > h3)', () => {
+    const md = [
+      '# Getting Started',
+      '',
+      '## Installation',
+      '',
+      '### Prerequisites',
+      '',
+      'You need Node.js 18+.',
+    ].join('\n');
+
+    const offset = md.indexOf('Node.js');
+    const ctx = computeCommentContext(md, offset);
+
+    expect(ctx.line).toBe(7);
+    expect(ctx.section).toBe('Prerequisites');
+    expect(ctx.sectionLevel).toBe(3);
+    expect(ctx.breadcrumb).toEqual([
+      'Getting Started',
+      'Installation',
+      'Prerequisites',
+    ]);
+  });
+
+  it('should handle empty string', () => {
+    const ctx = computeCommentContext('', 0);
+
+    expect(ctx.line).toBe(1);
+    expect(ctx.breadcrumb).toEqual([]);
+  });
+
+  it('should reset breadcrumb correctly for peer sections', () => {
+    const md = [
+      '# Guide',
+      '',
+      '## Alpha',
+      '',
+      '### Alpha Sub',
+      '',
+      'Alpha sub content.',
+      '',
+      '## Beta',
+      '',
+      'Beta content here.',
+    ].join('\n');
+
+    const offset = md.indexOf('Beta content');
+    const ctx = computeCommentContext(md, offset);
+
+    expect(ctx.line).toBe(11);
+    expect(ctx.section).toBe('Beta');
+    expect(ctx.sectionLevel).toBe(2);
+    expect(ctx.breadcrumb).toEqual(['Guide', 'Beta']);
+  });
+});
diff --git a/packages/core/src/__tests__/comments/comment-parser.test.ts b/packages/core/src/__tests__/comments/comment-parser.test.ts
new file mode 100644
index 0000000..67fbc6a
--- /dev/null
+++ b/packages/core/src/__tests__/comments/comment-parser.test.ts
@@ -0,0 +1,107 @@
+/**
+ * Tests for comment parser - extracts structured comments from markdown footnotes
+ */
+
+import { describe, it, expect } from 'vitest';
+import { parseComments } from '../../comments/comment-parser';
+
+describe('parseComments', () => {
+  describe('No comments', () => {
+    it('should return unchanged markdown and empty array when no comments exist', () => {
+      const markdown = `# Hello World
+
+This is a paragraph.
+
+## Section Two
+
+More content here.`;
+
+      const result = parseComments(markdown);
+
+      expect(result.cleanedMarkdown).toBe(markdown);
+      expect(result.comments).toEqual([]);
+    });
+
+    it('should handle empty markdown', () => {
+      const result = parseComments('');
+
+      expect(result.cleanedMarkdown).toBe('');
+      expect(result.comments).toEqual([]);
+    });
+  });
+
+  describe('Single comment extraction', () => {
+    it('should extract a single comment with all fields', () => {
+      const markdown = `Some highlighted text[^comment-1] in context.
+
+
+[^comment-1]: 
+    This API endpoint needs error handling
+    for the 404 case.`;
+
+      const result = parseComments(markdown);
+
+      expect(result.comments).toHaveLength(1);
+      const comment = result.comments[0];
+      expect(comment.id).toBe('comment-1');
+      expect(comment.selectedText).toBe('text');
+      expect(comment.body).toBe('This API endpoint needs error handling\nfor the 404 case.');
+      expect(comment.author).toBe('reviewer');
+      expect(comment.date).toBe('2026-03-03T14:30:00Z');
+      expect(comment.resolved).toBe(false);
+    });
+
+    it('should remove comment reference from cleaned markdown', () => {
+      const markdown = `Some highlighted text[^comment-1] in context.
+
+
+[^comment-1]: 
+    This is a comment.`;
+
+      const result = parseComments(markdown);
+
+      expect(result.cleanedMarkdown).toBe('Some highlighted text in context.');
+      expect(result.cleanedMarkdown).not.toContain('[^comment-1]');
+      expect(result.cleanedMarkdown).not.toContain('mdview:comment');
+    });
+  });
+
+  describe('Multiple comments', () => {
+    it('should extract multiple comments', () => {
+      const markdown = `First text[^comment-1] and second text[^comment-2] in the doc.
+
+
+[^comment-1]: 
+    First comment body.
+[^comment-2]: 
+    Second comment body.`;
+
+      const result = parseComments(markdown);
+
+      expect(result.comments).toHaveLength(2);
+      expect(result.comments[0].id).toBe('comment-1');
+      expect(result.comments[0].author).toBe('alice');
+      expect(result.comments[0].body).toBe('First comment body.');
+      expect(result.comments[1].id).toBe('comment-2');
+      expect(result.comments[1].author).toBe('bob');
+      expect(result.comments[1].body).toBe('Second comment body.');
+    });
+  });
+
+  describe('Malformed metadata', () => {
+    it('should skip comments with invalid JSON metadata', () => {
+      const markdown = `Text[^comment-1] and more text[^comment-2] here.
+
+
+[^comment-1]: 
+    This should be skipped.
+[^comment-2]: 
+    This should be parsed.`;
+
+      const result = parseComments(markdown);
+
+      expect(result.comments).toHaveLength(1);
+      expect(result.comments[0].id).toBe('comment-2');
+    });
+  });
+});
diff --git a/packages/core/src/__tests__/comments/comment-serializer.test.ts b/packages/core/src/__tests__/comments/comment-serializer.test.ts
new file mode 100644
index 0000000..3226acc
--- /dev/null
+++ b/packages/core/src/__tests__/comments/comment-serializer.test.ts
@@ -0,0 +1,106 @@
+/**
+ * Tests for Comment Serializer (v1 format)
+ *
+ * Verifies markdown footnote-based comment operations:
+ * generating IDs, adding, removing, updating, and resolving comments.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  generateNextCommentId,
+  addComment,
+  removeComment,
+  updateComment,
+  resolveComment,
+} from '../../comments/comment-serializer';
+import type { Comment } from '../../types/index';
+
+function makeComment(overrides: Partial = {}): Comment {
+  return {
+    id: 'comment-1',
+    selectedText: 'highlighted text',
+    body: 'This is a comment',
+    author: 'reviewer',
+    date: '2026-03-03T14:30:00Z',
+    resolved: false,
+    ...overrides,
+  };
+}
+
+describe('generateNextCommentId', () => {
+  it('should return "comment-1" for empty markdown', () => {
+    expect(generateNextCommentId('')).toBe('comment-1');
+  });
+
+  it('should return "comment-2" when comment-1 exists', () => {
+    const md = [
+      'Some highlighted text[^comment-1] in context.',
+      '',
+      '',
+      '[^comment-1]: ',
+      '    This is a comment',
+    ].join('\n');
+    expect(generateNextCommentId(md)).toBe('comment-2');
+  });
+});
+
+describe('addComment', () => {
+  it('should add a reference after selectedText and a footnote body at the end', () => {
+    const md = 'Some highlighted text in context.\n';
+    const comment = makeComment();
+    const result = addComment(md, comment);
+
+    expect(result).toContain('Some highlighted text[^comment-1] in context.');
+    expect(result).toContain('');
+    expect(result).toContain('[^comment-1]: ',
+      '[^comment-1]: ',
+      '    This is a comment',
+    ].join('\n');
+    const result = removeComment(md, 'comment-1');
+
+    expect(result).not.toContain('[^comment-1]');
+    expect(result).not.toContain('This is a comment');
+    expect(result).toContain('Some highlighted text in context.');
+  });
+});
+
+describe('updateComment', () => {
+  it('should replace the body text while keeping metadata unchanged', () => {
+    const md = [
+      'Some highlighted text[^comment-1] in context.',
+      '',
+      '',
+      '[^comment-1]: ',
+      '    Original body text',
+    ].join('\n');
+    const result = updateComment(md, 'comment-1', 'Updated body text');
+
+    expect(result).toContain('    Updated body text');
+    expect(result).not.toContain('Original body text');
+  });
+});
+
+describe('resolveComment', () => {
+  it('should add "resolved":true to metadata JSON', () => {
+    const md = [
+      'Some highlighted text[^comment-1] in context.',
+      '',
+      '',
+      '[^comment-1]: ',
+      '    This is a comment',
+    ].join('\n');
+    const result = resolveComment(md, 'comment-1');
+
+    expect(result).toContain('"resolved":true');
+  });
+});
diff --git a/packages/core/src/__tests__/comments/emoji-data.test.ts b/packages/core/src/__tests__/comments/emoji-data.test.ts
new file mode 100644
index 0000000..86ee996
--- /dev/null
+++ b/packages/core/src/__tests__/comments/emoji-data.test.ts
@@ -0,0 +1,61 @@
+/**
+ * Tests for emoji data module
+ */
+
+import { describe, it, expect } from 'vitest';
+import { QUICK_EMOJIS, EMOJI_CATEGORIES, searchEmojis } from '../../comments/emoji-data';
+
+describe('QUICK_EMOJIS', () => {
+  it('should have exactly 12 entries', () => {
+    expect(QUICK_EMOJIS).toHaveLength(12);
+  });
+
+  it('should include common reaction emojis', () => {
+    const chars = QUICK_EMOJIS.map((e) => e.char);
+    expect(chars).toContain('\u{1F44D}'); // thumbs up
+    expect(chars).toContain('\u{1F44E}'); // thumbs down
+    expect(chars).toContain('\u{2764}\u{FE0F}'); // red heart
+    expect(chars).toContain('\u{1F680}'); // rocket
+    expect(chars).toContain('\u{2705}'); // check mark
+    expect(chars).toContain('\u{274C}'); // cross mark
+  });
+});
+
+describe('EMOJI_CATEGORIES', () => {
+  it('should have exactly 8 categories', () => {
+    expect(EMOJI_CATEGORIES).toHaveLength(8);
+  });
+
+  it('should have name and emojis on each category', () => {
+    for (const cat of EMOJI_CATEGORIES) {
+      expect(cat.name).toBeTruthy();
+      expect(cat.emojis.length).toBeGreaterThan(0);
+    }
+  });
+});
+
+describe('searchEmojis', () => {
+  it('should find emojis by name', () => {
+    const results = searchEmojis('thumbs');
+    expect(results.length).toBeGreaterThan(0);
+    const chars = results.map((e) => e.char);
+    expect(chars).toContain('\u{1F44D}');
+  });
+
+  it('should find emojis by keyword', () => {
+    const results = searchEmojis('fire');
+    expect(results.length).toBeGreaterThan(0);
+    const chars = results.map((e) => e.char);
+    expect(chars).toContain('\u{1F525}');
+  });
+
+  it('should return empty array for no match', () => {
+    const results = searchEmojis('zzzznotanemoji');
+    expect(results).toEqual([]);
+  });
+
+  it('should return empty array for empty query', () => {
+    const results = searchEmojis('');
+    expect(results).toEqual([]);
+  });
+});
diff --git a/packages/core/src/__tests__/comments/source-position-map.test.ts b/packages/core/src/__tests__/comments/source-position-map.test.ts
new file mode 100644
index 0000000..512a955
--- /dev/null
+++ b/packages/core/src/__tests__/comments/source-position-map.test.ts
@@ -0,0 +1,107 @@
+/**
+ * Tests for Source Position Map
+ *
+ * Verifies the inline syntax stripping with offset tracking that enables
+ * accurate comment footnote reference insertion into formatted markdown.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+  buildSourceMap,
+  findInsertionPoint,
+} from '../../comments/source-position-map';
+
+describe('buildSourceMap', () => {
+  it('should produce identity mapping for plain text', () => {
+    const map = buildSourceMap('Hello world');
+    expect(map.plainText).toBe('Hello world');
+    expect(map.offsets).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
+    expect(map.spans).toEqual([]);
+  });
+
+  it('should strip bold markers and track offsets', () => {
+    const map = buildSourceMap('**bold**');
+    expect(map.plainText).toBe('bold');
+    expect(map.offsets).toEqual([2, 3, 4, 5]);
+    expect(map.spans).toHaveLength(1);
+    expect(map.spans[0]).toMatchObject({
+      sourceStart: 0,
+      sourceEnd: 8,
+      plainStart: 0,
+      plainEnd: 4,
+      type: 'bold',
+    });
+  });
+
+  it('should strip link syntax keeping display text', () => {
+    const map = buildSourceMap('[click](https://example.com)');
+    expect(map.plainText).toBe('click');
+    expect(map.spans.some((s) => s.type === 'link')).toBe(true);
+  });
+
+  it('should stop at comment separator', () => {
+    const md = 'Content **here**\n\n\n[^comment-1]: stuff';
+    const map = buildSourceMap(md);
+    expect(map.plainText).toBe('Content here\n\n');
+    expect(map.rawSource).toBe('Content **here**\n\n');
+  });
+
+  it('should handle empty string', () => {
+    const map = buildSourceMap('');
+    expect(map.plainText).toBe('');
+    expect(map.offsets).toEqual([]);
+    expect(map.spans).toEqual([]);
+  });
+});
+
+describe('findInsertionPoint', () => {
+  it('should find insertion point for plain text', () => {
+    const map = buildSourceMap('Hello world');
+    const point = findInsertionPoint(map, 'world');
+    expect(point).toBe(11);
+  });
+
+  it('should find insertion point after bold text', () => {
+    const map = buildSourceMap('Hello **important** text');
+    const point = findInsertionPoint(map, 'important');
+    expect(point).toBe(19);
+  });
+
+  it('should find insertion point after link', () => {
+    const map = buildSourceMap('Click [here](https://example.com) for more');
+    const point = findInsertionPoint(map, 'here');
+    expect(point).toBe(33);
+  });
+
+  it('should return null for text not found', () => {
+    const map = buildSourceMap('Hello world');
+    const point = findInsertionPoint(map, 'missing');
+    expect(point).toBeNull();
+  });
+
+  it('should return null for empty selectedText', () => {
+    const map = buildSourceMap('Hello world');
+    const point = findInsertionPoint(map, '');
+    expect(point).toBeNull();
+  });
+
+  it('should disambiguate using prefix context', () => {
+    const map = buildSourceMap('The word test and another test here');
+    const point = findInsertionPoint(map, 'test', {
+      prefix: 'another ',
+      suffix: ' here',
+    });
+    expect(point).toBe(30);
+  });
+});
+
+describe('source map insertion integration', () => {
+  it('should allow correct footnote insertion after bold text', () => {
+    const md = 'This is **important** for the review.';
+    const map = buildSourceMap(md);
+    const point = findInsertionPoint(map, 'important');
+    expect(point).not.toBeNull();
+    const result = md.slice(0, point!) + '[^comment-1]' + md.slice(point!);
+    expect(result).toBe('This is **important**[^comment-1] for the review.');
+  });
+});
diff --git a/packages/core/src/__tests__/converter.test.ts b/packages/core/src/__tests__/converter.test.ts
new file mode 100644
index 0000000..a602827
--- /dev/null
+++ b/packages/core/src/__tests__/converter.test.ts
@@ -0,0 +1,232 @@
+import { MarkdownConverter } from '../markdown-converter';
+import { extractFrontmatter, renderFrontmatterHtml } from '../frontmatter-extractor';
+import { CacheManager } from '../cache-manager';
+
+describe('MarkdownConverter', () => {
+  let converter: MarkdownConverter;
+
+  beforeEach(() => {
+    converter = new MarkdownConverter();
+  });
+
+  it('should convert basic markdown to HTML', () => {
+    const result = converter.convert('# Hello World');
+    expect(result.html).toContain(' {
+    const result = converter.convert('# Title\n\n## Subtitle');
+    expect(result.metadata.headings).toHaveLength(2);
+    expect(result.metadata.headings[0].level).toBe(1);
+    // The anchor plugin wraps heading content in text,
+    // making the inline token's content empty. The heading ID is still generated.
+    expect(result.metadata.headings[0].id).toBe('');
+    expect(result.metadata.headings[1].level).toBe(2);
+  });
+
+  it('should count words', () => {
+    const result = converter.convert('one two three four five');
+    expect(result.metadata.wordCount).toBe(5);
+  });
+
+  it('should detect code blocks', () => {
+    const result = converter.convert('```javascript\nconsole.log("hi");\n```');
+    expect(result.metadata.codeBlocks).toHaveLength(1);
+    expect(result.metadata.codeBlocks[0].language).toBe('javascript');
+  });
+
+  it('should detect mermaid blocks', () => {
+    const result = converter.convert('```mermaid\ngraph TD\n  A --> B\n```');
+    expect(result.metadata.mermaidBlocks).toHaveLength(1);
+    expect(result.metadata.mermaidBlocks[0].code).toContain('graph TD');
+    expect(result.html).toContain('mermaid-container');
+  });
+
+  it('should extract link metadata', () => {
+    const result = converter.convert('[Example](https://example.com)');
+    expect(result.metadata.links).toHaveLength(1);
+    expect(result.metadata.links[0].href).toBe('https://example.com');
+  });
+
+  it('should extract image metadata', () => {
+    const result = converter.convert('![alt text](image.png "title")');
+    expect(result.metadata.images).toHaveLength(1);
+    expect(result.metadata.images[0].src).toBe('image.png');
+    expect(result.metadata.images[0].alt).toBe('alt text');
+  });
+
+  it('should render task lists', () => {
+    const result = converter.convert('- [x] Done\n- [ ] Todo');
+    expect(result.html).toContain('type="checkbox"');
+  });
+
+  it('should validate valid markdown', () => {
+    const result = converter.validateSyntax('# Valid markdown');
+    expect(result.valid).toBe(true);
+    expect(result.errors).toHaveLength(0);
+  });
+
+  it('should handle empty input', () => {
+    const result = converter.convert('');
+    expect(result.html).toBeDefined();
+    expect(result.errors).toHaveLength(0);
+  });
+});
+
+describe('extractFrontmatter', () => {
+  it('should extract frontmatter from markdown', () => {
+    const markdown = '---\ntitle: Hello\nauthor: Test\n---\n# Content';
+    const result = extractFrontmatter(markdown);
+    expect(result.frontmatter).not.toBeNull();
+    expect(result.frontmatter!.title).toBe('Hello');
+    expect(result.frontmatter!.author).toBe('Test');
+    expect(result.cleanedMarkdown).toBe('# Content');
+  });
+
+  it('should return null frontmatter when none present', () => {
+    const result = extractFrontmatter('# No frontmatter');
+    expect(result.frontmatter).toBeNull();
+    expect(result.cleanedMarkdown).toBe('# No frontmatter');
+  });
+
+  it('should handle empty input', () => {
+    const result = extractFrontmatter('');
+    expect(result.frontmatter).toBeNull();
+    expect(result.cleanedMarkdown).toBe('');
+  });
+
+  it('should strip surrounding quotes from values', () => {
+    const markdown = '---\ntitle: "Quoted Title"\n---\n';
+    const result = extractFrontmatter(markdown);
+    expect(result.frontmatter!.title).toBe('Quoted Title');
+  });
+});
+
+describe('renderFrontmatterHtml', () => {
+  it('should render frontmatter as details/table HTML', () => {
+    const html = renderFrontmatterHtml({ title: 'Test', author: 'Author' });
+    expect(html).toContain(' {
+  let cache: CacheManager;
+
+  beforeEach(() => {
+    cache = new CacheManager({ maxSize: 3 });
+  });
+
+  it('should return null for missing keys', () => {
+    expect(cache.get('nonexistent')).toBeNull();
+  });
+
+  it('should store and retrieve cache entries', () => {
+    const result = {
+      html: '

test

', + metadata: { + wordCount: 1, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'test-key', + }; + + cache.set('test-key', result, '/test.md', 'hash123', 'github-light'); + const retrieved = cache.get('test-key'); + expect(retrieved).not.toBeNull(); + expect(retrieved!.html).toBe('

test

'); + }); + + it('should evict oldest entry when maxSize is reached', () => { + const makeResult = (key: string) => ({ + html: `

${key}

`, + metadata: { + wordCount: 1, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: key, + }); + + cache.set('key1', makeResult('key1'), '/a.md', 'h1', 'github-light'); + cache.set('key2', makeResult('key2'), '/b.md', 'h2', 'github-light'); + cache.set('key3', makeResult('key3'), '/c.md', 'h3', 'github-light'); + // This should evict key1 (oldest) + cache.set('key4', makeResult('key4'), '/d.md', 'h4', 'github-light'); + + expect(cache.get('key1')).toBeNull(); + expect(cache.get('key4')).not.toBeNull(); + }); + + it('should invalidate by path', () => { + const result = { + html: '

test

', + metadata: { + wordCount: 1, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'k', + }; + + cache.set('k', result, '/file.md', 'hash', 'github-light'); + cache.invalidateByPath('/file.md'); + expect(cache.get('k')).toBeNull(); + }); + + it('should clear all entries', () => { + const result = { + html: '

test

', + metadata: { + wordCount: 1, + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'k', + }; + + cache.set('k', result, '/file.md', 'hash', 'github-light'); + cache.clear(); + expect(cache.getStats().size).toBe(0); + }); + + it('should report stats', () => { + const stats = cache.getStats(); + expect(stats.size).toBe(0); + expect(stats.maxSize).toBe(3); + expect(stats.oldestEntry).toBeNull(); + }); +}); diff --git a/packages/core/src/__tests__/debug-logger.test.ts b/packages/core/src/__tests__/debug-logger.test.ts new file mode 100644 index 0000000..77d5caa --- /dev/null +++ b/packages/core/src/__tests__/debug-logger.test.ts @@ -0,0 +1,254 @@ +import type { StorageAdapter } from '../adapters'; +import { DebugLogger, createDebugLogger, createDebug } from '../utils/debug-logger'; + +describe('DebugLogger', () => { + describe('works with mock StorageAdapter that returns preferences', () => { + it('loads log level from storage adapter', async () => { + const mockAdapter: StorageAdapter = { + async getSync(_keys: string | string[]) { + return { + preferences: { logLevel: 'debug' }, + }; + }, + async setSync() {}, + async getLocal() { + return {}; + }, + async setLocal() {}, + }; + + const logger = new DebugLogger(mockAdapter); + // Wait for async init to complete + await logger.ready; + + expect(logger.getLogLevel()).toBe('debug'); + }); + + it('respects legacy debug boolean from storage', async () => { + const mockAdapter: StorageAdapter = { + async getSync(_keys: string | string[]) { + return { + preferences: { debug: true }, + }; + }, + async setSync() {}, + async getLocal() { + return {}; + }, + async setLocal() {}, + }; + + const logger = new DebugLogger(mockAdapter); + await logger.ready; + + expect(logger.getLogLevel()).toBe('debug'); + }); + + it('defaults to error when storage has preferences without logLevel or debug', async () => { + const mockAdapter: StorageAdapter = { + async getSync(_keys: string | string[]) { + return { + preferences: { theme: 'github-light' }, + }; + }, + async setSync() {}, + async getLocal() { + return {}; + }, + async setLocal() {}, + }; + + const logger = new DebugLogger(mockAdapter); + await logger.ready; + + expect(logger.getLogLevel()).toBe('error'); + }); + + it('defaults to error when storage adapter rejects', async () => { + const mockAdapter: StorageAdapter = { + async getSync() { + throw new Error('storage unavailable'); + }, + async setSync() {}, + async getLocal() { + return {}; + }, + async setLocal() {}, + }; + + const logger = new DebugLogger(mockAdapter); + await logger.ready; + + expect(logger.getLogLevel()).toBe('error'); + }); + }); + + describe('works without any adapter (graceful degradation)', () => { + it('defaults to error log level without adapter', () => { + const logger = new DebugLogger(); + expect(logger.getLogLevel()).toBe('error'); + }); + + it('allows setting log level manually', () => { + const logger = new DebugLogger(); + logger.setLogLevel('info'); + expect(logger.getLogLevel()).toBe('info'); + }); + }); + + describe('log level filtering works correctly', () => { + let consoleSpy: { + log: ReturnType; + debug: ReturnType; + info: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + + beforeEach(() => { + consoleSpy = { + log: vi.spyOn(console, 'log').mockImplementation(() => {}), + debug: vi.spyOn(console, 'debug').mockImplementation(() => {}), + info: vi.spyOn(console, 'info').mockImplementation(() => {}), + warn: vi.spyOn(console, 'warn').mockImplementation(() => {}), + error: vi.spyOn(console, 'error').mockImplementation(() => {}), + }; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('at level none, suppresses all output', () => { + const logger = new DebugLogger(); + logger.setLogLevel('none'); + + logger.error('ctx', 'msg'); + logger.warn('ctx', 'msg'); + logger.info('ctx', 'msg'); + logger.debug('ctx', 'msg'); + logger.log('ctx', 'msg'); + + expect(consoleSpy.error).not.toHaveBeenCalled(); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(consoleSpy.info).not.toHaveBeenCalled(); + expect(consoleSpy.debug).not.toHaveBeenCalled(); + expect(consoleSpy.log).not.toHaveBeenCalled(); + }); + + it('at level error, only shows errors', () => { + const logger = new DebugLogger(); + logger.setLogLevel('error'); + + logger.error('ctx', 'err msg'); + logger.warn('ctx', 'warn msg'); + logger.info('ctx', 'info msg'); + logger.log('ctx', 'log msg'); + + expect(consoleSpy.error).toHaveBeenCalledWith('[ctx]', 'err msg'); + expect(consoleSpy.warn).not.toHaveBeenCalled(); + expect(consoleSpy.info).not.toHaveBeenCalled(); + expect(consoleSpy.log).not.toHaveBeenCalled(); + }); + + it('at level warn, shows errors and warnings', () => { + const logger = new DebugLogger(); + logger.setLogLevel('warn'); + + logger.error('ctx', 'err msg'); + logger.warn('ctx', 'warn msg'); + logger.info('ctx', 'info msg'); + logger.log('ctx', 'log msg'); + + expect(consoleSpy.error).toHaveBeenCalled(); + expect(consoleSpy.warn).toHaveBeenCalledWith('[ctx]', 'warn msg'); + expect(consoleSpy.info).not.toHaveBeenCalled(); + expect(consoleSpy.log).not.toHaveBeenCalled(); + }); + + it('at level info, shows errors, warnings, and info', () => { + const logger = new DebugLogger(); + logger.setLogLevel('info'); + + logger.error('ctx', 'err'); + logger.warn('ctx', 'warn'); + logger.info('ctx', 'info'); + logger.log('ctx', 'log'); + + expect(consoleSpy.error).toHaveBeenCalled(); + expect(consoleSpy.warn).toHaveBeenCalled(); + expect(consoleSpy.info).toHaveBeenCalledWith('[ctx]', 'info'); + expect(consoleSpy.log).not.toHaveBeenCalled(); + }); + + it('at level debug, shows everything', () => { + const logger = new DebugLogger(); + logger.setLogLevel('debug'); + + logger.error('ctx', 'err'); + logger.warn('ctx', 'warn'); + logger.info('ctx', 'info'); + logger.debug('ctx', 'dbg'); + logger.log('ctx', 'log'); + + expect(consoleSpy.error).toHaveBeenCalled(); + expect(consoleSpy.warn).toHaveBeenCalled(); + expect(consoleSpy.info).toHaveBeenCalled(); + expect(consoleSpy.debug).toHaveBeenCalledWith('[ctx]', 'dbg'); + expect(consoleSpy.log).toHaveBeenCalledWith('[ctx]', 'log'); + }); + }); + + describe('factory functions', () => { + it('createDebugLogger returns a DebugLogger instance', () => { + const logger = createDebugLogger(); + expect(logger).toBeInstanceOf(DebugLogger); + }); + + it('createDebugLogger accepts an optional adapter', async () => { + const mockAdapter: StorageAdapter = { + async getSync() { + return { preferences: { logLevel: 'warn' as const } }; + }, + async setSync() {}, + async getLocal() { + return {}; + }, + async setLocal() {}, + }; + + const logger = createDebugLogger(mockAdapter); + await logger.ready; + expect(logger.getLogLevel()).toBe('warn'); + }); + + it('createDebug returns convenience object with all methods', () => { + const d = createDebug(); + expect(typeof d.log).toBe('function'); + expect(typeof d.debug).toBe('function'); + expect(typeof d.info).toBe('function'); + expect(typeof d.warn).toBe('function'); + expect(typeof d.error).toBe('function'); + expect(typeof d.group).toBe('function'); + expect(typeof d.groupEnd).toBe('function'); + expect(typeof d.table).toBe('function'); + expect(typeof d.time).toBe('function'); + expect(typeof d.timeEnd).toBe('function'); + expect(typeof d.setLogLevel).toBe('function'); + expect(typeof d.getLogLevel).toBe('function'); + expect(typeof d.setDebugMode).toBe('function'); + expect(typeof d.isDebugEnabled).toBe('function'); + }); + + it('createDebug convenience methods delegate to logger', () => { + const d = createDebug(); + d.setLogLevel('debug'); + expect(d.getLogLevel()).toBe('debug'); + expect(d.isDebugEnabled()).toBe(true); + + d.setDebugMode(false); + expect(d.getLogLevel()).toBe('error'); + expect(d.isDebugEnabled()).toBe(false); + }); + }); +}); diff --git a/packages/core/src/__tests__/export-ui.test.ts b/packages/core/src/__tests__/export-ui.test.ts new file mode 100644 index 0000000..1c2884a --- /dev/null +++ b/packages/core/src/__tests__/export-ui.test.ts @@ -0,0 +1,218 @@ +/** + * Tests for core ExportUI with MessagingAdapter + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { MessagingAdapter, IPCMessage } from '../adapters'; +import { NoopMessagingAdapter } from '../adapters'; + +// We need to mock the DOM for ExportUI +function setupDOM(): void { + document.body.innerHTML = ''; +} + +describe('ExportUI (core)', () => { + beforeEach(() => { + setupDOM(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('constructor with MessagingAdapter', () => { + it('accepts an optional MessagingAdapter and loads preferences via it', async () => { + const mockMessaging: MessagingAdapter = { + send: vi.fn().mockResolvedValue({ + state: { + preferences: { + exportDefaultPageSize: 'Letter', + exportDefaultFormat: 'pdf', + exportIncludeToc: false, + exportFilenameTemplate: '{title}-{date}', + }, + }, + }), + }; + + const { ExportUI } = await import('../ui/export-ui'); + const ui = new ExportUI({ messaging: mockMessaging }); + + // Give loadPreferences time to run (it's async in constructor) + await vi.waitFor(() => { + expect(mockMessaging.send).toHaveBeenCalledWith({ type: 'GET_STATE' }); + }); + + // Verify the adapter was called with the right message + expect(mockMessaging.send).toHaveBeenCalledTimes(1); + + ui.destroy(); + }); + + it('applies loaded preferences to options', async () => { + const mockMessaging: MessagingAdapter = { + send: vi.fn().mockResolvedValue({ + state: { + preferences: { + exportDefaultPageSize: 'Letter', + exportDefaultFormat: 'pdf', + }, + }, + }), + }; + + const { ExportUI } = await import('../ui/export-ui'); + const ui = new ExportUI({ messaging: mockMessaging }); + + // Wait for preferences to load + await vi.waitFor(() => { + expect(mockMessaging.send).toHaveBeenCalled(); + }); + + // The ExportUI should have updated its internal options + // We can verify by creating the button and checking it works + const button = ui.createExportButton(); + expect(button).toBeTruthy(); + expect(button.className).toContain('mdview-export-btn'); + + ui.destroy(); + }); + }); + + describe('graceful degradation without adapter', () => { + it('creates ExportUI without a messaging adapter', async () => { + const { ExportUI } = await import('../ui/export-ui'); + const ui = new ExportUI(); + + // Should not throw + expect(ui).toBeTruthy(); + + const button = ui.createExportButton(); + expect(button).toBeTruthy(); + expect(button.className).toContain('mdview-export-btn'); + + ui.destroy(); + }); + + it('works with NoopMessagingAdapter', async () => { + const noopMessaging = new NoopMessagingAdapter(); + const sendSpy = vi.spyOn(noopMessaging, 'send'); + + const { ExportUI } = await import('../ui/export-ui'); + const ui = new ExportUI({ messaging: noopMessaging }); + + // Wait for the async loadPreferences to complete + await vi.waitFor(() => { + expect(sendSpy).toHaveBeenCalled(); + }); + + // NoopMessagingAdapter returns {} which has no state.preferences + // So options should remain at defaults + const button = ui.createExportButton(); + expect(button).toBeTruthy(); + + ui.destroy(); + }); + + it('handles messaging adapter that rejects', async () => { + const failingMessaging: MessagingAdapter = { + send: vi.fn().mockRejectedValue(new Error('Connection failed')), + }; + + const { ExportUI } = await import('../ui/export-ui'); + // Should not throw even if messaging fails + const ui = new ExportUI({ messaging: failingMessaging }); + + await vi.waitFor(() => { + expect(failingMessaging.send).toHaveBeenCalled(); + }); + + // Should still work - graceful degradation + const button = ui.createExportButton(); + expect(button).toBeTruthy(); + + ui.destroy(); + }); + + it('handles messaging adapter that returns empty response', async () => { + const emptyMessaging: MessagingAdapter = { + send: vi.fn().mockResolvedValue(null), + }; + + const { ExportUI } = await import('../ui/export-ui'); + const ui = new ExportUI({ messaging: emptyMessaging }); + + await vi.waitFor(() => { + expect(emptyMessaging.send).toHaveBeenCalled(); + }); + + // Should work fine with null response + const button = ui.createExportButton(); + expect(button).toBeTruthy(); + + ui.destroy(); + }); + }); + + describe('UI functionality', () => { + it('creates export button with correct attributes', async () => { + const { ExportUI } = await import('../ui/export-ui'); + const ui = new ExportUI(); + + const button = ui.createExportButton(); + + expect(button.getAttribute('aria-label')).toBe('Export document'); + expect(button.getAttribute('aria-haspopup')).toBe('menu'); + expect(button.getAttribute('aria-expanded')).toBe('false'); + + ui.destroy(); + }); + + it('toggles menu visibility', async () => { + const { ExportUI } = await import('../ui/export-ui'); + const ui = new ExportUI(); + ui.createExportButton(); + + // Show menu + ui.showMenu(); + // There should be a menu in the DOM + const menu = document.querySelector('.mdview-export-menu'); + expect(menu).toBeTruthy(); + + // Hide menu + ui.hideMenu(); + + ui.destroy(); + }); + + it('destroys cleanly', async () => { + const { ExportUI } = await import('../ui/export-ui'); + const ui = new ExportUI(); + const button = ui.createExportButton(); + document.body.appendChild(button); + + ui.showMenu(); + + // Should not throw + ui.destroy(); + }); + }); + + describe('does NOT import chrome APIs', () => { + it('module source does not reference chrome.runtime', async () => { + // This is a static analysis check - import the module and verify + // it loaded without chrome global + const originalChrome = (globalThis as Record).chrome; + delete (globalThis as Record).chrome; + + try { + // Re-import to ensure it works without chrome global + const mod = await import('../ui/export-ui'); + expect(mod.ExportUI).toBeDefined(); + } finally { + if (originalChrome !== undefined) { + (globalThis as Record).chrome = originalChrome; + } + } + }); + }); +}); diff --git a/packages/core/src/__tests__/file-scanner.test.ts b/packages/core/src/__tests__/file-scanner.test.ts new file mode 100644 index 0000000..5427109 --- /dev/null +++ b/packages/core/src/__tests__/file-scanner.test.ts @@ -0,0 +1,380 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { FileScanner } from '../utils/file-scanner'; +import type { FileAdapter, FileChangeInfo } from '../adapters'; +import { NoopFileAdapter } from '../adapters'; + +describe('FileScanner (core)', () => { + describe('hasMarkdownExtension', () => { + it('recognizes .md files', () => { + expect(FileScanner.hasMarkdownExtension('/path/to/file.md')).toBe(true); + }); + + it('recognizes .markdown files', () => { + expect(FileScanner.hasMarkdownExtension('/path/to/file.markdown')).toBe(true); + }); + + it('is case-insensitive', () => { + expect(FileScanner.hasMarkdownExtension('/path/to/FILE.MD')).toBe(true); + expect(FileScanner.hasMarkdownExtension('/path/to/README.Markdown')).toBe(true); + }); + + it('rejects non-markdown extensions', () => { + expect(FileScanner.hasMarkdownExtension('/path/to/file.txt')).toBe(false); + expect(FileScanner.hasMarkdownExtension('/path/to/file.html')).toBe(false); + expect(FileScanner.hasMarkdownExtension('/path/to/file.js')).toBe(false); + }); + }); + + describe('matchesPattern (blocklist)', () => { + it('matches exact hostname', () => { + expect( + FileScanner.matchesBlocklistPattern('github.com', 'https://github.com/repo', 'github.com', '/repo') + ).toBe(true); + }); + + it('does not match different hostname', () => { + expect( + FileScanner.matchesBlocklistPattern('gitlab.com', 'https://github.com/repo', 'github.com', '/repo') + ).toBe(false); + }); + + it('matches wildcard subdomain', () => { + expect( + FileScanner.matchesBlocklistPattern('*.github.com', 'https://raw.github.com/file', 'raw.github.com', '/file') + ).toBe(true); + }); + + it('matches wildcard subdomain against base domain', () => { + expect( + FileScanner.matchesBlocklistPattern('*.github.com', 'https://github.com/file', 'github.com', '/file') + ).toBe(true); + }); + + it('matches path pattern', () => { + expect( + FileScanner.matchesBlocklistPattern('github.com/*/blob/*', 'https://github.com/user/blob/main', 'github.com', '/user/blob/main') + ).toBe(true); + }); + + it('matches full URL pattern', () => { + expect( + FileScanner.matchesBlocklistPattern('https://raw.githubusercontent.com/*', 'https://raw.githubusercontent.com/file.md', 'raw.githubusercontent.com', '/file.md') + ).toBe(true); + }); + + it('rejects empty pattern', () => { + expect( + FileScanner.matchesBlocklistPattern('', 'https://github.com', 'github.com', '/') + ).toBe(false); + }); + + it('rejects whitespace-only pattern', () => { + expect( + FileScanner.matchesBlocklistPattern(' ', 'https://github.com', 'github.com', '/') + ).toBe(false); + }); + }); + + describe('isSiteBlockedFromList', () => { + it('returns false for empty blocklist', () => { + expect(FileScanner.isSiteBlockedFromList([], 'https://github.com', 'github.com', '/')).toBe(false); + }); + + it('returns true when URL matches a pattern', () => { + expect( + FileScanner.isSiteBlockedFromList( + ['github.com', '*.gitlab.com'], + 'https://github.com/repo/file.md', + 'github.com', + '/repo/file.md' + ) + ).toBe(true); + }); + + it('returns false when URL does not match any pattern', () => { + expect( + FileScanner.isSiteBlockedFromList( + ['github.com', '*.gitlab.com'], + 'https://example.com/file.md', + 'example.com', + '/file.md' + ) + ).toBe(false); + }); + }); + + describe('generateHash', () => { + it('produces consistent hash for same content', async () => { + const hash1 = await FileScanner.generateHash('hello world'); + const hash2 = await FileScanner.generateHash('hello world'); + expect(hash1).toBe(hash2); + }); + + it('produces different hashes for different content', async () => { + // The global test setup mocks crypto.subtle.digest to always return + // zeroed ArrayBuffer(32). Override it to return content-dependent results. + let callCount = 0; + const originalDigest = crypto.subtle.digest; + crypto.subtle.digest = vi.fn(async () => { + callCount++; + const buf = new ArrayBuffer(32); + const view = new Uint8Array(buf); + view[0] = callCount; // Make each call return a unique hash + return buf; + }); + + try { + const hash1 = await FileScanner.generateHash('hello world'); + const hash2 = await FileScanner.generateHash('goodbye world'); + expect(hash1).not.toBe(hash2); + } finally { + crypto.subtle.digest = originalDigest; + } + }); + + it('returns a hex string', async () => { + const hash = await FileScanner.generateHash('test'); + // The global mock returns zeroed ArrayBuffer(32), which yields 64 hex '0' chars + expect(hash).toMatch(/^[0-9a-f]+$/); + expect(hash).toHaveLength(64); + }); + }); + + describe('validateFileSize', () => { + it('accepts content within size limit', () => { + expect(FileScanner.validateFileSize('small content')).toBe(true); + }); + + it('accepts content at exact limit', () => { + const content = 'x'.repeat(100); + expect(FileScanner.validateFileSize(content, 100)).toBe(true); + }); + + it('rejects content exceeding limit', () => { + const content = 'x'.repeat(200); + expect(FileScanner.validateFileSize(content, 100)).toBe(false); + }); + }); + + describe('formatFileSize', () => { + it('formats bytes', () => { + expect(FileScanner.formatFileSize(500)).toBe('500 B'); + }); + + it('formats kilobytes', () => { + expect(FileScanner.formatFileSize(2048)).toBe('2.0 KB'); + }); + + it('formats megabytes', () => { + expect(FileScanner.formatFileSize(3 * 1024 * 1024)).toBe('3.0 MB'); + }); + }); + + describe('getFileSize', () => { + it('returns byte length of content', () => { + const size = FileScanner.getFileSize('abc'); + expect(size).toBe(3); + }); + }); + + describe('watchFile with FileAdapter', () => { + let mockAdapter: FileAdapter; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls callback when file has changed', async () => { + mockAdapter = { + writeFile: vi.fn(), + readFile: vi.fn(), + checkChanged: vi.fn().mockResolvedValue({ changed: true, newHash: 'new-hash-123' } as FileChangeInfo), + watch: vi.fn().mockReturnValue(() => {}), + }; + + const callback = vi.fn(); + const cleanup = FileScanner.watchFile( + 'file:///test.md', + 'initial-hash', + callback, + { fileAdapter: mockAdapter, interval: 1000 } + ); + + // Advance timer to trigger the check + await vi.advanceTimersByTimeAsync(1000); + + expect(mockAdapter.checkChanged).toHaveBeenCalledWith('file:///test.md', 'initial-hash'); + expect(callback).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('does not call callback when file has not changed', async () => { + mockAdapter = { + writeFile: vi.fn(), + readFile: vi.fn(), + checkChanged: vi.fn().mockResolvedValue({ changed: false } as FileChangeInfo), + watch: vi.fn().mockReturnValue(() => {}), + }; + + const callback = vi.fn(); + const cleanup = FileScanner.watchFile( + 'file:///test.md', + 'initial-hash', + callback, + { fileAdapter: mockAdapter, interval: 1000 } + ); + + await vi.advanceTimersByTimeAsync(1000); + + expect(mockAdapter.checkChanged).toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('updates lastHash after change is detected', async () => { + mockAdapter = { + writeFile: vi.fn(), + readFile: vi.fn(), + checkChanged: vi.fn() + .mockResolvedValueOnce({ changed: true, newHash: 'second-hash' } as FileChangeInfo) + .mockResolvedValueOnce({ changed: false } as FileChangeInfo), + watch: vi.fn().mockReturnValue(() => {}), + }; + + const callback = vi.fn(); + const cleanup = FileScanner.watchFile( + 'file:///test.md', + 'first-hash', + callback, + { fileAdapter: mockAdapter, interval: 1000 } + ); + + // First check — file changed + await vi.advanceTimersByTimeAsync(1000); + expect(mockAdapter.checkChanged).toHaveBeenCalledWith('file:///test.md', 'first-hash'); + + // Second check — should use the updated hash + await vi.advanceTimersByTimeAsync(1000); + expect(mockAdapter.checkChanged).toHaveBeenCalledWith('file:///test.md', 'second-hash'); + + cleanup(); + }); + + it('handles adapter errors gracefully', async () => { + mockAdapter = { + writeFile: vi.fn(), + readFile: vi.fn(), + checkChanged: vi.fn().mockRejectedValue(new Error('Network error')), + watch: vi.fn().mockReturnValue(() => {}), + }; + + const callback = vi.fn(); + const cleanup = FileScanner.watchFile( + 'file:///test.md', + 'initial-hash', + callback, + { fileAdapter: mockAdapter, interval: 1000 } + ); + + // Should not throw + await vi.advanceTimersByTimeAsync(1000); + + expect(callback).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('handles adapter error response gracefully', async () => { + mockAdapter = { + writeFile: vi.fn(), + readFile: vi.fn(), + checkChanged: vi.fn().mockResolvedValue({ changed: false, error: 'File not found' } as FileChangeInfo), + watch: vi.fn().mockReturnValue(() => {}), + }; + + const callback = vi.fn(); + const cleanup = FileScanner.watchFile( + 'file:///test.md', + 'initial-hash', + callback, + { fileAdapter: mockAdapter, interval: 1000 } + ); + + await vi.advanceTimersByTimeAsync(1000); + + expect(callback).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('cleanup function stops polling', async () => { + mockAdapter = { + writeFile: vi.fn(), + readFile: vi.fn(), + checkChanged: vi.fn().mockResolvedValue({ changed: true, newHash: 'new' } as FileChangeInfo), + watch: vi.fn().mockReturnValue(() => {}), + }; + + const callback = vi.fn(); + const cleanup = FileScanner.watchFile( + 'file:///test.md', + 'initial-hash', + callback, + { fileAdapter: mockAdapter, interval: 1000 } + ); + + // Stop before any tick + cleanup(); + + await vi.advanceTimersByTimeAsync(5000); + + expect(mockAdapter.checkChanged).not.toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('watchFile without adapter (graceful degradation)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns a cleanup function without an adapter', () => { + const callback = vi.fn(); + const cleanup = FileScanner.watchFile( + 'file:///test.md', + 'initial-hash', + callback, + {} + ); + + expect(typeof cleanup).toBe('function'); + cleanup(); // should not throw + }); + + it('never calls callback without an adapter', async () => { + const callback = vi.fn(); + const cleanup = FileScanner.watchFile( + 'file:///test.md', + 'initial-hash', + callback, + {} + ); + + await vi.advanceTimersByTimeAsync(5000); + + expect(callback).not.toHaveBeenCalled(); + + cleanup(); + }); + }); +}); diff --git a/packages/core/src/__tests__/render-pipeline.test.ts b/packages/core/src/__tests__/render-pipeline.test.ts new file mode 100644 index 0000000..5adf752 --- /dev/null +++ b/packages/core/src/__tests__/render-pipeline.test.ts @@ -0,0 +1,294 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { MessagingAdapter, IPCMessage } from '../adapters'; + +// Mock heavy dependencies that live outside @mdview/core +vi.mock('../utils/dom-purifier', () => ({ + domPurifier: { + sanitize: (html: string) => html, + }, +})); + +vi.mock('../workers/worker-pool', () => ({ + workerPool: { + initialize: vi.fn().mockResolvedValue(undefined), + execute: vi.fn(), + }, +})); + +vi.mock('../renderers/syntax-highlighter', () => ({ + syntaxHighlighter: { + highlightVisible: vi.fn(), + }, +})); + +vi.mock('../renderers/mermaid-renderer', () => ({ + mermaidRenderer: { + renderAll: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../utils/toc-stripper', () => ({ + stripTableOfContents: vi.fn((md: string) => ({ markdown: md, tocFound: false })), +})); + +vi.mock('../comments/annotation-parser', () => ({ + parseComments: vi.fn((md: string) => ({ + cleanedMarkdown: md, + comments: [], + })), +})); + +vi.mock('../utils/skeleton-renderer', () => ({ + SkeletonRenderer: { + generateSkeleton: vi.fn(() => '
skeleton
'), + isHydrated: vi.fn(() => false), + markHydrated: vi.fn(), + }, +})); + +import { RenderPipeline } from '../render-pipeline'; + +describe('RenderPipeline', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + vi.clearAllMocks(); + }); + + describe('with MessagingAdapter', () => { + it('sends CACHE_GENERATE_KEY and CACHE_GET when useCache is true', async () => { + const sendMock = vi.fn().mockImplementation((msg: IPCMessage) => { + if (msg.type === 'CACHE_GENERATE_KEY') { + return Promise.resolve({ key: 'test-cache-key' }); + } + if (msg.type === 'CACHE_GET') { + return Promise.resolve({ result: null }); + } + if (msg.type === 'CACHE_SET') { + return Promise.resolve({}); + } + return Promise.resolve({}); + }); + const mockMessaging: MessagingAdapter = { send: sendMock }; + + const pipeline = new RenderPipeline({ messaging: mockMessaging }); + + await pipeline.render({ + container, + markdown: '# Hello', + filePath: '/test.md', + useCache: true, + useWorkers: false, + }); + + // Should have called CACHE_GENERATE_KEY + expect(sendMock).toHaveBeenCalledWith( + expect.objectContaining({ type: 'CACHE_GENERATE_KEY' }) + ); + + // Should have called CACHE_GET + expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({ type: 'CACHE_GET' })); + }); + + it('uses cached result when adapter returns a cache hit', async () => { + const cachedHtml = '

Cached content

'; + const sendMock = vi.fn().mockImplementation((msg: IPCMessage) => { + if (msg.type === 'CACHE_GENERATE_KEY') { + return Promise.resolve({ key: 'cached-key' }); + } + if (msg.type === 'CACHE_GET') { + return Promise.resolve({ + result: { + html: cachedHtml, + metadata: { + headings: [], + codeBlocks: [], + mermaidBlocks: [], + images: [], + links: [], + wordCount: 0, + frontmatter: null, + }, + highlightedBlocks: new Map(), + mermaidSVGs: new Map(), + timestamp: Date.now(), + cacheKey: 'cached-key', + }, + }); + } + return Promise.resolve({}); + }); + const mockMessaging: MessagingAdapter = { send: sendMock }; + + const pipeline = new RenderPipeline({ messaging: mockMessaging }); + + const progressUpdates: string[] = []; + pipeline.onProgress((p) => progressUpdates.push(p.stage)); + + await pipeline.render({ + container, + markdown: '# Hello', + filePath: '/test.md', + useCache: true, + useWorkers: false, + }); + + expect(container.innerHTML).toBe(cachedHtml); + expect(progressUpdates).toContain('cached'); + }); + + it('sends CACHE_SET after successful render', async () => { + const sendMock = vi.fn().mockImplementation((msg: IPCMessage) => { + if (msg.type === 'CACHE_GENERATE_KEY') { + return Promise.resolve({ key: 'new-cache-key' }); + } + if (msg.type === 'CACHE_GET') { + return Promise.resolve({ result: null }); + } + if (msg.type === 'CACHE_SET') { + return Promise.resolve({}); + } + return Promise.resolve({}); + }); + const mockMessaging: MessagingAdapter = { send: sendMock }; + + const pipeline = new RenderPipeline({ messaging: mockMessaging }); + + await pipeline.render({ + container, + markdown: '# Hello', + filePath: '/test.md', + useCache: true, + useWorkers: false, + }); + + expect(sendMock).toHaveBeenCalledWith(expect.objectContaining({ type: 'CACHE_SET' })); + }); + }); + + describe('without MessagingAdapter (graceful degradation)', () => { + it('renders markdown without an adapter', async () => { + const pipeline = new RenderPipeline(); + + await pipeline.render({ + container, + markdown: '# Hello World', + useCache: true, + filePath: '/test.md', + useWorkers: false, + }); + + // Should still render successfully (just skips caching) + expect(container.innerHTML).toBeTruthy(); + expect(container.innerHTML).toContain('Hello World'); + }); + + it('does not throw when useCache is true but no adapter', async () => { + const pipeline = new RenderPipeline(); + + await expect( + pipeline.render({ + container, + markdown: '**Bold text**', + filePath: '/test.md', + useCache: true, + useWorkers: false, + }) + ).resolves.not.toThrow(); + + expect(container.innerHTML).toContain('Bold text'); + }); + + it('skips all caching operations without an adapter', async () => { + const pipeline = new RenderPipeline(); + + const progressUpdates: string[] = []; + pipeline.onProgress((p) => progressUpdates.push(p.stage)); + + await pipeline.render({ + container, + markdown: '# Test', + filePath: '/test.md', + useCache: true, + useWorkers: false, + }); + + // Should NOT have a 'cached' stage (no adapter means no cache hit) + expect(progressUpdates).not.toContain('cached'); + // Should still complete + expect(progressUpdates).toContain('complete'); + }); + + it('renders without filePath and without adapter', async () => { + const pipeline = new RenderPipeline(); + + await pipeline.render({ + container, + markdown: '# No file path', + useWorkers: false, + }); + + expect(container.innerHTML).toContain('No file path'); + }); + }); + + describe('core pipeline features', () => { + it('supports progress callbacks', async () => { + const pipeline = new RenderPipeline(); + const stages: string[] = []; + + pipeline.onProgress((p) => stages.push(p.stage)); + + await pipeline.render({ + container, + markdown: '# Test', + useWorkers: false, + useCache: false, + }); + + expect(stages).toContain('parsing'); + expect(stages).toContain('complete'); + }); + + it('supports unsubscribing from progress', async () => { + const pipeline = new RenderPipeline(); + const stages: string[] = []; + + const unsub = pipeline.onProgress((p) => stages.push(p.stage)); + unsub(); + + await pipeline.render({ + container, + markdown: '# Test', + useWorkers: false, + useCache: false, + }); + + expect(stages).toHaveLength(0); + }); + + it('cancelRender stops the pipeline', async () => { + const pipeline = new RenderPipeline(); + + // Cancel immediately + pipeline.cancelRender(); + + await pipeline.render({ + container, + markdown: '# Test', + useWorkers: false, + useCache: false, + }); + + // Container should be empty since render was cancelled at the start + // (after parsing, before sanitizing) + // The exact behavior depends on timing, but it should not throw + }); + + it('getLastCommentParseResult returns null before rendering', () => { + const pipeline = new RenderPipeline(); + expect(pipeline.getLastCommentParseResult()).toBeNull(); + }); + }); +}); diff --git a/packages/core/src/__tests__/smoke.test.ts b/packages/core/src/__tests__/smoke.test.ts new file mode 100644 index 0000000..41f6435 --- /dev/null +++ b/packages/core/src/__tests__/smoke.test.ts @@ -0,0 +1,7 @@ +import { VERSION } from '../index'; + +describe('@mdview/core', () => { + it('should export VERSION', () => { + expect(VERSION).toBe('0.0.1'); + }); +}); diff --git a/packages/core/src/__tests__/theme-engine.test.ts b/packages/core/src/__tests__/theme-engine.test.ts new file mode 100644 index 0000000..97afbb1 --- /dev/null +++ b/packages/core/src/__tests__/theme-engine.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Theme } from '../types/index'; +import type { StorageAdapter } from '../adapters'; +import { NoopStorageAdapter } from '../adapters'; +import githubLight from '../themes/github-light'; +import githubDark from '../themes/github-dark'; + +// Import the class under test +import { ThemeEngine } from '../theme-engine'; + +describe('ThemeEngine', () => { + let engine: ThemeEngine; + + beforeEach(() => { + engine = new ThemeEngine(); + }); + + describe('loadTheme', () => { + it('loads a theme by name', async () => { + const theme = await engine.loadTheme('github-light'); + expect(theme.name).toBe('github-light'); + expect(theme.variant).toBe('light'); + }); + + it('caches loaded themes', async () => { + const first = await engine.loadTheme('github-dark'); + const second = await engine.loadTheme('github-dark'); + expect(first).toBe(second); // same reference + }); + + it('falls back to github-light for unknown themes', async () => { + // 'test-theme' is listed in ThemeName type but has no module + const theme = await engine.loadTheme('test-theme'); + expect(theme.name).toBe('github-light'); + }); + }); + + describe('compileToCSSVariables', () => { + it('compiles theme colors to CSS variables', () => { + const vars = engine.compileToCSSVariables(githubLight); + expect(vars['--md-bg']).toBe(githubLight.colors.background); + expect(vars['--md-fg']).toBe(githubLight.colors.foreground); + expect(vars['--md-primary']).toBe(githubLight.colors.primary); + }); + + it('compiles typography to CSS variables', () => { + const vars = engine.compileToCSSVariables(githubLight); + expect(vars['--md-font-family']).toBe(githubLight.typography.fontFamily); + expect(vars['--md-font-family-code']).toBe(githubLight.typography.codeFontFamily); + expect(vars['--md-line-height']).toBe(githubLight.typography.baseLineHeight.toString()); + }); + + it('compiles spacing to CSS variables', () => { + const vars = engine.compileToCSSVariables(githubLight); + expect(vars['--md-block-margin']).toBe(githubLight.spacing.blockMargin); + expect(vars['--md-paragraph-margin']).toBe(githubLight.spacing.paragraphMargin); + }); + + it('applies typography overrides', () => { + const vars = engine.compileToCSSVariables(githubLight, { + fontFamily: 'Custom Sans', + codeFontFamily: 'Custom Mono', + baseLineHeight: 2.0, + }); + expect(vars['--md-font-family']).toBe('Custom Sans'); + expect(vars['--md-font-family-code']).toBe('Custom Mono'); + expect(vars['--md-line-height']).toBe('2'); + }); + }); + + describe('with StorageAdapter', () => { + it('applies theme correctly with mock StorageAdapter providing overrides', async () => { + const storage = new NoopStorageAdapter(); + await storage.setSync({ + preferences: { + fontFamily: 'Inter', + codeFontFamily: 'Fira Code', + lineHeight: 1.8, + maxWidth: 1200, + }, + }); + + const engineWithStorage = new ThemeEngine(storage); + const overrides = await engineWithStorage.getStorageOverrides(); + + expect(overrides.fontFamily).toBe('Inter'); + expect(overrides.codeFontFamily).toBe('Fira Code'); + expect(overrides.baseLineHeight).toBe(1.8); + expect(overrides.maxWidth).toBe(1200); + }); + + it('applies useMaxWidth override from storage', async () => { + const storage = new NoopStorageAdapter(); + await storage.setSync({ + preferences: { + useMaxWidth: true, + }, + }); + + const engineWithStorage = new ThemeEngine(storage); + const overrides = await engineWithStorage.getStorageOverrides(); + + expect(overrides.useMaxWidth).toBe(true); + }); + }); + + describe('without StorageAdapter (graceful degradation)', () => { + it('works without adapter — uses theme defaults only', () => { + const engineNoAdapter = new ThemeEngine(); + const vars = engineNoAdapter.compileToCSSVariables(githubLight); + + // Should use theme defaults, not any overrides + expect(vars['--md-font-family']).toBe(githubLight.typography.fontFamily); + expect(vars['--md-font-family-code']).toBe(githubLight.typography.codeFontFamily); + expect(vars['--md-line-height']).toBe(githubLight.typography.baseLineHeight.toString()); + }); + + it('getStorageOverrides returns empty object without adapter', async () => { + const engineNoAdapter = new ThemeEngine(); + const overrides = await engineNoAdapter.getStorageOverrides(); + + expect(overrides).toEqual({}); + }); + }); + + describe('font overrides from storage are applied', () => { + it('produces CSS variables with storage font overrides', async () => { + const storage = new NoopStorageAdapter(); + await storage.setSync({ + preferences: { + fontFamily: 'Georgia', + codeFontFamily: 'JetBrains Mono', + lineHeight: 1.6, + }, + }); + + const engineWithStorage = new ThemeEngine(storage); + const overrides = await engineWithStorage.getStorageOverrides(); + const vars = engineWithStorage.compileToCSSVariables(githubDark, overrides); + + expect(vars['--md-font-family']).toBe('Georgia'); + expect(vars['--md-font-family-code']).toBe('JetBrains Mono'); + expect(vars['--md-line-height']).toBe('1.6'); + // Non-overridden values should still come from theme + expect(vars['--md-bg']).toBe(githubDark.colors.background); + }); + }); + + describe('getAvailableThemes', () => { + it('returns list of available themes', () => { + const themes = engine.getAvailableThemes(); + expect(themes.length).toBe(8); + const names = themes.map((t) => t.name); + expect(names).toContain('github-light'); + expect(names).toContain('github-dark'); + expect(names).toContain('catppuccin-mocha'); + }); + + it('each theme has name, displayName, and variant', () => { + const themes = engine.getAvailableThemes(); + for (const t of themes) { + expect(t.name).toBeTruthy(); + expect(t.displayName).toBeTruthy(); + expect(['light', 'dark']).toContain(t.variant); + } + }); + }); + + describe('getCurrentTheme', () => { + it('returns null before any theme is set', () => { + expect(engine.getCurrentTheme()).toBeNull(); + }); + + it('returns the theme after setCurrentTheme', () => { + engine.setCurrentTheme(githubLight); + expect(engine.getCurrentTheme()).toBe(githubLight); + }); + }); +}); diff --git a/packages/core/src/__tests__/themes.test.ts b/packages/core/src/__tests__/themes.test.ts new file mode 100644 index 0000000..af105c8 --- /dev/null +++ b/packages/core/src/__tests__/themes.test.ts @@ -0,0 +1,60 @@ +import type { Theme } from '../types/index'; + +import catppuccinFrappe from '../themes/catppuccin-frappe'; +import catppuccinLatte from '../themes/catppuccin-latte'; +import catppuccinMacchiato from '../themes/catppuccin-macchiato'; +import catppuccinMocha from '../themes/catppuccin-mocha'; +import githubDark from '../themes/github-dark'; +import githubLight from '../themes/github-light'; +import monokaiPro from '../themes/monokai-pro'; +import monokai from '../themes/monokai'; + +const requiredFields: (keyof Theme)[] = [ + 'name', + 'displayName', + 'variant', + 'colors', + 'typography', + 'spacing', + 'syntaxTheme', + 'mermaidTheme', +]; + +const themes: { label: string; theme: Theme }[] = [ + { label: 'catppuccin-frappe', theme: catppuccinFrappe }, + { label: 'catppuccin-latte', theme: catppuccinLatte }, + { label: 'catppuccin-macchiato', theme: catppuccinMacchiato }, + { label: 'catppuccin-mocha', theme: catppuccinMocha }, + { label: 'github-dark', theme: githubDark }, + { label: 'github-light', theme: githubLight }, + { label: 'monokai-pro', theme: monokaiPro }, + { label: 'monokai', theme: monokai }, +]; + +describe('@mdview/core themes', () => { + it.each(themes)('$label has all required Theme fields', ({ theme }) => { + for (const field of requiredFields) { + expect(theme).toHaveProperty(field); + expect(theme[field]).toBeDefined(); + } + }); + + it.each(themes)('$label has a valid variant', ({ theme }) => { + expect(['light', 'dark']).toContain(theme.variant); + }); + + it.each(themes)('$label has name matching its label', ({ label, theme }) => { + expect(theme.name).toBe(label); + }); + + it.each(themes)('$label has non-empty displayName', ({ theme }) => { + expect(theme.displayName.length).toBeGreaterThan(0); + }); + + it.each(themes)('$label has valid mermaidTheme', ({ theme }) => { + expect(theme.mermaidTheme).toHaveProperty('theme'); + expect(theme.mermaidTheme).toHaveProperty('themeVariables'); + expect(theme.mermaidTheme.themeVariables).toHaveProperty('primaryColor'); + expect(theme.mermaidTheme.themeVariables).toHaveProperty('primaryTextColor'); + }); +}); diff --git a/packages/core/src/__tests__/types.test.ts b/packages/core/src/__tests__/types.test.ts new file mode 100644 index 0000000..907d131 --- /dev/null +++ b/packages/core/src/__tests__/types.test.ts @@ -0,0 +1,140 @@ +import type { + Theme, + ThemeName, + AppState, + ConversionResult, + Comment, + CommentParseResult, + ExportFormat, + PaperSize, + ContentNode, + CachedResult, + WorkerTask, +} from '../types/index'; + +describe('@mdview/core types', () => { + it('should allow creating a Theme object', () => { + const theme: Theme = { + name: 'github-light', + displayName: 'GitHub Light', + variant: 'light', + author: 'test', + version: '1.0.0', + colors: { + background: '#fff', + backgroundSecondary: '#f6f8fa', + backgroundTertiary: '#eee', + foreground: '#24292e', + foregroundSecondary: '#586069', + foregroundMuted: '#6a737d', + primary: '#0366d6', + secondary: '#6f42c1', + accent: '#e36209', + heading: '#24292e', + link: '#0366d6', + linkHover: '#0366d6', + linkVisited: '#6f42c1', + codeBackground: '#f6f8fa', + codeText: '#24292e', + codeKeyword: '#d73a49', + codeString: '#032f62', + codeComment: '#6a737d', + codeFunction: '#6f42c1', + border: '#e1e4e8', + borderLight: '#eaecef', + borderHeavy: '#d1d5da', + selection: '#0366d633', + highlight: '#fff3cd', + shadow: 'rgba(27,31,35,0.15)', + success: '#28a745', + warning: '#ffd33d', + error: '#d73a49', + info: '#0366d6', + commentHighlight: '#fff3cd', + commentHighlightResolved: '#dcffe4', + commentCardBg: '#ffffff', + }, + typography: { + fontFamily: 'system-ui', + codeFontFamily: 'monospace', + baseFontSize: '16px', + baseLineHeight: 1.6, + h1Size: '2em', + h2Size: '1.5em', + h3Size: '1.25em', + h4Size: '1em', + h5Size: '0.875em', + h6Size: '0.85em', + fontWeightNormal: 400, + fontWeightBold: 600, + headingFontWeight: 600, + }, + spacing: { + blockMargin: '16px', + paragraphMargin: '16px', + listItemMargin: '4px', + headingMargin: '24px', + codeBlockPadding: '16px', + tableCellPadding: '6px 13px', + }, + syntaxTheme: 'github', + mermaidTheme: { + theme: 'default', + themeVariables: { + primaryColor: '#0366d6', + primaryTextColor: '#24292e', + primaryBorderColor: '#e1e4e8', + lineColor: '#586069', + secondaryColor: '#6f42c1', + tertiaryColor: '#e36209', + background: '#fff', + mainBkg: '#fff', + }, + }, + }; + expect(theme.name).toBe('github-light'); + expect(theme.variant).toBe('light'); + }); + + it('should allow ThemeName literals', () => { + const names: ThemeName[] = [ + 'github-light', + 'github-dark', + 'catppuccin-latte', + 'catppuccin-frappe', + 'catppuccin-macchiato', + 'catppuccin-mocha', + 'monokai', + 'monokai-pro', + ]; + expect(names).toHaveLength(8); + }); + + it('should allow ExportFormat and PaperSize types', () => { + const format: ExportFormat = 'docx'; + const size: PaperSize = 'A4'; + expect(format).toBe('docx'); + expect(size).toBe('A4'); + }); + + it('should allow Comment type with all fields', () => { + const comment: Comment = { + id: 'comment-1', + selectedText: 'test', + body: 'a comment', + author: 'tester', + date: new Date().toISOString(), + resolved: false, + context: { + line: 1, + section: 'Introduction', + sectionLevel: 1, + breadcrumb: ['Introduction'], + }, + tags: ['nit', 'suggestion'], + replies: [{ id: 'reply-1', author: 'reviewer', body: 'agreed', date: new Date().toISOString() }], + reactions: { '👍': ['tester'] }, + }; + expect(comment.id).toBe('comment-1'); + }); +}); diff --git a/packages/core/src/__tests__/utils.test.ts b/packages/core/src/__tests__/utils.test.ts new file mode 100644 index 0000000..75665b1 --- /dev/null +++ b/packages/core/src/__tests__/utils.test.ts @@ -0,0 +1,183 @@ +import { + splitIntoSections, + splitIntoChunks, + getInitialSections, + type MarkdownSection, +} from '../utils/section-splitter'; +import { FilenameGenerator } from '../utils/filename-generator'; +import { stripTableOfContents } from '../utils/toc-stripper'; + +describe('section-splitter', () => { + describe('splitIntoSections', () => { + it('should split markdown by headings', () => { + const md = '# Heading 1\nContent 1\n## Heading 2\nContent 2'; + const sections = splitIntoSections(md); + expect(sections).toHaveLength(2); + expect(sections[0].heading).toBe('Heading 1'); + expect(sections[1].heading).toBe('Heading 2'); + }); + + it('should return a single section for markdown without headings', () => { + const md = 'Just some text\nwith multiple lines'; + const sections = splitIntoSections(md); + expect(sections).toHaveLength(1); + expect(sections[0].heading).toBeUndefined(); + }); + + it('should not split on headings inside code fences', () => { + const md = '# Real Heading\n```\n# Not a heading\n```\nMore content'; + const sections = splitIntoSections(md); + expect(sections).toHaveLength(1); + expect(sections[0].heading).toBe('Real Heading'); + }); + + it('should assign sequential section IDs', () => { + const md = '# A\ntext\n## B\ntext\n### C\ntext'; + const sections = splitIntoSections(md); + expect(sections.map((s) => s.id)).toEqual(['section-0', 'section-1', 'section-2']); + }); + + it('should track line numbers', () => { + const md = '# First\nline 1\nline 2\n## Second\nline 3'; + const sections = splitIntoSections(md); + expect(sections[0].startLine).toBe(0); + expect(sections[0].endLine).toBe(2); + expect(sections[1].startLine).toBe(3); + expect(sections[1].endLine).toBe(4); + }); + }); + + describe('splitIntoChunks', () => { + it('should split large content into chunks', () => { + const line = 'x'.repeat(100) + '\n'; + const md = line.repeat(100); + const sections = splitIntoChunks(md, 500); + expect(sections.length).toBeGreaterThan(1); + sections.forEach((s) => { + expect(s.id).toMatch(/^chunk-\d+$/); + }); + }); + + it('should return single chunk for small content', () => { + const md = 'small text'; + const sections = splitIntoChunks(md); + expect(sections).toHaveLength(1); + }); + }); + + describe('getInitialSections', () => { + const makeSections = (count: number): MarkdownSection[] => + Array.from({ length: count }, (_, i) => ({ + markdown: `Section ${i}`, + startLine: i * 2, + endLine: i * 2 + 1, + heading: `Heading ${i}`, + level: 1, + id: `section-${i}`, + })); + + it('should return up to maxSections sections', () => { + const sections = makeSections(10); + const initial = getInitialSections(sections, { maxSections: 2 }); + expect(initial).toHaveLength(2); + }); + + it('should return at least one section even if maxSize is very small', () => { + const sections = makeSections(5); + const initial = getInitialSections(sections, { maxSize: 1 }); + expect(initial).toHaveLength(1); + }); + + it('should return sections up to a specific ID', () => { + const sections = makeSections(5); + const initial = getInitialSections(sections, { upToSectionId: 'section-2' }); + // Should include sections 0, 1, 2 plus one more for context + expect(initial).toHaveLength(4); + }); + }); +}); + +describe('filename-generator', () => { + it('should generate a filename with default template', () => { + const result = FilenameGenerator.generate({ title: 'My Document', extension: 'pdf' }); + expect(result).toBe('my-document.pdf'); + }); + + it('should sanitize illegal characters', () => { + const result = FilenameGenerator.generate({ title: 'file<>:"/\\|?*name', extension: 'txt' }); + expect(result).toBe('filename.txt'); + }); + + it('should handle empty title', () => { + const result = FilenameGenerator.generate({ title: '', extension: 'docx' }); + expect(result).toBe('document.docx'); + }); + + it('should use a custom template with date vars', () => { + const result = FilenameGenerator.generate({ + title: 'Report', + extension: 'pdf', + template: '{title}-{year}', + }); + const year = new Date().getFullYear(); + expect(result).toBe(`report-${year}.pdf`); + }); + + it('should truncate overly long filenames', () => { + const longTitle = 'a'.repeat(300); + const result = FilenameGenerator.generate({ title: longTitle, extension: 'pdf' }); + // 200 char max + '.pdf' = 204 + expect(result.length).toBeLessThanOrEqual(204); + }); +}); + +describe('toc-stripper', () => { + it('should return original markdown when no TOC is found', () => { + const md = '# Hello\nSome content'; + const result = stripTableOfContents(md); + expect(result.tocFound).toBe(false); + expect(result.markdown).toBe(md); + }); + + it('should strip a TOC section with anchor links', () => { + const md = [ + '# My Doc', + '', + '## Table of Contents', + '- [Section 1](#section-1)', + '- [Section 2](#section-2)', + '- [Section 3](#section-3)', + '', + '## Section 1', + 'Content 1', + ].join('\n'); + const result = stripTableOfContents(md); + expect(result.tocFound).toBe(true); + expect(result.markdown).not.toContain('Table of Contents'); + expect(result.markdown).toContain('Section 1'); + expect(result.markdown).toContain('Content 1'); + }); + + it('should not strip a heading named Contents without anchor links', () => { + const md = [ + '## Contents', + 'This is just regular content, not a TOC.', + '', + '## Other', + 'More content', + ].join('\n'); + const result = stripTableOfContents(md); + expect(result.tocFound).toBe(false); + }); + + it('should accept an optional logger', () => { + const logs: string[] = []; + const logger = { + debug: (_ctx: string, msg: string) => logs.push(msg), + info: (_ctx: string, msg: string) => logs.push(msg), + }; + const md = '# Doc\nNo TOC here'; + stripTableOfContents(md, logger); + expect(logs.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/core/src/adapters.ts b/packages/core/src/adapters.ts new file mode 100644 index 0000000..5c1c40f --- /dev/null +++ b/packages/core/src/adapters.ts @@ -0,0 +1,208 @@ +/** + * Platform adapter interfaces for @mdview/core. + * + * These abstractions replace direct Chrome extension API calls, + * allowing the core rendering engine to run in any environment + * (Chrome extension, Electron, Node.js, tests). + */ + +import type { AppState, ThemeName, CachedResult } from './types/index'; + +// --------------------------------------------------------------------------- +// StorageAdapter — replaces chrome.storage.sync/local +// --------------------------------------------------------------------------- + +export interface StorageAdapter { + /** Read values by key from persistent (synced) storage */ + getSync(keys: string | string[]): Promise>; + /** Write values to persistent (synced) storage */ + setSync(data: Record): Promise; + /** Read values by key from local-only storage */ + getLocal(keys: string | string[]): Promise>; + /** Write values to local-only storage */ + setLocal(data: Record): Promise; +} + +// --------------------------------------------------------------------------- +// MessagingAdapter — replaces chrome.runtime.sendMessage +// --------------------------------------------------------------------------- + +/** Message envelope used for cross-context IPC */ +export interface IPCMessage { + type: string; + payload?: unknown; +} + +export interface MessagingAdapter { + /** Send a message and await a response */ + send(message: IPCMessage): Promise; +} + +// --------------------------------------------------------------------------- +// FileAdapter — replaces native host file I/O and file watching +// --------------------------------------------------------------------------- + +export interface FileWriteResult { + success: boolean; + error?: string; +} + +export interface FileChangeInfo { + changed: boolean; + newHash?: string; + error?: string; +} + +export interface FileAdapter { + /** Write content to a file at the given path */ + writeFile(path: string, content: string): Promise; + /** Read content from a file at the given path */ + readFile(path: string): Promise; + /** Check if a file has changed since the last known hash */ + checkChanged(url: string, lastHash: string): Promise; + /** Watch a file/directory for changes, returns an unsubscribe function */ + watch(path: string, callback: () => void): () => void; +} + +// --------------------------------------------------------------------------- +// IdentityAdapter — replaces GET_USERNAME native host message +// --------------------------------------------------------------------------- + +export interface IdentityAdapter { + /** Get the current user's display name or system username */ + getUsername(): Promise; +} + +// --------------------------------------------------------------------------- +// ExportAdapter — replaces browser download / printToPDF +// --------------------------------------------------------------------------- + +export interface ExportSaveOptions { + /** Suggested filename */ + filename: string; + /** MIME type of the content */ + mimeType: string; + /** File content as Blob or ArrayBuffer */ + data: Blob | ArrayBuffer; +} + +export interface ExportAdapter { + /** Save/download a file (browser download or native save dialog) */ + saveFile(options: ExportSaveOptions): Promise; + /** Print the current view to PDF (Electron: webContents.printToPDF) */ + printToPDF?(options?: { + pageSize?: string; + margins?: string; + landscape?: boolean; + }): Promise; +} + +// --------------------------------------------------------------------------- +// PlatformAdapters — combined context for dependency injection +// --------------------------------------------------------------------------- + +export interface PlatformAdapters { + storage: StorageAdapter; + messaging: MessagingAdapter; + file: FileAdapter; + identity: IdentityAdapter; + export: ExportAdapter; +} + +// --------------------------------------------------------------------------- +// No-op defaults for graceful degradation +// --------------------------------------------------------------------------- + +/** Storage adapter that stores nothing (in-memory, ephemeral) */ +export class NoopStorageAdapter implements StorageAdapter { + private syncStore = new Map(); + private localStore = new Map(); + + async getSync(keys: string | string[]): Promise> { + const keyList = Array.isArray(keys) ? keys : [keys]; + const result: Record = {}; + for (const key of keyList) { + if (this.syncStore.has(key)) { + result[key] = this.syncStore.get(key); + } + } + return result; + } + + async setSync(data: Record): Promise { + for (const [key, value] of Object.entries(data)) { + this.syncStore.set(key, value); + } + } + + async getLocal(keys: string | string[]): Promise> { + const keyList = Array.isArray(keys) ? keys : [keys]; + const result: Record = {}; + for (const key of keyList) { + if (this.localStore.has(key)) { + result[key] = this.localStore.get(key); + } + } + return result; + } + + async setLocal(data: Record): Promise { + for (const [key, value] of Object.entries(data)) { + this.localStore.set(key, value); + } + } +} + +/** Messaging adapter that returns empty responses */ +export class NoopMessagingAdapter implements MessagingAdapter { + async send(_message: IPCMessage): Promise { + return {}; + } +} + +/** File adapter that rejects all operations */ +export class NoopFileAdapter implements FileAdapter { + async writeFile(_path: string, _content: string): Promise { + return { success: false, error: 'No file adapter configured' }; + } + + async readFile(_path: string): Promise { + throw new Error('No file adapter configured'); + } + + async checkChanged( + _url: string, + _lastHash: string + ): Promise { + return { changed: false }; + } + + watch(_path: string, _callback: () => void): () => void { + return () => {}; + } +} + +/** Identity adapter that returns empty username */ +export class NoopIdentityAdapter implements IdentityAdapter { + async getUsername(): Promise { + return ''; + } +} + +/** Export adapter that does nothing */ +export class NoopExportAdapter implements ExportAdapter { + async saveFile(_options: ExportSaveOptions): Promise { + // No-op in environments without export capability + } +} + +/** Create a PlatformAdapters bundle with all no-op defaults */ +export function createNoopAdapters(): PlatformAdapters { + return { + storage: new NoopStorageAdapter(), + messaging: new NoopMessagingAdapter(), + file: new NoopFileAdapter(), + identity: new NoopIdentityAdapter(), + export: new NoopExportAdapter(), + }; +} diff --git a/src/core/cache-manager.ts b/packages/core/src/cache-manager.ts similarity index 96% rename from src/core/cache-manager.ts rename to packages/core/src/cache-manager.ts index a4e1cec..8b297cb 100644 --- a/src/core/cache-manager.ts +++ b/packages/core/src/cache-manager.ts @@ -3,8 +3,13 @@ * In-memory cache with SHA-256 hash-based keys for rendered markdown content */ -import type { CachedResult, CacheEntry, ThemeName } from '../types'; -import { debug } from '../utils/debug-logger'; +import type { CachedResult, CacheEntry, ThemeName } from './types/index'; + +// Lightweight debug facade — no Chrome dependency +const debug = { + log: (..._args: unknown[]) => {}, + info: (..._args: unknown[]) => {}, +}; export interface CacheOptions { maxSize?: number; // Max cache entries (default: 50) diff --git a/src/comments/annotation-parser.ts b/packages/core/src/comments/annotation-parser.ts similarity index 99% rename from src/comments/annotation-parser.ts rename to packages/core/src/comments/annotation-parser.ts index d3a0fb2..7789d2b 100644 --- a/src/comments/annotation-parser.ts +++ b/packages/core/src/comments/annotation-parser.ts @@ -9,7 +9,7 @@ * separator with footnote definitions. */ -import type { Comment, CommentContext, CommentParseResult, CommentReply } from '../types'; +import type { Comment, CommentContext, CommentParseResult, CommentReply } from '../types/index'; import { parseComments } from './comment-parser'; const V1_SENTINEL = ''; diff --git a/src/comments/annotation-serializer.ts b/packages/core/src/comments/annotation-serializer.ts similarity index 99% rename from src/comments/annotation-serializer.ts rename to packages/core/src/comments/annotation-serializer.ts index cbc563f..623eec8 100644 --- a/src/comments/annotation-serializer.ts +++ b/packages/core/src/comments/annotation-serializer.ts @@ -8,7 +8,7 @@ * signatures. On v1 input, automatically migrates to v2 format. */ -import type { Comment, CommentReply } from '../types'; +import type { Comment, CommentReply } from '../types/index'; import type { SourcePositionMap, SelectionContext } from './source-position-map'; import { findInsertionPoint } from './source-position-map'; import { parseComments } from './comment-parser'; diff --git a/src/comments/comment-context.ts b/packages/core/src/comments/comment-context.ts similarity index 91% rename from src/comments/comment-context.ts rename to packages/core/src/comments/comment-context.ts index 901fd8e..02b0634 100644 --- a/src/comments/comment-context.ts +++ b/packages/core/src/comments/comment-context.ts @@ -7,17 +7,14 @@ * the raw file can immediately understand where each comment is anchored. */ -import type { CommentContext } from '../types'; +import type { CommentContext } from '../types/index'; import { splitIntoSections, type MarkdownSection } from '../utils/section-splitter'; /** * Compute positional context for a comment at a given character offset * in the content section of markdown (above the comment separator). */ -export function computeCommentContext( - contentMarkdown: string, - charOffset: number -): CommentContext { +export function computeCommentContext(contentMarkdown: string, charOffset: number): CommentContext { const line = offsetToLine(contentMarkdown, charOffset); const sections = splitIntoSections(contentMarkdown); const { section, sectionLevel, breadcrumb } = findSectionContext(sections, line); @@ -83,10 +80,7 @@ function findSectionContext( * when encountering a heading at level N, pop all headings at level >= N, * then push. The final stack is the breadcrumb. */ -function buildBreadcrumb( - sections: MarkdownSection[], - target: MarkdownSection -): string[] { +function buildBreadcrumb(sections: MarkdownSection[], target: MarkdownSection): string[] { const stack: Array<{ heading: string; level: number }> = []; for (const s of sections) { diff --git a/src/comments/comment-highlight.ts b/packages/core/src/comments/comment-highlight.ts similarity index 99% rename from src/comments/comment-highlight.ts rename to packages/core/src/comments/comment-highlight.ts index 84e2241..c22cd1a 100644 --- a/src/comments/comment-highlight.ts +++ b/packages/core/src/comments/comment-highlight.ts @@ -1,4 +1,4 @@ -import type { Comment } from '../types'; +import type { Comment } from '../types/index'; const HIGHLIGHT_CLASS = 'mdview-comment-highlight'; const COMMENT_ID_ATTR = 'data-comment-id'; diff --git a/src/comments/comment-manager.ts b/packages/core/src/comments/comment-manager.ts similarity index 93% rename from src/comments/comment-manager.ts rename to packages/core/src/comments/comment-manager.ts index b4c9daf..3fa1ec2 100644 --- a/src/comments/comment-manager.ts +++ b/packages/core/src/comments/comment-manager.ts @@ -1,12 +1,21 @@ /** - * Comment Manager + * Comment Manager (core) * * CRUD orchestrator that connects the comment parser, serializer, UI, - * text highlighter, and native host file-writing into a single cohesive - * controller. All comment lifecycle operations flow through this class. + * text highlighter, and file-writing into a single cohesive controller. + * All comment lifecycle operations flow through this class. + * + * This core version uses FileAdapter and IdentityAdapter instead of + * chrome.runtime.sendMessage, allowing it to work in any environment + * (Chrome extension, Electron, Node.js, tests). + * + * Both adapters are optional — the manager degrades gracefully: + * - Without FileAdapter: file writes are silently skipped + * - Without IdentityAdapter: empty username is used as fallback */ -import type { Comment, CommentParseResult, AppState, CommentTag } from '../types'; +import type { Comment, CommentParseResult, AppState, CommentTag } from '../types/index'; +import type { FileAdapter, IdentityAdapter } from '../adapters'; import { CommentUI } from './comment-ui'; import { CommentHighlighter } from './comment-highlight'; import { parseComments } from './annotation-parser'; @@ -25,6 +34,11 @@ import { buildSourceMap, findInsertionPoint } from './source-position-map'; import type { SourcePositionMap, SelectionContext } from './source-position-map'; import { computeCommentContext } from './comment-context'; +export interface CommentManagerAdapters { + file?: FileAdapter; + identity?: IdentityAdapter; +} + export class CommentManager { private comments: Comment[] = []; private rawMarkdown: string = ''; @@ -36,6 +50,9 @@ export class CommentManager { private sourceMap: SourcePositionMap | null = null; private pendingContext: SelectionContext | null = null; + private readonly fileAdapter: FileAdapter | null; + private readonly identityAdapter: IdentityAdapter | null; + private windowListeners: Array<{ event: string; handler: EventListener; @@ -51,6 +68,11 @@ export class CommentManager { * to prevent the auto-reload from triggering on our own write. */ private static readonly WRITE_GUARD_DELAY = 2000; + constructor(adapters?: CommentManagerAdapters) { + this.fileAdapter = adapters?.file ?? null; + this.identityAdapter = adapters?.identity ?? null; + } + /** * Parse existing comments from raw markdown, set up UI and highlights, * and wire event listeners for user actions. @@ -64,16 +86,15 @@ export class CommentManager { this.filePath = filePath; this.authorName = preferences.commentAuthor ?? ''; - // If no author configured, try to get system username from native host - if (!this.authorName) { + // If no author configured, try to get username from identity adapter + if (!this.authorName && this.identityAdapter) { try { - const resp = await chrome.runtime.sendMessage({ type: 'GET_USERNAME' }) as - { username?: string } | undefined; - if (resp?.username) { - this.authorName = resp.username; + const username = await this.identityAdapter.getUsername(); + if (username) { + this.authorName = username; } } catch { - // Native host may not be installed — leave author empty + // Identity adapter may fail — leave author empty } } @@ -335,7 +356,7 @@ export class CommentManager { this.repositionAllCards(); } - // Write to file in the background + // Write to file via adapter (if available) try { await this.writeFile(updatedMarkdown); if (this.ui) this.ui.showToast('Comment saved'); @@ -460,19 +481,21 @@ export class CommentManager { } /** - * Write content to the file via the native messaging host. + * Write content to the file via the FileAdapter. + * If no FileAdapter is configured, the write is silently skipped. */ private async writeFile(content: string): Promise { + if (!this.fileAdapter) { + // No file adapter — graceful degradation, skip write + return; + } + this.writeInProgress = true; try { - // Relay through service worker since content scripts can't use sendNativeMessage - const response = await chrome.runtime.sendMessage({ - type: 'WRITE_FILE', - payload: { path: this.filePath, content }, - }) as { success?: boolean; error?: string }; - - if (response?.error) { - throw new Error(response.error); + const result = await this.fileAdapter.writeFile(this.filePath, content); + + if (!result.success && result.error) { + throw new Error(result.error); } } finally { // Keep the guard active for a grace period so the auto-reload watcher diff --git a/src/comments/comment-parser.ts b/packages/core/src/comments/comment-parser.ts similarity index 99% rename from src/comments/comment-parser.ts rename to packages/core/src/comments/comment-parser.ts index e6c2b95..58ab9c5 100644 --- a/src/comments/comment-parser.ts +++ b/packages/core/src/comments/comment-parser.ts @@ -10,7 +10,7 @@ * v1 backward compatibility delegation from the annotation parser. */ -import type { Comment, CommentContext, CommentMetadata, CommentParseResult } from '../types'; +import type { Comment, CommentContext, CommentMetadata, CommentParseResult } from '../types/index'; const COMMENT_SEPARATOR = ''; const COMMENT_REF_PATTERN = /\[\^comment-(\w+)\]/g; diff --git a/src/comments/comment-serializer.ts b/packages/core/src/comments/comment-serializer.ts similarity index 99% rename from src/comments/comment-serializer.ts rename to packages/core/src/comments/comment-serializer.ts index 96a6a54..d2cc399 100644 --- a/src/comments/comment-serializer.ts +++ b/packages/core/src/comments/comment-serializer.ts @@ -16,7 +16,7 @@ * imported by production code. */ -import type { Comment, CommentMetadata, CommentReply } from '../types'; +import type { Comment, CommentMetadata, CommentReply } from '../types/index'; import type { SourcePositionMap, SelectionContext } from './source-position-map'; import { findInsertionPoint } from './source-position-map'; diff --git a/src/comments/comment-ui.ts b/packages/core/src/comments/comment-ui.ts similarity index 99% rename from src/comments/comment-ui.ts rename to packages/core/src/comments/comment-ui.ts index 31cc63f..6e99450 100644 --- a/src/comments/comment-ui.ts +++ b/packages/core/src/comments/comment-ui.ts @@ -5,7 +5,7 @@ * Dispatches custom events for all user actions. */ -import type { Comment, CommentTag, CommentReply, CommentReactions } from '../types'; +import type { Comment, CommentTag, CommentReply, CommentReactions } from '../types/index'; import { QUICK_EMOJIS, EMOJI_CATEGORIES, searchEmojis } from './emoji-data'; const ALL_TAGS: CommentTag[] = [ diff --git a/src/comments/emoji-data.ts b/packages/core/src/comments/emoji-data.ts similarity index 100% rename from src/comments/emoji-data.ts rename to packages/core/src/comments/emoji-data.ts diff --git a/src/comments/source-position-map.ts b/packages/core/src/comments/source-position-map.ts similarity index 100% rename from src/comments/source-position-map.ts rename to packages/core/src/comments/source-position-map.ts diff --git a/src/core/export-controller.ts b/packages/core/src/export-controller.ts similarity index 92% rename from src/core/export-controller.ts rename to packages/core/src/export-controller.ts index 544a45a..8a9edc1 100644 --- a/src/core/export-controller.ts +++ b/packages/core/src/export-controller.ts @@ -3,10 +3,10 @@ * Orchestrates the document export process */ -import type { ExportOptions, ExportProgress, ProgressCallback, ExportFormat } from '../types'; -import { ContentCollector } from '../utils/content-collector'; -import { SVGConverter } from '../utils/svg-converter'; -import { debug } from '../utils/debug-logger'; +import type { ExportOptions, ExportProgress, ProgressCallback, ExportFormat } from './types/index'; +import { ContentCollector } from './utils/content-collector'; +import { SVGConverter } from './utils/svg-converter'; +import { debug } from './utils/debug-logger'; /** * Controls the export process from content collection to file download @@ -30,7 +30,7 @@ export class ExportController { if (mermaidContainers.length > 0) { report('collecting', 2, 'Rendering diagrams...'); try { - const { mermaidRenderer } = await import('../renderers/mermaid-renderer'); + const { mermaidRenderer } = await import('./renderers/mermaid-renderer'); await mermaidRenderer.renderAllImmediate(container); } catch (error) { debug.warn('ExportController', 'Could not force-render Mermaid diagrams before export:', error); @@ -64,7 +64,7 @@ export class ExportController { report('generating', 55, 'Generating Word document...'); // Dynamic import to avoid loading docx unless needed - const { DOCXGenerator } = await import('../utils/docx-generator'); + const { DOCXGenerator } = await import('./utils/docx-generator'); const generator = new DOCXGenerator(); const blob = await generator.generate(content, images, { @@ -135,4 +135,3 @@ export class ExportController { ); // Limit length with fallback } } - diff --git a/src/core/frontmatter-extractor.ts b/packages/core/src/frontmatter-extractor.ts similarity index 100% rename from src/core/frontmatter-extractor.ts rename to packages/core/src/frontmatter-extractor.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..20f12bd --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,118 @@ +// @mdview/core - shared rendering engine +export const VERSION = '0.0.1'; + +// Types +export type * from './types/index'; + +// Themes +export { default as catppuccinFrappe } from './themes/catppuccin-frappe'; +export { default as catppuccinLatte } from './themes/catppuccin-latte'; +export { default as catppuccinMacchiato } from './themes/catppuccin-macchiato'; +export { default as catppuccinMocha } from './themes/catppuccin-mocha'; +export { default as githubDark } from './themes/github-dark'; +export { default as githubLight } from './themes/github-light'; +export { default as monokaiPro } from './themes/monokai-pro'; +export { default as monokai } from './themes/monokai'; + +// Comment parsers & serializers +// v2 annotation format (current) +export { detectFormat, parseAnnotations } from './comments/annotation-parser'; +export { + generateNextCommentId, + addComment, + addCommentAtOffset, + removeComment, + updateComment, + resolveComment, + updateCommentMetadata, + addReply, + toggleReaction, +} from './comments/annotation-serializer'; +// v1 comment format (legacy) +export { parseComments } from './comments/comment-parser'; +// Comment manager +export { CommentManager } from './comments/comment-manager'; +export type { CommentManagerAdapters } from './comments/comment-manager'; +// Comment UI +export { CommentHighlighter } from './comments/comment-highlight'; +export { CommentUI } from './comments/comment-ui'; +// Context & utilities +export * from './comments/comment-context'; +export * from './comments/source-position-map'; +export * from './comments/emoji-data'; + +// Utilities +export * from './utils/section-splitter'; +export * from './utils/filename-generator'; +export * from './utils/toc-stripper'; +export { FileScanner } from './utils/file-scanner'; +export type { WatchFileOptions } from './utils/file-scanner'; +export { DOMPurifierUtil, domPurifier } from './utils/dom-purifier'; +export { SkeletonRenderer } from './utils/skeleton-renderer'; +export type { SkeletonSection } from './utils/skeleton-renderer'; +export { ScrollManager } from './utils/scroll-manager'; + +// Markdown converter +export { MarkdownConverter, markdownConverter } from './markdown-converter'; +export type { ConvertOptions } from './markdown-converter'; + +// Cache manager +export { CacheManager, cacheManager } from './cache-manager'; +export type { CacheOptions } from './cache-manager'; + +// Frontmatter extractor +export { extractFrontmatter, renderFrontmatterHtml } from './frontmatter-extractor'; +export type { FrontmatterResult } from './frontmatter-extractor'; + +// Debug logger +export { DebugLogger, createDebugLogger, createDebug, debug } from './utils/debug-logger'; +export type { DebugHelpers } from './utils/debug-logger'; + +// Theme engine +export { ThemeEngine } from './theme-engine'; +export type { ThemeInfo, ThemeOverrides } from './theme-engine'; + +// Render pipeline +export { RenderPipeline } from './render-pipeline'; +export type { + RenderOptions, + RenderProgress, + ProgressCallback, + RenderPipelineOptions, +} from './render-pipeline'; + +// Renderers +export { + SyntaxHighlighter, + syntaxHighlighter, + SYNTAX_THEME_MAP, +} from './renderers/syntax-highlighter'; +export type { HighlightResult, DetectionResult } from './renderers/syntax-highlighter'; +export { MermaidRenderer, mermaidRenderer } from './renderers/mermaid-renderer'; +export type { MermaidOptions, DiagramControls } from './renderers/mermaid-renderer'; + +// Export modules +export { ContentCollector } from './utils/content-collector'; +export { SVGConverter } from './utils/svg-converter'; +export { DOCXGenerator } from './utils/docx-generator'; +export { PDFGenerator } from './utils/pdf-generator'; +export { ExportController } from './export-controller'; + +// UI +export { TocRenderer } from './ui/toc-renderer'; +export { ExportUI } from './ui/export-ui'; +export type { CoreExportUIOptions } from './ui/export-ui'; + +// Lazy section renderer +export { LazySectionRenderer } from './lazy-section-renderer'; +export type { LazySectionOptions } from './lazy-section-renderer'; + +// Platform adapters +export * from './adapters'; + +// Workers +export { WorkerPool, workerPool } from './workers/worker-pool'; +export type { WorkerPoolOptions } from './workers/worker-pool'; +export { handleParseTask } from './workers/tasks/parse-task'; +export { handleHighlightTask } from './workers/tasks/highlight-task'; +export { handleMermaidTask } from './workers/tasks/mermaid-task'; diff --git a/src/core/lazy-section-renderer.ts b/packages/core/src/lazy-section-renderer.ts similarity index 97% rename from src/core/lazy-section-renderer.ts rename to packages/core/src/lazy-section-renderer.ts index f0bd507..217dbef 100644 --- a/src/core/lazy-section-renderer.ts +++ b/packages/core/src/lazy-section-renderer.ts @@ -3,9 +3,9 @@ * Renders markdown sections on-demand as they become visible */ -import type { MarkdownSection } from '../utils/section-splitter'; +import type { MarkdownSection } from './utils/section-splitter'; import type { MarkdownConverter } from './markdown-converter'; -import { debug } from '../utils/debug-logger'; +import { debug } from './utils/debug-logger'; export interface LazySectionOptions { rootMargin?: string; diff --git a/src/core/markdown-converter.ts b/packages/core/src/markdown-converter.ts similarity index 97% rename from src/core/markdown-converter.ts rename to packages/core/src/markdown-converter.ts index 789c970..31154d6 100644 --- a/src/core/markdown-converter.ts +++ b/packages/core/src/markdown-converter.ts @@ -8,7 +8,7 @@ import markdownItAttrs from 'markdown-it-attrs'; import markdownItAnchor from 'markdown-it-anchor'; import markdownItTaskLists from 'markdown-it-task-lists'; import * as emojiPlugin from 'markdown-it-emoji'; -import type { ConversionResult, ParseError, ValidationResult } from '../types'; +import type { ConversionResult, ParseError, ValidationResult } from './types/index'; export interface ConvertOptions { baseUrl?: string; @@ -111,10 +111,13 @@ export class MarkdownConverter { }); // Store code in global registry (bypasses DOM insertion issues) - if (!window.__MDVIEW_MERMAID_CODE__) { - window.__MDVIEW_MERMAID_CODE__ = new Map(); + // Guard for non-browser environments + if (typeof window !== 'undefined') { + if (!window.__MDVIEW_MERMAID_CODE__) { + window.__MDVIEW_MERMAID_CODE__ = new Map(); + } + window.__MDVIEW_MERMAID_CODE__.set(id, code); } - window.__MDVIEW_MERMAID_CODE__.set(id, code); // Escape the code for safe HTML attribute storage // This allows the code to survive caching and be retrieved later diff --git a/src/core/render-pipeline.ts b/packages/core/src/render-pipeline.ts similarity index 87% rename from src/core/render-pipeline.ts rename to packages/core/src/render-pipeline.ts index 5925b5f..278666e 100644 --- a/src/core/render-pipeline.ts +++ b/packages/core/src/render-pipeline.ts @@ -1,22 +1,35 @@ /** * Render Pipeline * Multi-stage pipeline for rendering markdown with progressive enhancement + * + * Platform-agnostic version: uses MessagingAdapter instead of chrome.runtime.sendMessage. + * When no adapter is provided, caching is silently skipped (graceful degradation). */ import { MarkdownConverter } from './markdown-converter'; import { extractFrontmatter, renderFrontmatterHtml } from './frontmatter-extractor'; -import { domPurifier } from '../utils/dom-purifier'; -import { workerPool } from '../workers/worker-pool'; +import { domPurifier } from './utils/dom-purifier'; +import { workerPool } from './workers/worker-pool'; import type { ConversionResult, ThemeName, CachedResult, ParseTaskPayload, CommentParseResult, -} from '../types'; -import { debug } from '../utils/debug-logger'; -import { splitIntoSections } from '../utils/section-splitter'; -import { SkeletonRenderer } from '../utils/skeleton-renderer'; +} from './types/index'; +import { splitIntoSections } from './utils/section-splitter'; +import { SkeletonRenderer } from './utils/skeleton-renderer'; +import type { MessagingAdapter } from './adapters'; + +// --------------------------------------------------------------------------- +// No-op debug logger (temporary until debug-logger is extracted to core) +// --------------------------------------------------------------------------- +const debug = { + info: (..._args: unknown[]) => {}, + debug: (..._args: unknown[]) => {}, + error: (..._args: unknown[]) => {}, + log: (..._args: unknown[]) => {}, +}; export interface RenderOptions { container: HTMLElement; @@ -46,17 +59,23 @@ export interface RenderProgress { export type ProgressCallback = (progress: RenderProgress) => void; +export interface RenderPipelineOptions { + messaging?: MessagingAdapter; +} + export class RenderPipeline { private converter: MarkdownConverter; private progressCallbacks: Set = new Set(); private cancelRequested = false; private workersEnabled = true; - private progressUpdateTimer: number | null = null; + private progressUpdateTimer: ReturnType | null = null; private lastProgressUpdate = 0; private lastCommentParseResult: CommentParseResult | null = null; + private messaging?: MessagingAdapter; - constructor() { + constructor(options?: RenderPipelineOptions) { this.converter = new MarkdownConverter(); + this.messaging = options?.messaging; // Initialize workers in background void this.initializeWorkers(); } @@ -70,14 +89,14 @@ export class RenderPipeline { try { await workerPool.initialize(); this.workersEnabled = true; - debug.info('RenderPipeline', '✅ Worker pool initialized'); + debug.info('RenderPipeline', 'Worker pool initialized'); } catch (error) { // Workers unavailable - WorkerPool already logged the reason // Silently fall back to synchronous rendering this.workersEnabled = false; // Only log errors for unexpected failures (non-file:// protocols) - if (window.location.protocol !== 'file:') { + if (typeof window !== 'undefined' && window.location?.protocol !== 'file:') { debug.error('RenderPipeline', 'Worker initialization failed, using sync fallback:', error); } } @@ -107,8 +126,8 @@ export class RenderPipeline { const shouldUseWorkers = useWorkers && this.workersEnabled; try { - // Check cache if enabled (from service worker) - if (useCache && filePath) { + // Check cache if enabled and adapter is available + if (useCache && filePath && this.messaging) { const cacheKey = await this.getCacheKey( filePath, markdown, @@ -145,7 +164,7 @@ export class RenderPipeline { // Preprocess: Strip existing TOC if custom TOC is enabled if ((preferences as { showToc?: boolean }).showToc) { - const { stripTableOfContents } = await import('../utils/toc-stripper'); + const { stripTableOfContents } = await import('./utils/toc-stripper'); const stripResult = stripTableOfContents(processedMarkdown); processedMarkdown = stripResult.markdown; if (stripResult.tocFound) { @@ -156,7 +175,7 @@ export class RenderPipeline { // Preprocess: Strip comment footnotes before parsing this.lastCommentParseResult = null; if ((preferences as { commentsEnabled?: boolean }).commentsEnabled !== false) { - const { parseComments } = await import('../comments/annotation-parser'); + const { parseComments } = await import('./comments/annotation-parser'); this.lastCommentParseResult = parseComments(processedMarkdown); processedMarkdown = this.lastCommentParseResult.cleanedMarkdown; if (this.lastCommentParseResult.comments.length > 0) { @@ -272,8 +291,8 @@ export class RenderPipeline { this.applyTheming(container); debug.info('RenderPipeline', 'Theme applied successfully'); - // Cache the result if enabled (to service worker) - if (useCache && filePath) { + // Cache the result if enabled and adapter is available + if (useCache && filePath && this.messaging) { try { const cacheKey = await this.getCacheKey( filePath, @@ -347,13 +366,22 @@ export class RenderPipeline { // Re-setup image lazy loading this.setupImageLazyLoading(container); - // Re-initialize Mermaid diagrams (they may need panzoom reinitialization) - const mermaidContainers = container.querySelectorAll('.mermaid-container.mermaid-ready'); - if (mermaidContainers.length > 0) { + // Re-initialize Mermaid diagrams + const pendingMermaid = container.querySelectorAll('.mermaid-container.mermaid-pending'); + const readyMermaid = container.querySelectorAll('.mermaid-container.mermaid-ready'); + + if (pendingMermaid.length > 0 || readyMermaid.length > 0) { try { - // Mermaid diagrams should already have SVG from cache - // Controls will be re-added automatically when mermaid module loads - await import('../renderers/mermaid-renderer'); + const { mermaidRenderer } = await import('./renderers/mermaid-renderer'); + + // Re-render any pending diagrams that were cached before rendering completed + if (pendingMermaid.length > 0) { + debug.info( + 'RenderPipeline', + `Re-rendering ${pendingMermaid.length} pending mermaid diagram(s) from stale cache` + ); + await mermaidRenderer.renderAll(container); + } } catch (error) { debug.error('RenderPipeline', 'Failed to reinitialize mermaid:', error); } @@ -378,11 +406,11 @@ export class RenderPipeline { await this.applySyntaxHighlighting(container); } - // CRITICAL: Mark Mermaid blocks (actual rendering happens lazily on intersection) - debug.debug('RenderPipeline', 'Marking Mermaid blocks...'); - this.markMermaidBlocks(container); + // CRITICAL: Mark and render Mermaid blocks (awaited so cache captures rendered state) + debug.debug('RenderPipeline', 'Rendering Mermaid blocks...'); + await this.markAndRenderMermaidBlocks(container); - debug.debug('RenderPipeline', 'Syntax highlighting complete'); + debug.debug('RenderPipeline', 'Enhancement complete'); // NON-CRITICAL: Schedule remaining enhancements during idle time // This improves perceived performance by not blocking the main render @@ -405,7 +433,7 @@ export class RenderPipeline { }; // Use requestIdleCallback if available, otherwise setTimeout - if ('requestIdleCallback' in window) { + if (typeof requestIdleCallback !== 'undefined') { requestIdleCallback(runEnhancements, { timeout: 100 }); } else { setTimeout(runEnhancements, 0); @@ -420,7 +448,7 @@ export class RenderPipeline { // Even with workers available, use lazy loading for instant content display // Only visible code blocks are highlighted immediately, rest on scroll try { - const { syntaxHighlighter } = await import('../renderers/syntax-highlighter'); + const { syntaxHighlighter } = await import('./renderers/syntax-highlighter'); syntaxHighlighter.highlightVisible(container); } catch (error) { debug.error('RenderPipeline', 'Syntax highlighting error:', error); @@ -438,7 +466,7 @@ export class RenderPipeline { if (timeSinceLastUpdate < 100 && progress.stage !== 'complete' && progress.stage !== 'cached') { // Schedule update for later if not already scheduled if (!this.progressUpdateTimer) { - this.progressUpdateTimer = window.setTimeout(() => { + this.progressUpdateTimer = setTimeout(() => { this.notifyProgress(progress); this.progressUpdateTimer = null; this.lastProgressUpdate = Date.now(); @@ -452,7 +480,7 @@ export class RenderPipeline { } /** - * Get cache key from service worker + * Get cache key via messaging adapter */ private async getCacheKey( filePath: string, @@ -460,7 +488,10 @@ export class RenderPipeline { theme: ThemeName, preferences: Record ): Promise { - const response: unknown = await chrome.runtime.sendMessage({ + if (!this.messaging) { + return ''; + } + const response: unknown = await this.messaging.send({ type: 'CACHE_GENERATE_KEY', payload: { filePath, content, theme, preferences }, }); @@ -468,10 +499,13 @@ export class RenderPipeline { } /** - * Get cached result from service worker + * Get cached result via messaging adapter */ private async getCachedResult(key: string): Promise { - const response: unknown = await chrome.runtime.sendMessage({ + if (!this.messaging) { + return null; + } + const response: unknown = await this.messaging.send({ type: 'CACHE_GET', payload: { key }, }); @@ -479,7 +513,7 @@ export class RenderPipeline { } /** - * Set cached result in service worker + * Set cached result via messaging adapter */ private async setCachedResult( key: string, @@ -488,14 +522,17 @@ export class RenderPipeline { contentHash: string, theme: ThemeName ): Promise { - await chrome.runtime.sendMessage({ + if (!this.messaging) { + return; + } + await this.messaging.send({ type: 'CACHE_SET', payload: { key, result, filePath, contentHash, theme }, }); } /** - * Generate content hash via service worker + * Generate content hash */ private async generateContentHash(content: string): Promise { const encoder = new TextEncoder(); @@ -571,7 +608,7 @@ export class RenderPipeline { // Preprocess: Strip existing TOC if custom TOC is enabled if ((preferences as { showToc?: boolean }).showToc) { - const { stripTableOfContents } = await import('../utils/toc-stripper'); + const { stripTableOfContents } = await import('./utils/toc-stripper'); const stripResult = stripTableOfContents(processedMarkdown); processedMarkdown = stripResult.markdown; if (stripResult.tocFound) { @@ -582,7 +619,7 @@ export class RenderPipeline { // Preprocess: Strip comment footnotes before parsing this.lastCommentParseResult = null; if ((preferences as { commentsEnabled?: boolean }).commentsEnabled !== false) { - const { parseComments } = await import('../comments/annotation-parser'); + const { parseComments } = await import('./comments/annotation-parser'); this.lastCommentParseResult = parseComments(processedMarkdown); processedMarkdown = this.lastCommentParseResult.cleanedMarkdown; if (this.lastCommentParseResult.comments.length > 0) { @@ -770,7 +807,7 @@ export class RenderPipeline { */ private async applySyntaxHighlighting(container: HTMLElement): Promise { try { - const { syntaxHighlighter } = await import('../renderers/syntax-highlighter'); + const { syntaxHighlighter } = await import('./renderers/syntax-highlighter'); syntaxHighlighter.highlightVisible(container); } catch (error) { debug.error('RenderPipeline', 'Syntax highlighting error:', error); @@ -867,13 +904,13 @@ export class RenderPipeline { void (async () => { try { await navigator.clipboard.writeText(code.textContent || ''); - copyButton.textContent = '✓ Copied'; + copyButton.textContent = 'Copied'; setTimeout(() => { copyButton.textContent = 'Copy'; }, 2000); } catch (error) { debug.error('RenderPipeline', 'Failed to copy:', error); - copyButton.textContent = '✗ Failed'; + copyButton.textContent = 'Failed'; setTimeout(() => { copyButton.textContent = 'Copy'; }, 2000); @@ -914,18 +951,18 @@ export class RenderPipeline { } /** - * Mark Mermaid blocks for rendering + * Mark Mermaid blocks for rendering and await completion */ - private markMermaidBlocks(container: HTMLElement): void { + private async markAndRenderMermaidBlocks(container: HTMLElement): Promise { const mermaidBlocks = container.querySelectorAll('.mermaid-container'); mermaidBlocks.forEach((block) => { block.classList.add('mermaid-pending'); }); - // Initialize Mermaid renderer - this.renderMermaidDiagrams(container).catch((error) => { - debug.error('RenderPipeline', 'Mermaid rendering catch error:', error); - }); + // Await mermaid rendering so cache captures the rendered state + if (mermaidBlocks.length > 0) { + await this.renderMermaidDiagrams(container); + } } /** @@ -933,7 +970,7 @@ export class RenderPipeline { */ private async renderMermaidDiagrams(container: HTMLElement): Promise { try { - const { mermaidRenderer } = await import('../renderers/mermaid-renderer'); + const { mermaidRenderer } = await import('./renderers/mermaid-renderer'); await mermaidRenderer.renderAll(container); } catch (error) { debug.error('RenderPipeline', 'Mermaid rendering error:', error); @@ -1003,7 +1040,7 @@ export class RenderPipeline { const errorMessage = error instanceof Error ? error.message : String(error); container.innerHTML = `
}); }); }); - - diff --git a/tests/unit/utils/docx-generator.test.ts b/tests/unit/utils/docx-generator.test.ts index bb92d7a..8a320a1 100644 --- a/tests/unit/utils/docx-generator.test.ts +++ b/tests/unit/utils/docx-generator.test.ts @@ -3,8 +3,8 @@ */ import { describe, test, expect, beforeEach } from 'vitest'; -import { DOCXGenerator } from '../../../src/utils/docx-generator'; -import type { ContentNode, CollectedContent, ConvertedImage } from '../../../src/types'; +import { DOCXGenerator } from '@mdview/core'; +import type { ContentNode, CollectedContent, ConvertedImage } from '@mdview/core'; describe('DOCXGenerator', () => { let generator: DOCXGenerator; @@ -49,7 +49,9 @@ describe('DOCXGenerator', () => { const blob = await generator.generate(content, new Map()); expect(blob).toBeInstanceOf(Blob); - expect(blob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + expect(blob.type).toBe( + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + ); expect(blob.size).toBeGreaterThan(0); }); @@ -394,7 +396,10 @@ describe('DOCXGenerator', () => { }); test('should apply borders', async () => { - const tableData = [['A', 'B'], ['C', 'D']]; + const tableData = [ + ['A', 'B'], + ['C', 'D'], + ]; const content = createTestContent([ { @@ -575,4 +580,3 @@ describe('DOCXGenerator', () => { }); }); }); - diff --git a/tests/unit/utils/dom-purifier.test.ts b/tests/unit/utils/dom-purifier.test.ts index 506f5ba..e642cf7 100644 --- a/tests/unit/utils/dom-purifier.test.ts +++ b/tests/unit/utils/dom-purifier.test.ts @@ -3,7 +3,7 @@ */ import { describe, test, expect, beforeEach } from 'vitest'; -import { DOMPurifierUtil } from '../../../src/utils/dom-purifier'; +import { DOMPurifierUtil } from '@mdview/core'; import { xssPayloads } from '../../helpers/fixtures'; describe('DOMPurifier', () => { diff --git a/tests/unit/utils/file-scanner-blocklist.test.ts b/tests/unit/utils/file-scanner-blocklist.test.ts index 6cd28d8..2799cc5 100644 --- a/tests/unit/utils/file-scanner-blocklist.test.ts +++ b/tests/unit/utils/file-scanner-blocklist.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { FileScanner } from '../../../src/utils/file-scanner'; +import { FileScanner } from '../../../packages/chrome-ext/src/utils/file-scanner'; describe('FileScanner Blocklist', () => { beforeEach(() => { diff --git a/tests/unit/utils/filename-generator.test.ts b/tests/unit/utils/filename-generator.test.ts index d157401..75e4478 100644 --- a/tests/unit/utils/filename-generator.test.ts +++ b/tests/unit/utils/filename-generator.test.ts @@ -3,7 +3,7 @@ */ import { describe, test, expect, beforeEach, vi } from 'vitest'; -import { FilenameGenerator } from '../../../src/utils/filename-generator'; +import { FilenameGenerator } from '@mdview/core'; describe('FilenameGenerator', () => { beforeEach(() => { @@ -180,4 +180,3 @@ describe('FilenameGenerator', () => { }); }); }); - diff --git a/tests/unit/utils/pdf-generator.test.ts b/tests/unit/utils/pdf-generator.test.ts index 0c67458..17fea67 100644 --- a/tests/unit/utils/pdf-generator.test.ts +++ b/tests/unit/utils/pdf-generator.test.ts @@ -3,20 +3,37 @@ */ import { describe, test, expect, beforeEach, vi, afterEach } from 'vitest'; -import { PDFGenerator } from '../../../src/utils/pdf-generator'; +import { PDFGenerator } from '@mdview/core'; -// Mock the debug logger -vi.mock('../../../src/utils/debug-logger', () => ({ - debug: { +// Mock the debug logger (core internal) +vi.mock('../../../packages/core/src/utils/debug-logger', () => { + const mockHelpers = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), - }, -})); + log: vi.fn(), + }; + return { + debug: mockHelpers, + createDebug: vi.fn(() => mockHelpers), + createDebugLogger: vi.fn(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + DebugLogger: vi.fn().mockImplementation(() => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + })), + }; +}); -// Mock SVGConverter -vi.mock('../../../src/utils/svg-converter', () => ({ +// Mock SVGConverter (core internal dependency of PDFGenerator) +vi.mock('../../../packages/core/src/utils/svg-converter', () => ({ SVGConverter: vi.fn().mockImplementation(() => ({ convert: vi.fn().mockResolvedValue({ id: 'test-svg', @@ -28,8 +45,8 @@ vi.mock('../../../src/utils/svg-converter', () => ({ })), })); -// Mock mermaid renderer -vi.mock('../../../src/renderers/mermaid-renderer', () => ({ +// Mock mermaid renderer (core internal dependency) +vi.mock('../../../packages/core/src/renderers/mermaid-renderer', () => ({ mermaidRenderer: { renderAllImmediate: vi.fn().mockResolvedValue(undefined), }, @@ -76,7 +93,9 @@ describe('PDFGenerator', () => { HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({ drawImage: vi.fn(), }); - HTMLCanvasElement.prototype.toDataURL = vi.fn().mockReturnValue('data:image/png;base64,mockdata'); + HTMLCanvasElement.prototype.toDataURL = vi + .fn() + .mockReturnValue('data:image/png;base64,mockdata'); // Mock getBoundingClientRect for SVGs Element.prototype.getBoundingClientRect = vi.fn().mockReturnValue({ @@ -247,7 +266,7 @@ describe('PDFGenerator', () => { svgContainer.appendChild(mermaidDiv); // Mock converter to throw error - const { SVGConverter } = await import('../../../src/utils/svg-converter'); + const { SVGConverter } = await import('@mdview/core'); (SVGConverter as any).mockImplementationOnce(() => ({ convert: vi.fn().mockRejectedValue(new Error('Conversion failed')), })); @@ -310,9 +329,7 @@ describe('PDFGenerator', () => { throw new Error('Print failed'); }); - await expect( - generator.print(svgContainer, { convertSvgsToImages: true }) - ).rejects.toThrow(); + await expect(generator.print(svgContainer, { convertSvgsToImages: true })).rejects.toThrow(); // SVGs should be restored even on error const restoredSvg = mermaidDiv.querySelector('svg'); @@ -320,4 +337,3 @@ describe('PDFGenerator', () => { }); }); }); - diff --git a/tests/unit/utils/svg-converter.test.ts b/tests/unit/utils/svg-converter.test.ts index 4e19cba..086149d 100644 --- a/tests/unit/utils/svg-converter.test.ts +++ b/tests/unit/utils/svg-converter.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { SVGConverter } from '../../../src/utils/svg-converter'; +import { SVGConverter } from '@mdview/core'; /** * Helper to create a mock SVG element diff --git a/tests/unit/utils/toc-stripper.test.ts b/tests/unit/utils/toc-stripper.test.ts index 96a75c0..0150099 100644 --- a/tests/unit/utils/toc-stripper.test.ts +++ b/tests/unit/utils/toc-stripper.test.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect } from 'vitest'; -import { stripTableOfContents } from '../../../src/utils/toc-stripper'; +import { stripTableOfContents } from '@mdview/core'; describe('stripTableOfContents', () => { describe('Basic TOC Detection', () => { diff --git a/tsconfig.json b/tsconfig.json index fb19e33..4683906 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,10 +17,10 @@ "types": ["chrome", "vite/client", "vitest/globals"], "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["packages/chrome-ext/src/*"] } }, - "include": ["src/**/*", "tests/**/*"], + "include": ["packages/*/src/**/*", "tests/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/vitest.config.ts b/vitest.config.ts index 7ad0f8e..29f060a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -28,7 +28,7 @@ export default defineConfig({ }, resolve: { alias: { - '@': resolve(__dirname, './src'), + '@': resolve(__dirname, './packages/chrome-ext/src'), }, }, }); diff --git a/vitest.workspace.ts b/vitest.workspace.ts new file mode 100644 index 0000000..ee916e3 --- /dev/null +++ b/vitest.workspace.ts @@ -0,0 +1,19 @@ +import { defineWorkspace } from 'vitest/config'; + +export default defineWorkspace([ + { + extends: './vitest.config.ts', + test: { + name: 'chrome-ext', + include: ['tests/**/*.test.ts'], + }, + }, + { + extends: './packages/core/vitest.config.ts', + test: { + name: 'core', + root: './packages/core', + include: ['src/**/*.test.ts'], + }, + }, +]);