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 @@
+
+
+
+
+
+
+
+
+
+
+
{{ loadingProgress }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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"
}
}