diff --git a/docs/.vitepress/theme/components/ReadAloud.vue b/docs/.vitepress/theme/components/ReadAloud.vue new file mode 100644 index 0000000..7ecdbe0 --- /dev/null +++ b/docs/.vitepress/theme/components/ReadAloud.vue @@ -0,0 +1,316 @@ + + + + + + + \ No newline at end of file diff --git a/docs/.vitepress/theme/composables/useArticleText.ts b/docs/.vitepress/theme/composables/useArticleText.ts new file mode 100644 index 0000000..290b543 --- /dev/null +++ b/docs/.vitepress/theme/composables/useArticleText.ts @@ -0,0 +1,78 @@ +export interface ChunkItem { + text: string + element: Element | null +} + +export function useArticleText() { + const getArticleChunks = (): ChunkItem[] => { + if (typeof document === 'undefined') return [] + const docElement = document.querySelector('.vp-doc') + if (!docElement) return [] + + const elements = docElement.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, blockquote') + const chunks: ChunkItem[] = [] + + elements.forEach((el) => { + // Exclude UI controls, read-aloud component, pre & code blocks + if (el.closest('.read-aloud-container, pre, .vp-code')) return + + const clone = el.cloneNode(true) as HTMLElement + clone.querySelectorAll('.line-numbers-wrapper, .copy, .header-anchor, script, style, .read-aloud-container, button, img, svg, code').forEach(child => child.remove()) + + let text = clone.innerText || clone.textContent || '' + text = text + .replace(/\s+/g, ' ') + .replace(/\b(e\.g\.|i\.e\.|vs\.|etc\.)/gi, match => match.replace(/\./g, '')) + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .trim() + + if (!text) return + + const rawSentences = text.match(/[^.!?\n]+[.!?\n]+/g) || [text] + let current = '' + + for (const sentence of rawSentences) { + const trimmed = sentence.trim() + if (!trimmed) continue + if ((current + ' ' + trimmed).length > 250) { + if (current) chunks.push({ text: current.trim(), element: el }) + current = trimmed + } else { + current = current ? current + ' ' + trimmed : trimmed + } + } + if (current.trim()) { + chunks.push({ text: current.trim(), element: el }) + } + }) + + return chunks + } + + const highlightElement = (el: Element | null) => { + if (typeof document === 'undefined') return + + document.querySelectorAll('.read-aloud-highlight').forEach(item => { + item.classList.remove('read-aloud-highlight') + }) + + if (el) { + el.classList.add('read-aloud-highlight') + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + } + + const clearHighlight = () => { + if (typeof document === 'undefined') return + document.querySelectorAll('.read-aloud-highlight').forEach(item => { + item.classList.remove('read-aloud-highlight') + }) + } + + return { + getArticleChunks, + highlightElement, + clearHighlight, + } +} diff --git a/docs/.vitepress/theme/composables/useBrowserTTS.ts b/docs/.vitepress/theme/composables/useBrowserTTS.ts new file mode 100644 index 0000000..7983aa5 --- /dev/null +++ b/docs/.vitepress/theme/composables/useBrowserTTS.ts @@ -0,0 +1,97 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +export function useBrowserTTS() { + const isBrowserSupported = ref(false) + const browserVoices = ref([]) + const selectedBrowserVoiceIndex = ref(0) + let currentUtterance: SpeechSynthesisUtterance | null = null + + const loadBrowserVoices = () => { + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + const voices = window.speechSynthesis.getVoices() + if (!voices || voices.length === 0) return + + const scoreVoice = (v: SpeechSynthesisVoice) => { + let score = 0 + const name = v.name.toLowerCase() + if (name.includes('google') && name.includes('us english')) score += 1000 + if (name.includes('microsoft') && (name.includes('jenny') || name.includes('guy') || name.includes('aria'))) score += 1000 + if (name.includes('samantha') || name.includes('alex') || name.includes('daniel')) score += 900 + if (name.includes('google') || name.includes('microsoft') || name.includes('natural')) score += 500 + if (v.lang === 'en-US') score += 100 + else if (v.lang.startsWith('en')) score += 50 + if (v.default) score += 200 + return score + } + + const sortedVoices = [...voices].sort((a, b) => scoreVoice(b) - scoreVoice(a)) + const englishVoices = sortedVoices.filter(v => v.lang.startsWith('en')) + browserVoices.value = englishVoices.length > 0 ? englishVoices : sortedVoices + } + } + + onMounted(() => { + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + isBrowserSupported.value = true + loadBrowserVoices() + window.speechSynthesis.onvoiceschanged = loadBrowserVoices + } else { + isBrowserSupported.value = false + } + }) + + onUnmounted(() => { + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + if (window.speechSynthesis.onvoiceschanged === loadBrowserVoices) { + window.speechSynthesis.onvoiceschanged = null + } + window.speechSynthesis.cancel() + } + currentUtterance = null + }) + + const speakBrowserChunk = ( + text: string, + rate: number, + onStart: () => void, + onEnd: () => void, + onError: (err: any) => void + ) => { + if (typeof window === 'undefined' || !('speechSynthesis' in window)) return + + window.speechSynthesis.cancel() + + const utterance = new SpeechSynthesisUtterance(text) + const voice = browserVoices.value[selectedBrowserVoiceIndex.value] + if (voice) { + utterance.voice = voice + utterance.lang = voice.lang + } + + utterance.rate = rate + utterance.pitch = 1.0 + utterance.volume = 1.0 + + utterance.onstart = () => onStart() + utterance.onend = () => onEnd() + utterance.onerror = (event) => onError(event) + + currentUtterance = utterance + window.speechSynthesis.speak(utterance) + } + + const stopBrowserTTS = () => { + if (typeof window !== 'undefined' && 'speechSynthesis' in window) { + window.speechSynthesis.cancel() + } + currentUtterance = null + } + + return { + isBrowserSupported, + browserVoices, + selectedBrowserVoiceIndex, + speakBrowserChunk, + stopBrowserTTS, + } +} diff --git a/docs/.vitepress/theme/composables/usePuterTTS.ts b/docs/.vitepress/theme/composables/usePuterTTS.ts new file mode 100644 index 0000000..368bd35 --- /dev/null +++ b/docs/.vitepress/theme/composables/usePuterTTS.ts @@ -0,0 +1,139 @@ +import { ref } from 'vue' +import type { ChunkItem } from './useArticleText' + +export interface PuterVoice { + id: string + name: string + provider: string +} + +export function usePuterTTS() { + const puterVoices: PuterVoice[] = [ + { id: 'nova', name: 'Nova (Female - Natural)', provider: 'openai' }, + { id: 'alloy', name: 'Alloy (Neutral - Clear)', provider: 'openai' }, + { id: 'echo', name: 'Echo (Male - Warm)', provider: 'openai' }, + { id: 'fable', name: 'Fable (Expressive - Story)', provider: 'openai' }, + { id: 'onyx', name: 'Onyx (Male - Deep)', provider: 'openai' }, + { id: 'shimmer', name: 'Shimmer (Female - Soft)', provider: 'openai' }, + ] + const selectedPuterVoice = ref('nova') + + let currentAudio: HTMLAudioElement | null = null + + // Lightweight Audio Cache & Prefetch Map using key format: `${voice}:${idx}` + const audioCache = new Map() + const prefetchMap = new Map>() + + const clearAudioCache = () => { + audioCache.forEach(audio => { + audio.pause() + audio.src = '' + }) + audioCache.clear() + prefetchMap.clear() + } + + const fetchPuterChunk = (chunkIndex: number, chunks: ChunkItem[]): Promise => { + if (chunkIndex < 0 || chunkIndex >= chunks.length) { + return Promise.resolve(null) + } + + const cacheKey = `${selectedPuterVoice.value}:${chunkIndex}` + + if (audioCache.has(cacheKey)) { + return Promise.resolve(audioCache.get(cacheKey)!) + } + + if (prefetchMap.has(cacheKey)) { + return prefetchMap.get(cacheKey)! + } + + const promise = (async () => { + try { + const puterModule = await import('@heyputer/puter.js') + const puter = puterModule.default || puterModule.puter || puterModule + + const audio = await puter.ai.txt2speech(chunks[chunkIndex].text, { + provider: 'openai', + model: 'tts-1', + voice: selectedPuterVoice.value, + }) + + audioCache.set(cacheKey, audio) + prefetchMap.delete(cacheKey) + return audio + } catch (err) { + console.warn(`Failed to prefetch Puter AI chunk ${chunkIndex}:`, err) + prefetchMap.delete(cacheKey) + return null + } + })() + + prefetchMap.set(cacheKey, promise) + return promise + } + + const isPuterChunkCached = (chunkIndex: number): boolean => { + const cacheKey = `${selectedPuterVoice.value}:${chunkIndex}` + return audioCache.has(cacheKey) + } + + const stopPuterAudio = () => { + if (currentAudio) { + currentAudio.pause() + currentAudio = null + } + } + + const playPuterAudio = async ( + audio: HTMLAudioElement, + rate: number, + onEnded: () => void, + onError: (e: any) => void + ) => { + if (currentAudio && currentAudio !== audio) { + currentAudio.pause() + } + + currentAudio = audio + currentAudio.currentTime = 0 + currentAudio.playbackRate = rate + + audio.onended = onEnded + audio.onerror = onError + + await audio.play().catch(() => {}) + } + + const pausePuterAudio = () => { + if (currentAudio) { + currentAudio.pause() + } + } + + const resumePuterAudio = () => { + if (currentAudio && currentAudio.paused) { + return currentAudio.play() + } + return Promise.resolve() + } + + const setPuterRate = (rate: number) => { + if (currentAudio) { + currentAudio.playbackRate = rate + } + } + + return { + puterVoices, + selectedPuterVoice, + fetchPuterChunk, + isPuterChunkCached, + playPuterAudio, + pausePuterAudio, + resumePuterAudio, + stopPuterAudio, + setPuterRate, + clearAudioCache, + } +} diff --git a/docs/.vitepress/theme/composables/useReadAloudEngine.ts b/docs/.vitepress/theme/composables/useReadAloudEngine.ts new file mode 100644 index 0000000..33a8722 --- /dev/null +++ b/docs/.vitepress/theme/composables/useReadAloudEngine.ts @@ -0,0 +1,231 @@ +import { ref, watch, onUnmounted, computed } from 'vue' +import { useRoute } from 'vitepress' +import { useArticleText, type ChunkItem } from './useArticleText' +import { useBrowserTTS } from './useBrowserTTS' +import { usePuterTTS } from './usePuterTTS' + +export function useReadAloudEngine() { + const route = useRoute() + const { getArticleChunks, highlightElement, clearHighlight } = useArticleText() + const browserTTS = useBrowserTTS() + const puterTTS = usePuterTTS() + + const ttsMode = ref<'puter' | 'browser'>('puter') + const isPlaying = ref(false) + const isPaused = ref(false) + const isLoadingModel = ref(false) + const loadingProgress = ref('') + const currentRate = ref(1.0) + const rates = [0.8, 1.0, 1.25, 1.5, 2.0] + + const chunks = ref([]) + const currentChunkIndex = ref(0) + + const isSupported = computed(() => true) + + const stopSpeech = () => { + isPlaying.value = false + isPaused.value = false + isLoadingModel.value = false + loadingProgress.value = '' + currentChunkIndex.value = 0 + chunks.value = [] + + clearHighlight() + puterTTS.stopPuterAudio() + browserTTS.stopBrowserTTS() + } + + const speakNextPuterChunk = async () => { + if (currentChunkIndex.value >= chunks.value.length) { + stopSpeech() + return + } + + const idx = currentChunkIndex.value + const currentChunk = chunks.value[idx] + + // Highlight active DOM element in yellow + highlightElement(currentChunk ? currentChunk.element : null) + + if (!puterTTS.isPuterChunkCached(idx)) { + isLoadingModel.value = true + loadingProgress.value = `Loading AI audio (${idx + 1}/${chunks.value.length})...` + } + + const audio = await puterTTS.fetchPuterChunk(idx, chunks.value) + + if (!audio || !isPlaying.value) { + isLoadingModel.value = false + loadingProgress.value = '' + if (!audio) { + if (browserTTS.isBrowserSupported.value) { + ttsMode.value = 'browser' + speakNextBrowserChunk() + } else { + stopSpeech() + } + } + return + } + + isLoadingModel.value = false + loadingProgress.value = '' + + // Pre-fetch next 2 chunks in parallel + puterTTS.fetchPuterChunk(idx + 1, chunks.value) + puterTTS.fetchPuterChunk(idx + 2, chunks.value) + + await puterTTS.playPuterAudio( + audio, + currentRate.value, + () => { + if (!isPlaying.value || isPaused.value) return + currentChunkIndex.value++ + speakNextPuterChunk() + }, + (err) => { + console.warn('Puter audio chunk playback error:', err) + currentChunkIndex.value++ + if (isPlaying.value && !isPaused.value) { + speakNextPuterChunk() + } + } + ) + } + + const speakNextBrowserChunk = () => { + if (currentChunkIndex.value >= chunks.value.length) { + stopSpeech() + return + } + + const idx = currentChunkIndex.value + const currentChunk = chunks.value[idx] + + // Highlight active DOM element in yellow + highlightElement(currentChunk ? currentChunk.element : null) + + browserTTS.speakBrowserChunk( + currentChunk.text, + currentRate.value, + () => { + isPlaying.value = true + isPaused.value = false + }, + () => { + if (!isPlaying.value || isPaused.value) return + currentChunkIndex.value++ + speakNextBrowserChunk() + }, + (event) => { + if (event.error === 'canceled' || event.error === 'interrupted') return + currentChunkIndex.value++ + if (isPlaying.value && !isPaused.value) { + speakNextBrowserChunk() + } + } + ) + } + + const togglePlayPause = async () => { + if (isPlaying.value && !isPaused.value) { + // Pause: Keep highlight visible on current element + isPaused.value = true + if (ttsMode.value === 'browser') { + browserTTS.stopBrowserTTS() + } else { + puterTTS.pausePuterAudio() + } + return + } + + if (isPaused.value) { + // Resume + isPaused.value = false + isPlaying.value = true + if (ttsMode.value === 'browser') { + speakNextBrowserChunk() + } else { + puterTTS.resumePuterAudio().catch(() => { + speakNextPuterChunk() + }) + } + return + } + + // Start fresh + stopSpeech() + const articleChunks = getArticleChunks() + if (!articleChunks || articleChunks.length === 0) return + + chunks.value = articleChunks + currentChunkIndex.value = 0 + isPlaying.value = true + isPaused.value = false + + if (ttsMode.value === 'puter') { + puterTTS.fetchPuterChunk(0, chunks.value) + puterTTS.fetchPuterChunk(1, chunks.value) + await speakNextPuterChunk() + } else { + speakNextBrowserChunk() + } + } + + const setRate = (rate: number) => { + currentRate.value = rate + if (ttsMode.value === 'puter') { + puterTTS.setPuterRate(rate) + } else { + if (isPlaying.value && !isPaused.value) { + speakNextBrowserChunk() + } + } + } + + const onEngineChange = () => { + if (isPlaying.value || isPaused.value) { + stopSpeech() + } + } + + const onVoiceChange = () => { + puterTTS.clearAudioCache() + if (isPlaying.value || isPaused.value) { + stopSpeech() + setTimeout(() => togglePlayPause(), 50) + } + } + + watch(() => route.path, () => { + stopSpeech() + puterTTS.clearAudioCache() + }) + + onUnmounted(() => { + stopSpeech() + puterTTS.clearAudioCache() + }) + + return { + isSupported, + ttsMode, + isPlaying, + isPaused, + isLoadingModel, + loadingProgress, + currentRate, + rates, + togglePlayPause, + stopSpeech, + setRate, + onEngineChange, + onVoiceChange, + puterVoices: puterTTS.puterVoices, + selectedPuterVoice: puterTTS.selectedPuterVoice, + browserVoices: browserTTS.browserVoices, + selectedBrowserVoiceIndex: browserTTS.selectedBrowserVoiceIndex, + isBrowserSupported: browserTTS.isBrowserSupported, + } +} diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 9533c49..5a7c571 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -3,7 +3,10 @@ import type { Theme } from 'vitepress' import DefaultTheme from 'vitepress/theme' import { useData } from 'vitepress' import { createMermaidRenderer } from 'vitepress-mermaid-renderer' -import { type App } from 'vue' +import type { App } from 'vue' + +import './style.css' + import ChapterMeta from './components/ChapterMeta.vue' import MentalModel from './components/MentalModel.vue' import CommonMistake from './components/CommonMistake.vue' @@ -15,27 +18,31 @@ import ChallengeBlock from './components/ChallengeBlock.vue' import Recap from './components/Recap.vue' import ProjectResult from './components/ProjectResult.vue' import ReadingProgress from './components/ReadingProgress.vue' -import './style.css' +import ReadAloud from './components/ReadAloud.vue' export default { extends: DefaultTheme, - Layout: () => { + + Layout() { const { isDark } = useData() - const initMermaid = () => { + const renderMermaid = () => { createMermaidRenderer({ - theme: 'default', + theme: isDark.value ? 'dark' : 'default', }) } - nextTick(() => initMermaid()) - watch( - () => isDark.value, - () => initMermaid(), - ) + nextTick(renderMermaid) + + watch(isDark, () => { + nextTick(renderMermaid) + }) - return h(DefaultTheme.Layout) + return h(DefaultTheme.Layout, null, { + 'doc-before': () => h(ReadAloud), + }) }, + enhanceApp({ app }: { app: App }) { app.component('ChapterMeta', ChapterMeta) app.component('MentalModel', MentalModel) @@ -48,5 +55,6 @@ export default { app.component('Recap', Recap) app.component('ProjectResult', ProjectResult) app.component('ReadingProgress', ReadingProgress) + app.component('ReadAloud', ReadAloud) }, -} satisfies Theme +} satisfies Theme \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e98aea6..a792c08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "the_quickshell_book", "version": "1.0.0", "dependencies": { + "@heyputer/puter.js": "^2.5.4", "vitepress": "^1.6.3" }, "devDependencies": { @@ -20,7 +21,7 @@ "vitepress-mermaid-renderer": "^1.1.27" }, "engines": { - "node": ">=24.0.0" + "node": ">=22.0.0" } }, "node_modules/@algolia/abtesting": { @@ -827,6 +828,22 @@ "node": ">=18" } }, + "node_modules/@heyputer/kv.js": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@heyputer/kv.js/-/kv.js-0.2.1.tgz", + "integrity": "sha512-YhVtzz7ZA/HmuaDvzZZhhUyQWBvp3/TXeY4jULssTdLJwT+tEM4BTYHXttORX+V5auvrYinjj8dNFQnby5T82w==", + "license": "MIT" + }, + "node_modules/@heyputer/puter.js": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@heyputer/puter.js/-/puter.js-2.5.4.tgz", + "integrity": "sha512-gZNbTB2k+CLL3zG/RHwpQlcj35+rwQyEn1YebwC09aiMlCT9L3Wr9uU0I18fk/tnA3EFWXWC6Zf2yyse2aJ6oQ==", + "license": "Apache-2.0", + "dependencies": { + "@heyputer/kv.js": "^0.2.1", + "open": "^10.2.0" + } + }, "node_modules/@iconify-json/simple-icons": { "version": "1.2.91", "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.91.tgz", @@ -2028,6 +2045,21 @@ "node": ">=8" } }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2657,6 +2689,46 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delaunator": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", @@ -2988,6 +3060,21 @@ "node": ">=12" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", @@ -3021,6 +3108,24 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3043,6 +3148,21 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/js-yaml": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", @@ -3347,6 +3467,24 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-manager-detector": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.7.0.tgz", @@ -3618,6 +3756,18 @@ "points-on-path": "^0.2.1" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -4533,6 +4683,21 @@ } } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 6c97477..2a0b52b 100644 --- a/package.json +++ b/package.json @@ -15,15 +15,16 @@ "validate:links": "tsx scripts/validate-links.ts" }, "dependencies": { + "@heyputer/puter.js": "^2.5.4", "vitepress": "^1.6.3" }, "devDependencies": { - "vitepress-mermaid-renderer": "^1.1.27", + "fast-glob": "^3.3.2", + "gray-matter": "^4.0.3", "mermaid": "^11.6.0", + "playwright": "^1.49.0", "tsx": "^4.19.0", "typescript": "^5.7.0", - "playwright": "^1.49.0", - "gray-matter": "^4.0.3", - "fast-glob": "^3.3.2" + "vitepress-mermaid-renderer": "^1.1.27" } }