diff --git a/.env.example b/.env.example index 1d66f64..2f654d6 100644 --- a/.env.example +++ b/.env.example @@ -72,6 +72,7 @@ ASKOOSU_CHAT_MAX_REQUEST_BYTES=32768 ASKOOSU_RATE_LIMIT_STORE=postgres ASKOOSU_CHAT_RATE_LIMIT_PER_MINUTE=60 ASKOOSU_CHAT_SESSION_RATE_LIMIT_PER_MINUTE=30 +ASKOOSU_IP_HASH_SALT= ASKOOSU_FAQ_SEMANTIC_ROUTER_ENABLED=true ASKOOSU_FAQ_SEMANTIC_DIRECT_MIN=0.88 ASKOOSU_FAQ_SEMANTIC_REWRITE_MIN=0.76 diff --git a/Dockerfile b/Dockerfile index 6f5539f..3e54d5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,7 @@ RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/content ./content COPY --from=builder --chown=nextjs:nodejs /app/docs ./docs COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static diff --git a/content/blog/chatgpt-devspace-ram-saver.mdx b/content/blog/chatgpt-devspace-ram-saver.mdx new file mode 100644 index 0000000..cc37628 --- /dev/null +++ b/content/blog/chatgpt-devspace-ram-saver.mdx @@ -0,0 +1,455 @@ +--- +title: "ChatGPT + DevSpace 긴 대화가 RAM을 먹어치울 때, Tampermonkey로 버텨낸 기록" +date: 2026-08-20 +tags: ["ChatGPT", "DevSpace", "Tampermonkey", "Brave", "macOS", "메모리최적화"] +description: "ChatGPT 웹에 DevSpace를 연결해 어디서든 코딩 지시를 내리기 시작한 뒤, 길어진 대화 한 탭이 수 GB의 RAM과 swap을 잡아먹는 문제를 겪었다. 브라우저 변경과 여러 userscript 실험 끝에 오래된 DOM을 직접 비우는 RAM Saver v5.0에 정착한 과정을 기록한다." +slug: "chatgpt-devspace-ram-saver" +published: true +--- + +## TL;DR + +- ChatGPT 웹에 DevSpace를 연결하고 나니 노트북을 열지 않아도 스마트폰에서 코딩 작업을 지시하고 결과를 확인할 수 있어서 작업 방식이 꽤 많이 바뀌었다. +- 대신 한 대화를 오래 이어 갈수록 브라우저 탭의 메모리가 계속 커졌다. 내 환경에서는 한 세션이 1GB를 넘기는 것은 흔했고, 길어진 세션은 3~4GB 이상까지 올라갔다. +- Brave, Orion, Edge 같은 다른 브라우저와 메모리 절약 설정도 시험했지만 내가 원했던 수준의 차이는 없었다. +- 저장해 둔 긴 대화 HTML을 확인했을 때 DevSpace 관련 iframe뿐 아니라 SVG, 이미지, 코드블록, 버튼 같은 DOM이 대량으로 남아 있었다. +- 결국 Tampermonkey userscript에서 **DevSpace iframe을 비우고, 오래된 메시지를 가상화하고, 화면 밖 이미지·SVG·코드블록을 정리하는 방식**으로 방향을 바꿨고, 이 방식으로 실제 사용 중 메모리 압박을 관리할 수 있게 됐다. + +이 글은 일반적인 ChatGPT 성능 벤치마크가 아니라 **내가 실제로 ChatGPT 웹 + DevSpace를 장시간 사용하는 환경에서 겪은 문제와 해결 과정**을 기록한 글이다. + +--- + +## DevSpace를 연결하고 나니 작업 방식이 정말 편해졌다 + +ChatGPT 브라우저에 DevSpace를 연결한 뒤 가장 크게 달라진 것은 장소의 제약이었다. + +예전에는 코드를 수정하려면 결국 노트북을 열어야 했다. 하지만 DevSpace를 붙여 두고 나서는 출퇴근 지하철에서도 스마트폰으로 작업을 지시할 수 있었다. + +예를 들면 이런 흐름이다. + +```text title="mobile-dev-workflow.txt" +스마트폰에서 ChatGPT 열기 + ↓ +작업 지시 + ↓ +DevSpace가 집/사무실 Mac의 실제 저장소 접근 + ↓ +코드 수정 / 테스트 / Git 작업 + ↓ +스마트폰에서 결과 확인 +``` + +노트북을 가방에서 꺼내지 않아도 된다는 것만으로도 생각보다 큰 차이가 있었다. + +특히 여러 저장소를 동시에 다루거나, CI 결과를 보고 후속 수정을 지시하거나, 이동 중에 간단한 검증을 맡길 때 편했다. + +그런데 대화를 오래 이어가기 시작하면서 다른 문제가 생겼다. + +## 문제는 대화가 길어질수록 브라우저 RAM이 너무 커진다는 것 + +처음에는 그냥 "ChatGPT 웹이 조금 무겁네" 정도였다. + +그런데 한 세션에서 DevSpace 호출을 계속 쌓아가며 몇 시간씩 작업하니 상황이 달라졌다. + +내 환경에서는 ChatGPT 한 탭이 1GB를 넘어가는 일이 흔했고, 대화가 충분히 길어지면 **3~4GB 이상**을 잡아먹는 경우도 생겼다. + +메인 작업 머신으로 쓰던 MacBook Air는 RAM이 24GB였는데, 다른 앱을 함께 띄우고 긴 ChatGPT 세션 몇 개를 유지하면 **swap이 5GB 이상** 생기는 상황도 반복됐다. + +64GB RAM의 MacBook Pro로 옮기면 해결될 것 같았지만 그쪽에는 또 다른 문제가 있었다. PR 자동 리뷰를 위해 로컬 LLM을 함께 돌리고 있었기 때문에, 브라우저가 몇 GB씩 가져가는 것이 여전히 부담이었다. + +즉 단순히 "RAM이 많은 컴퓨터를 쓰면 된다"로 끝낼 문제가 아니었다. + +## Brave와 Orion으로 바꿔 봤지만 드라마틱하지 않았다 + +처음에는 브라우저 엔진 문제라고 생각했다. + +검색하면서 Brave, Orion 같은 브라우저를 후보로 잡았다. Brave는 Chromium 계열이라 무거운 웹앱을 오래 돌릴 때 상대적으로 버티지 않을까 기대했고, Orion은 WebKit 기반의 가벼운 탭 관리가 장점이라고 해서 시험해 봤다. Edge에서도 같은 긴 대화를 열어 비교해 봤다. + +브라우저 자체의 메모리 절약 기능, 하드웨어 가속 설정 변경, 백그라운드 탭 정리 같은 것도 함께 시도했다. + +하지만 내 사용 패턴에서는 브라우저만 바꾼다고 문제가 사라지지는 않았다. + +긴 대화에 들어가면 결국 메모리가 크게 올라갔다. + +이때부터 "브라우저가 어떤 엔진인가"보다 **한 대화 안에 무엇이 계속 남아 있는가**를 보기 시작했다. + +## 저장된 대화 HTML을 보니 iframe만의 문제가 아니었다 + +처음 Tampermonkey 스크립트를 만들 때는 DevSpace가 띄우는 iframe이 주범이라고 생각했다. + +그래서 초기 버전에서는 DevSpace나 workspace 관련 iframe을 찾아 `about:blank`로 바꾸고 내부를 비우는 방식부터 시도했다. + +그런데 저장한 긴 대화 HTML을 확인해 보니 한 시점에 다음처럼 많은 요소가 쌓여 있었다. + +| 요소 | 확인한 개수 | +| --- | ---: | +| iframe | 288 | +| SVG | 734 | +| 이미지 | 290 | +| 코드블록 | 163 | +| 버튼 | 1,293 | + +이 숫자는 ChatGPT의 일반적인 고정값이 아니다. **내가 길게 사용한 특정 세션을 저장해서 확인한 스냅샷**이다. + +중요했던 건 iframe 하나만 지운다고 끝날 구조가 아니었다는 점이다. + +툴 호출 결과, 아이콘 SVG, 이미지, syntax highlighting을 위한 수많은 `span`, 과거 메시지 DOM까지 한 페이지에 계속 남아 있었다. + +그래서 방향을 바꿨다. + +## v1.5 → v2.0 → v3.0, 그리고 결국 v5.0 + +Tampermonkey 스크립트도 한 번에 완성된 것은 아니었다. + +처음에는 React 화면을 완전히 깨뜨리지 않으려고 DOM 노드를 무작정 `remove()`하는 대신 iframe의 `src`를 `about:blank`로 바꾸고 컨테이너 내부를 비우는 방식으로 시작했다. + +그 뒤에도 메모리가 충분히 내려가지 않아 더 공격적인 정리를 시도했고, 정리가 실제로 얼마나 일어나고 있는지 확인하려고 우측 하단에 카운터 뱃지도 붙였다. + +마지막에는 iframe만이 아니라 다음을 모두 대상으로 삼았다. + +- DevSpace / workspace 관련 iframe 비우기 +- 생각 블록과 툴 메시지 내부 비우기 +- 화면 밖 SVG 제거 +- 화면 밖 이미지 `src` 언로드 +- 화면 밖 코드블록의 syntax highlighting용 하위 `span` 제거 +- 오래된 메시지의 전체 DOM을 짧은 preview placeholder로 교체 +- 애니메이션과 transition 비활성화 +- MutationObserver로 새 DOM이 생길 때마다 다시 정리 + +핵심은 마지막 항목 중 **오래된 메시지 가상화**였다. + +최근 메시지 몇 개만 온전하게 남기고, 이미 화면 위로 지나간 오래된 대화는 내용 전체를 들고 있지 않게 만드는 방식이다. + +## 최종적으로 정착한 ChatGPT RAM Saver v5.0 + +아래가 마지막에 정착해서 사용한 버전이다. + +```javascript title="chatgpt-ram-saver-v5.user.js" +// ==UserScript== +// @name ChatGPT RAM Saver v5.0 (Total Memory Purge) +// @namespace http://tampermonkey.net +// @version 5.0 +// @description iframe뿐 아니라 SVG, 이미지, 코드블록, 이벤트리스너, 애니메이션까지 전부 정리하여 ChatGPT RAM을 극한까지 절약합니다. +// @author You +// @match https://chatgpt.com/* +// @grant none +// @run-at document-end +// ==/UserScript== + +(function() { + 'use strict'; + + // ─── 상태 ─── + let stats = { iframes: 0, svgs: 0, imgs: 0, codeblocks: 0, toolmsgs: 0, thoughts: 0 }; + let isProcessing = false; + const KEEP_LAST_N_MESSAGES = 4; // 최근 N개 메시지만 풀 DOM 유지 + + // ─── 1. CSS 주입: 애니메이션 전체 비활성화 + GPU 메모리 절약 ─── + const injectCSS = () => { + if (document.getElementById('ram-saver-css')) return; + const style = document.createElement('style'); + style.id = 'ram-saver-css'; + style.textContent = ` + /* 숨길 요소 */ + div[data-testid="thought-block"], + span.group\\/tool-message { + display: none !important; + height: 0 !important; + overflow: hidden !important; + } + + /* 모든 CSS 애니메이션/트랜지션 비활성화 → GPU 메모리 절약 */ + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } + + /* 로딩 shimmer 비활성화 */ + .loading-results-shimmer { + display: none !important; + background: none !important; + } + + /* 가상화된(비워진) 메시지 placeholder */ + [data-virtualized="true"] { + min-height: 40px; + max-height: 40px; + overflow: hidden !important; + opacity: 0.3; + } + + /* 뱃지 */ + #ram-saver-badge { + position: fixed !important; + z-index: 2147483647 !important; + bottom: 16px !important; + right: 16px !important; + padding: 6px 10px !important; + background: rgba(0,0,0,0.8) !important; + color: #00FF88 !important; + border-radius: 8px !important; + font: 11px/1.4 'SF Mono', Menlo, monospace !important; + pointer-events: none !important; + white-space: pre-line !important; + box-shadow: 0 2px 12px rgba(0,0,0,0.4) !important; + display: block !important; + visibility: visible !important; + opacity: 1 !important; + } + `; + (document.head || document.documentElement).appendChild(style); + }; + + // ─── 2. UI 뱃지 ─── + const updateBadge = () => { + let b = document.getElementById('ram-saver-badge'); + if (!b) { + b = document.createElement('div'); + b.id = 'ram-saver-badge'; + document.documentElement.appendChild(b); + } + const total = Object.values(stats).reduce((a, c) => a + c, 0); + b.textContent = `🧹 RAM Saver v5.0\niframe:${stats.iframes} svg:${stats.svgs} img:${stats.imgs}\ncode:${stats.codeblocks} tool:${stats.toolmsgs} think:${stats.thoughts}\n총 정리: ${total}개`; + }; + + // ─── 3. iframe 청소 ─── + const purgeIframes = () => { + document.querySelectorAll('iframe:not([data-purged="true"])').forEach(frame => { + const t = (frame.getAttribute('title') || '').toLowerCase(); + const s = (frame.getAttribute('src') || '').toLowerCase(); + + // 시스템 필수 iframe 보호 (display:none인 숨겨진 인증용) + if (frame.style.display === 'none' && !t && !s.includes('devspace')) return; + + const isTarget = t.includes('devspace') || t.includes('ui://') || + t.includes('workspace') || s.includes('devspace') || + s.includes('workspace-app') || s.includes('saved_resource'); + + if (isTarget) { + try { frame.contentWindow && frame.contentWindow.stop(); } catch(e) {} + frame.removeAttribute('src'); + frame.removeAttribute('srcdoc'); + frame.src = 'about:blank'; + frame.setAttribute('data-purged', 'true'); + + const wrapper = frame.closest('.no-scrollbar') || + frame.closest('[class*="overflow-hidden"]') || + frame.parentElement; + if (wrapper && wrapper !== document.body) { + wrapper.innerHTML = ''; + wrapper.style.cssText = 'display:none!important;height:0!important;'; + } + stats.iframes++; + } + }); + }; + + // ─── 4. 화면 밖 SVG 제거 ─── + const purgeSVGs = () => { + // 대화 영역 안의 SVG만 대상 (사이드바/입력창 SVG는 보호) + const chatArea = document.querySelector('[class*="react-scroll-to-bottom"]') || + document.querySelector('main'); + if (!chatArea) return; + + chatArea.querySelectorAll('svg:not([data-purged="true"])').forEach(svg => { + // 화면 밖에 있는 SVG만 제거 + const rect = svg.getBoundingClientRect(); + if (rect.bottom < -200 || rect.top > window.innerHeight + 200) { + // 부모에 빈 placeholder 남기고 SVG 제거 + svg.setAttribute('data-purged', 'true'); + svg.innerHTML = ''; + svg.style.display = 'none'; + stats.svgs++; + } + }); + }; + + // ─── 5. 화면 밖 이미지 언로드 ─── + const purgeImages = () => { + const chatArea = document.querySelector('[class*="react-scroll-to-bottom"]') || + document.querySelector('main'); + if (!chatArea) return; + + chatArea.querySelectorAll('img:not([data-purged="true"])').forEach(img => { + const rect = img.getBoundingClientRect(); + if (rect.bottom < -300 || rect.top > window.innerHeight + 300) { + if (img.src && !img.getAttribute('data-original-src')) { + img.setAttribute('data-original-src', img.src); + } + img.removeAttribute('src'); + img.removeAttribute('srcset'); + img.setAttribute('data-purged', 'true'); + img.style.cssText = 'height:20px!important;width:20px!important;'; + stats.imgs++; + } + }); + }; + + // ─── 6. 코드블록 구문 강조 제거 (span 노드 대량 절약) ─── + const simplifyCodeBlocks = () => { + const chatArea = document.querySelector('[class*="react-scroll-to-bottom"]') || + document.querySelector('main'); + if (!chatArea) return; + + chatArea.querySelectorAll('pre:not([data-simplified="true"])').forEach(pre => { + const rect = pre.getBoundingClientRect(); + // 화면 밖의 코드블록만 단순화 + if (rect.bottom < -200 || rect.top > window.innerHeight + 200) { + const codeEl = pre.querySelector('code'); + if (codeEl) { + const plainText = codeEl.textContent; // span 다 벗기고 순수 텍스트만 + codeEl.innerHTML = ''; + codeEl.textContent = plainText; + } + pre.setAttribute('data-simplified', 'true'); + stats.codeblocks++; + } + }); + }; + + // ─── 7. 생각 블록 & 툴 메시지 비우기 ─── + const purgeThoughtsAndTools = () => { + document.querySelectorAll('div[data-testid="thought-block"]:not([data-purged="true"])').forEach(el => { + el.innerHTML = ''; + el.setAttribute('data-purged', 'true'); + stats.thoughts++; + }); + document.querySelectorAll('span.group\\/tool-message:not([data-purged="true"])').forEach(el => { + el.innerHTML = ''; + el.setAttribute('data-purged', 'true'); + stats.toolmsgs++; + }); + }; + + // ─── 8. 오래된 메시지 가상화 (핵심!) ─── + const virtualizeOldMessages = () => { + const messages = document.querySelectorAll('[data-message-id]:not([data-virtualized="true"])'); + if (messages.length <= KEEP_LAST_N_MESSAGES) return; + + // 최근 N개를 제외한 오래된 메시지의 내부를 비움 + const toVirtualize = Array.from(messages).slice(0, messages.length - KEEP_LAST_N_MESSAGES); + toVirtualize.forEach(msg => { + const rect = msg.getBoundingClientRect(); + // 화면 밖에 있을 때만 가상화 + if (rect.bottom < -100) { + // 텍스트 요약만 남기고 내부 DOM 비우기 + const role = msg.getAttribute('data-message-author-role') || 'unknown'; + const preview = msg.textContent.substring(0, 80).trim(); + msg.innerHTML = `
[${role}] ${preview}...
`; + msg.setAttribute('data-virtualized', 'true'); + } + }); + }; + + // ─── 메인 청소 함수 ─── + const fullPurge = () => { + if (isProcessing) return; + isProcessing = true; + try { + purgeIframes(); + purgeThoughtsAndTools(); + purgeSVGs(); + purgeImages(); + simplifyCodeBlocks(); + virtualizeOldMessages(); + updateBadge(); + } finally { + isProcessing = false; + } + }; + + // ─── MutationObserver (디바운스) ─── + let timer = null; + const observer = new MutationObserver((muts) => { + if (muts.every(m => m.target.id === 'ram-saver-badge' || m.target.id === 'ram-saver-css')) return; + if (timer) clearTimeout(timer); + timer = setTimeout(fullPurge, 400); + }); + + // ─── 부팅 ─── + injectCSS(); + observer.observe(document.documentElement, { childList: true, subtree: true }); + + // 초기 실행 (페이지 로딩 직후 시차를 두고 3회) + setTimeout(fullPurge, 500); + setTimeout(fullPurge, 2000); + setTimeout(fullPurge, 5000); + + // 백업: 5초마다 정기 청소 + 뱃지 복구 + setInterval(() => { + fullPurge(); + updateBadge(); + }, 5000); + + console.log('🚀 [ChatGPT RAM Saver v5.0] Total Memory Purge 가동!'); +})(); +``` + +## 왜 이 방식이 효과가 있었나 + +내 경우에는 브라우저를 바꾸는 것보다 **길어진 대화의 과거 UI를 계속 온전한 DOM으로 유지하지 않는 것**이 훨씬 직접적인 접근이었다. + +특히 DevSpace처럼 한 번의 대화에서 툴 호출이 많이 발생하는 사용 패턴에서는 단순 텍스트만 쌓이는 것이 아니다. + +각 호출의 UI, 아이콘, 상태 표시, 코드 결과, iframe, syntax highlighting 구조까지 함께 쌓일 수 있다. + +v5.0은 "브라우저 메모리 관리가 더 잘 되기를 기다리는 것" 대신, 내가 다시 볼 가능성이 낮은 과거 화면의 DOM을 아예 줄여 버린다. + +최근 네 개 메시지만 전체 DOM으로 유지하도록 한 것도 이 때문이다. + +```javascript title="important-setting.js" +const KEEP_LAST_N_MESSAGES = 4; +``` + +이 숫자는 정답이 아니다. 과거 대화를 자주 위로 스크롤해서 확인한다면 8이나 12처럼 늘리는 편이 낫다. + +나는 장시간 코딩 세션에서 "지금 진행 중인 최근 문맥"을 보는 경우가 대부분이라 4로 두었다. + +## 대신 꽤 공격적인 스크립트다 + +이 스크립트에는 분명한 대가가 있다. + +오래된 메시지의 내부 DOM을 직접 비우기 때문에, 위로 스크롤했을 때 원래의 완전한 메시지 UI를 그대로 다시 볼 수 없다. 이미지와 SVG도 unload하거나 비워 버리고, 코드블록의 syntax highlighting도 제거한다. + +또 ChatGPT 웹의 내부 `data-testid`, class 이름, 메시지 DOM 구조를 직접 참조한다. ChatGPT UI가 변경되면 selector가 더 이상 맞지 않거나 일부 기능이 깨질 수 있다. + +그래서 이 스크립트는 다음 전제로 쓰고 있다. + +1. 중요한 과거 대화는 필요하면 페이지를 새로고침하거나 별도로 기록한다. +2. UI가 이상해지면 Tampermonkey 스크립트를 먼저 끄고 확인한다. +3. ChatGPT 화면 구조가 바뀌면 selector를 다시 점검한다. +4. 로그인·인증처럼 정체를 확실히 모르는 iframe은 무조건 삭제하지 않는다. + +즉 "모든 사람에게 추천하는 일반 최적화"라기보다, **한 세션에서 DevSpace 툴 호출을 아주 많이 쌓는 내 작업 방식에 맞춘 강제 가상화 스크립트**에 가깝다. + +## 결국 새 대화를 자주 여는 것이 가장 단순한 해결책이기도 하다 + +이 과정에서 얻은 결론이 하나 더 있다. + +스크립트 없이 가장 확실하게 가벼운 상태를 유지하려면 한 세션을 끝없이 이어 가지 않는 것이 좋다. + +문제가 명확히 끝났다면 다음 작업은 새 대화에서 시작하고, 필요한 컨텍스트는 저장소의 문서나 Git 커밋, PR 설명에 남기는 방식이 훨씬 건강하다. + +하지만 실제 작업에서는 한 흐름을 끊기 어려울 때가 있다. + +특히 수정 → 테스트 → CI → 리뷰 → 재수정 과정을 한 대화에서 계속 이어갈 때는 세션을 유지하는 편이 편하다. + +그럴 때 이 userscript가 일종의 안전장치가 됐다. + +## 지금의 결론 + +DevSpace를 ChatGPT에 연결한 장점은 포기하고 싶지 않았다. + +스마트폰에서 실제 개발 머신의 코드를 다루고, 출퇴근 중에도 작업을 이어갈 수 있다는 편리함은 너무 컸다. + +문제는 그 편리함을 오래 사용했을 때 브라우저 한 탭이 몇 GB씩 커지는 것이었다. + +Brave나 Orion으로 옮기는 것만으로는 내 환경에서 충분하지 않았다. 결국 내가 정착한 방법은 브라우저 선택이 아니라 **오래된 ChatGPT 화면을 적극적으로 버리는 것**이었다. + +Tampermonkey로 여러 버전을 만들며 실패하다가, iframe뿐 아니라 과거 메시지 DOM 전체까지 정리하는 v5.0으로 넘어간 뒤에야 24GB MacBook Air에서도 장시간 세션의 RAM과 swap 압박을 이전보다 관리 가능한 수준으로 유지할 수 있었다. + +아마 ChatGPT 웹의 DOM 구조는 계속 바뀔 것이다. 그래서 이 코드도 영구적인 해결책은 아니다. + +그래도 "AI 에이전트와 브라우저를 하루 종일 붙여 쓰는 시대에는 브라우저 탭 자체도 개발 환경의 일부로 관리해야 한다"는 점은 꽤 오래 남을 것 같다. diff --git a/content/blog/devspace-team-onboarding.mdx b/content/blog/devspace-team-onboarding.mdx new file mode 100644 index 0000000..5f234ea --- /dev/null +++ b/content/blog/devspace-team-onboarding.mdx @@ -0,0 +1,1010 @@ +--- +title: "DevSpace 팀 온보딩: ChatGPT 웹에서 로컬 코드를 직접 다루기" +date: 2026-08-13 +tags: ["DevSpace", "ChatGPT", "MCP", "ngrok", "Tailscale", "Cloudflare Tunnel", "팀협업"] +description: "4명이 그대로 따라 할 수 있도록 DevSpace 설치부터 고정 HTTPS 주소, ChatGPT MCP 연결, 첫 저장소 테스트와 보안 규칙까지 10~15분 실전 절차로 정리한다." +slug: "devspace-team-onboarding" +published: true +--- + +## TL;DR + +- DevSpace는 ChatGPT가 내 컴퓨터의 실제 프로젝트 폴더를 읽고, 수정하고, 테스트 명령까지 실행하게 해 주는 self-hosted MCP 서버다. +- DevSpace 자체가 Codex 사용량이나 ChatGPT 토큰 한도를 늘려 주는 것은 아니다. 대신 코드를 계속 복사해서 붙여넣는 대신 필요한 파일만 직접 읽게 할 수 있어서 컨텍스트 낭비와 작업 전환 비용을 줄이는 데 유용하다. +- 내 Mac mini는 `127.0.0.1:7676`에서 DevSpace를 실행하고 `https://devspace.oosu.dev`라는 고정 Cloudflare Tunnel 주소를 붙여 사용하고 있다. +- 개인 도메인이 없는 팀원은 **ngrok 무료 계정의 자동 할당 dev domain**을 기본 선택으로 추천한다. 계정에 할당된 `*.ngrok-free.app` 주소를 재사용할 수 있어서 집, 교육장, 카페처럼 네트워크가 바뀌어도 ChatGPT 쪽 MCP URL을 계속 바꿀 필요가 없다. +- 이미 Tailscale을 쓰고 있다면 **Tailscale Funnel**도 좋은 대안이다. `machine-name.tailnet-name.ts.net` 형태의 예측 가능한 주소를 계속 사용할 수 있다. +- Cloudflare Quick Tunnel은 계정이나 도메인 없이 바로 쓸 수 있지만 실행할 때마다 랜덤 `*.trycloudflare.com` 주소가 만들어지므로 이번 목적에는 적합하지 않다. + +이 글은 우리 4명 팀이 각자 자신의 컴퓨터에 DevSpace를 설치하고, ChatGPT 웹에서 실제 로컬 저장소를 직접 다루기 위한 팀 온보딩 문서다. + +Node와 Git이 이미 설치돼 있다면 10~15분을 목표로 한다. + +--- + +## 먼저 중요한 것: DevSpace가 해결하는 문제와 해결하지 않는 문제 + +현재 팀원들은 Codex Desktop으로 작업하면서 큰 저장소를 오래 다룰 때 사용량과 컨텍스트 압박을 자주 겪고 있다. + +DevSpace를 쓰는 이유를 여기서 정확히 구분해야 한다. + +DevSpace는 Codex 요금제의 사용량을 늘려 주거나 제한을 우회하는 도구가 아니다. + +대신 이런 작업을 줄여 준다. + +- 파일 내용을 매번 채팅에 붙여넣기 +- 저장소 구조를 매 세션 설명하기 +- AI가 수정한 코드를 다시 로컬에 복사하기 +- 테스트 명령을 사람이 대신 실행한 뒤 결과를 다시 붙여넣기 +- 병렬 작업 때문에 checkout이 꼬이는 문제 + +연결이 끝난 뒤에는 ChatGPT가 승인된 프로젝트 폴더 안에서 필요한 파일을 직접 읽고, 검색하고, 수정하고, 테스트를 실행할 수 있다. + +즉 목표는 다음 구조다. + +```text title="architecture.txt" +ChatGPT Web + ↓ HTTPS +고정 공개 주소 + ↓ +DevSpace MCP Server + ↓ +내가 허용한 로컬 프로젝트 폴더 + ↓ +Git / 테스트 / 빌드 / 로컬 도구 +``` + +--- + +## 0. 시작 전에 ChatGPT 권한부터 확인하기 + +설치보다 먼저 해야 할 일이 있다. + +2026년 8월 13일 기준 OpenAI 공식 안내에서는 **Developer mode와 full MCP 지원이 ChatGPT Business, Enterprise, Edu의 ChatGPT 웹에서 제공**되고 있다. Plugin Directory 자체는 더 넓은 플랜에서 보일 수 있지만, custom MCP를 만들고 실제 write/modify tool을 쓰는 권한은 플랜, 워크스페이스 설정, 사용자 역할에 따라 달라진다. + +제품 제공 범위와 UI는 베타 기간에 바뀔 수 있으므로 각자 계정에서 먼저 확인한다. + +### 왜 어떤 문서에는 Apps, 내 화면에는 Plugins라고 나오나? + +이 부분이 특히 헷갈린다. + +OpenAI는 **2026년 7월 9일 App Directory를 Plugin Directory로 변경**했다. 현재 Plugin은 workflow를 묶어 보여 주는 상위 단위이고, 실제 외부 MCP 서버에 연결되는 integration은 내부적으로 App으로 관리된다. + +그래서 다음 두 표현이 동시에 등장할 수 있다. + +```text title="plugin-vs-app.txt" +현재 ChatGPT 화면 +Plugins / 새 플러그인 + +OpenAI 공식 MCP 설정 문서 +Apps / Create +``` + +둘이 완전히 다른 기능이라는 뜻이 아니다. 우리 팀에서 실제로 DevSpace를 등록할 때는 **Plugins 메뉴의 새 플러그인 흐름**을 사용했고, 그 안에서 DevSpace MCP endpoint와 OAuth 인증을 연결했다. + +### Developer mode를 실제로 켜는 위치 + +먼저 ChatGPT **웹**을 연다. 모바일 앱에서 설정하려고 하지 않는다. + +Business 워크스페이스의 관리자/소유자라면 다음 경로를 먼저 확인한다. + +```text title="chatgpt-menu.txt" +Settings +→ Apps +→ Advanced Settings +→ Developer mode +``` + +화면 구성에 따라 아래 custom app 생성 화면에 들어가면서 Developer mode를 활성화할 수도 있다. + +```text title="chatgpt-workspace-menu.txt" +Workspace Settings +→ Apps +→ Create +``` + +Enterprise/Edu에서 일반 개발자 계정으로 사용하는 경우에는 관리자가 먼저 권한을 열어 줘야 한다. + +```text title="chatgpt-enterprise-admin.txt" +관리자 +Workspace Settings +→ Permissions & Roles +→ Connected Data +→ Developer mode / Create custom MCP connectors 허용 +``` + +그다음 권한을 받은 사용자가 자신의 계정에서 켠다. + +```text title="chatgpt-enterprise-user.txt" +사용자 +Settings +→ Apps +→ Advanced Settings +→ Developer mode ON +``` + +메뉴가 안 보이면 DevSpace 설치 문제부터 의심하지 말고 먼저 **내 ChatGPT 플랜, 워크스페이스 역할, 관리자 정책**을 확인한다. + +쓰기 권한이 없는 계정에서는 DevSpace 설치 자체가 ChatGPT 플랜 제한을 우회해 주지 않는다. + +공식 안내: + +- [OpenAI - Developer mode and MCP apps in ChatGPT](https://help.openai.com/en/articles/12584461-developer-mode-and-full-mcp-connectors-in-chatgpt-beta) +- [OpenAI - Plugins in ChatGPT and Codex](https://help.openai.com/en/articles/20001256-plugins-in-codexOpenAI) + +--- + +## 1. 준비물 확인 + +DevSpace 공식 요구사항은 다음과 같다. + +- Node `>=22.19 <27` +- npm +- Git +- Bash +- 외부에서 접근 가능한 HTTPS 주소 + +macOS와 Linux는 일반적인 Bash 환경이면 된다. + +Windows는 PowerShell이나 `cmd.exe`만 있는 환경은 아직 지원 대상이 아니다. Git Bash, WSL, MSYS2, Cygwin Bash 중 하나를 준비한다. 가장 간단한 선택은 Git Bash다. + +먼저 확인한다. + +```bash title="terminal" +node -v +npm -v +git --version +bash --version +``` + +내 현재 Mac mini에서는 Node 24 계열과 DevSpace 1.0.7을 사용하고 있다. + +버전 숫자를 내 환경과 똑같이 맞출 필요는 없고 DevSpace 공식 범위 안이면 된다. + +--- + +## 2. DevSpace 설치 + +가장 단순한 설치 방법이다. + +```bash title="terminal" +npm install -g @waishnav/devspace + +devspace --version +``` + +전역 설치가 싫다면 `npx`로도 사용할 수 있다. + +```bash title="terminal" +npx @waishnav/devspace init +npx @waishnav/devspace serve +``` + +이 글에서는 읽기 편하게 이후 명령을 `devspace` 기준으로 적는다. + +공식 문서: + +- [DevSpace Setup Guide](https://github.com/Waishnav/devspace/blob/main/docs/setup.md) + +--- + +## 3. 제일 중요한 부분: 고정 HTTPS 주소 만들기 + +처음 DevSpace를 쓸 때 내가 가장 불편했던 부분이 이 부분이었다. + +처음에는 임시 터널 주소를 사용했고, 집과 다른 장소를 오가거나 터널을 다시 띄울 때 주소가 달라졌다. + +DevSpace는 자신의 public URL을 기준으로 OAuth metadata와 Host allowlist를 만들기 때문에 공개 주소가 달라지면 설정과 인증 흐름을 다시 맞춰야 한다. + +그래서 지금 내 환경은 이렇게 고정해 두었다. + +```text title="my-current-devspace.txt" +DevSpace local host: 127.0.0.1 +DevSpace local port: 7676 +Public base URL: https://devspace.oosu.dev + +Cloudflare Tunnel +devspace.oosu.dev + → http://localhost:7676 +``` + +이 방식의 장점은 집 Wi-Fi에서 교육장 Wi-Fi로 바뀌어 로컬 IP가 달라져도 외부에서 보는 주소는 계속 `devspace.oosu.dev`라는 것이다. + +하지만 팀원 모두가 개인 도메인을 가지고 있을 필요는 없다. + +### 추천 A. 개인 도메인이 없다면 ngrok 무료 dev domain + +우리 팀 기본값으로는 이 방법이 가장 간단하다. + +ngrok 무료 계정에는 자동으로 할당되는 dev domain 하나가 제공된다. + +예를 들면 이런 형태다. + +```text title="ngrok-domain.txt" +https://example-name.ngrok-free.app +``` + +무료 플랜에서는 이름을 마음대로 고르거나 개인 도메인을 붙일 수는 없지만, 계정에 할당된 dev domain을 로컬 endpoint에 사용할 수 있다. + +### macOS + +```bash title="terminal" +brew install ngrok + +ngrok config add-authtoken "" + +ngrok http 7676 +``` + +### Windows + +ngrok 공식 Windows 다운로드 페이지에서 Microsoft Store, WinGet, Scoop 중 하나로 설치한 뒤 Git Bash에서 다음을 실행한다. + +```bash title="git-bash" +ngrok config add-authtoken "" + +ngrok http 7676 +``` + +처음 실행하면 다음처럼 HTTPS 주소가 출력된다. + +```text title="ngrok-output.txt" +Forwarding +https://example-name.ngrok-free.app +→ http://localhost:7676 +``` + +이 주소를 기억한다. + +중요한 포인트는 **DevSpace 설정에는 `/mcp`를 붙이지 않는 것**이다. + +```text title="correct-url.txt" +DevSpace publicBaseUrl +https://example-name.ngrok-free.app + +ChatGPT MCP endpoint +https://example-name.ngrok-free.app/mcp +``` + +ngrok 무료 플랜 공식 설명: + +- [ngrok Free Plan Limits](https://ngrok.com/docs/pricing-limits/free-plan-limits) +- [ngrok Share Localhost Quickstart](https://ngrok.com/docs/guides/share-localhost/quickstart) + +### 왜 Wi-Fi가 바뀌어도 괜찮나? + +ngrok agent는 집 공유기나 교육장 공유기에서 외부로 직접 포트를 여는 구조가 아니다. + +로컬 컴퓨터에서 ngrok cloud로 outbound 연결을 만들고, ngrok의 공개 주소가 그 연결을 `localhost:7676`으로 전달한다. + +따라서 네트워크가 바뀌었을 때 중요한 것은 로컬 IP 주소가 아니라 다음 두 가지다. + +- ngrok agent가 새 네트워크에서 인터넷에 다시 연결될 수 있는가 +- 같은 계정의 할당 dev domain을 계속 사용하는가 + +이 두 조건을 만족하면 ChatGPT에 저장한 MCP URL은 그대로 둘 수 있다. + +### 컴퓨터 재부팅까지 자동 복구하고 싶다면 + +ngrok은 macOS, Windows, Linux에서 OS background service로 설치할 수 있다. + +endpoint 설정을 `ngrok.yml`에 저장한 뒤 다음처럼 서비스로 등록할 수 있다. + +```bash title="terminal" +ngrok service install --config /path/to/ngrok.yml +ngrok service start +``` + +macOS에서는 `launchd`, Windows에서는 Windows Service로 등록된다. + +처음 팀 온보딩에서는 수동 `ngrok http 7676`으로 연결을 성공시킨 뒤, 필요할 때 background service까지 적용하는 것을 추천한다. + +공식 문서: + +- [ngrok Agent background service](https://ngrok.com/docs/agent) + +--- + +## 4. 대안: 이미 Tailscale을 쓰고 있다면 Funnel + +Tailscale을 이미 설치해서 쓰는 팀원이라면 Funnel도 좋다. + +Funnel은 로컬 서비스를 인터넷에 HTTPS로 공개하면서 다음처럼 tailnet 기반 주소를 사용한다. + +```text title="tailscale-url.txt" +https://my-mac.my-tailnet.ts.net +``` + +Tailscale 공식 문서에서도 Funnel DNS 이름을 predictable, stable한 주소로 설명한다. + +기본 흐름은 다음과 같다. + +```bash title="terminal" +tailscale funnel 7676 +``` + +환경에 따라 관리자 권한이 필요하면 다음처럼 실행한다. + +```bash title="terminal" +sudo tailscale funnel 7676 +``` + +출력 예시는 이런 형태다. + +```text title="tailscale-output.txt" +Available on the internet: +https://my-mac.my-tailnet.ts.net + +|-- / proxy http://127.0.0.1:7676 +``` + +그다음 DevSpace에는 origin만 저장한다. + +```text title="tailscale-devspace.txt" +publicBaseUrl +https://my-mac.my-tailnet.ts.net + +ChatGPT endpoint +https://my-mac.my-tailnet.ts.net/mcp +``` + +주의할 점도 있다. + +- Funnel은 MagicDNS와 HTTPS 설정이 필요하다. +- Funnel용 tailnet policy 권한이 필요하다. +- macOS는 Tailscale 배포 방식에 따라 CLI 사용 조건이 다르므로 공식 문서를 먼저 확인한다. + +공식 문서: + +- [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) +- [Tailscale Funnel examples](https://tailscale.com/docs/reference/examples/funnel) + +--- + +## 5. Cloudflare Quick Tunnel은 왜 추천하지 않나? + +개인 도메인도 없고 계정도 만들기 싫다면 다음 명령으로 Cloudflare Quick Tunnel을 만들 수 있다. + +```bash title="terminal" +cloudflared tunnel --url http://localhost:7676 +``` + +문제는 주소다. + +```text title="quick-tunnel.txt" +https://random-words.trycloudflare.com +``` + +Cloudflare 공식 문서상 Quick Tunnel은 실행할 때 랜덤 `*.trycloudflare.com` hostname을 만든다. + +프로세스를 새로 띄우면 URL이 달라질 수 있다. + +그러면 다음 값도 다시 맞춰야 한다. + +- DevSpace `publicBaseUrl` +- ChatGPT custom MCP endpoint +- 경우에 따라 OAuth 승인 + +잠깐 테스트할 때는 매우 편하지만, 우리가 원하는 것은 "집에서도 교육장에서도 같은 ChatGPT MCP 연결을 계속 쓰는 것"이므로 팀 기본값으로는 맞지 않는다. + +공식 문서: + +- [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) + +개인 도메인이 이미 있다면 내 환경처럼 Named Cloudflare Tunnel을 쓰는 것이 좋다. + +--- + +## 6. DevSpace 초기화 + +고정 HTTPS 주소를 만들었으면 이제 DevSpace를 초기화한다. + +```bash title="terminal" +devspace init +``` + +설정 중 중요한 항목은 세 가지다. + +### Project roots + +ChatGPT가 접근할 수 있는 로컬 폴더다. + +내 환경에서는 여러 프로젝트 때문에 허용 범위를 넓게 둔 적이 있지만, 팀원들에게는 처음부터 좁게 잡는 것을 추천한다. + +예를 들어 팀 저장소가 여기 모여 있다면: + +```text title="project-root-example.txt" +~/Documents/team-repos +``` + +이 상위 폴더만 허용한다. + +이렇게 하면 아래 저장소들을 모두 열 수 있다. + +```text title="team-repos.txt" +team-repos/ +├── ontology_dashboard +└── gen_data +``` + +다음처럼 홈 전체를 허용하는 것은 피한다. + +```text title="avoid-roots.txt" +~ +/ +C:\ +``` + +### Local port + +기본값을 그대로 쓴다. + +```text title="port.txt" +7676 +``` + +### Public base URL + +ngrok을 쓴다면: + +```text title="public-base-url.txt" +https://example-name.ngrok-free.app +``` + +Tailscale이라면: + +```text title="public-base-url-tailscale.txt" +https://my-mac.my-tailnet.ts.net +``` + +여기에는 `/mcp`를 붙이지 않는다. + +초기화가 끝나면 Owner password가 출력된다. + +이 비밀번호는 다음 위치에도 저장된다. + +```text title="devspace-auth-file.txt" +~/.devspace/auth.json +``` + +이 파일과 Owner password는 각자 자기 컴퓨터에서만 관리한다. + +팀 Slack, Discord, Notion, GitHub Issue에 올리지 않는다. + +--- + +## 7. DevSpace 실행과 진단 + +먼저 설정을 진단한다. + +```bash title="terminal" +devspace doctor +``` + +문제가 없다면 서버를 실행한다. + +```bash title="terminal" +devspace serve +``` + +기본 로컬 endpoint는 다음과 같다. + +```text title="local-mcp.txt" +http://127.0.0.1:7676/mcp +``` + +하지만 ChatGPT는 내 컴퓨터의 `127.0.0.1`에 직접 접근할 수 없으므로 실제 ChatGPT 설정에는 공개 HTTPS endpoint를 사용한다. + +--- + +## 8. ChatGPT에 DevSpace 연결 + +여기가 실제 연결에서 가장 중요한 부분이다. + +앞 단계에서 ChatGPT 웹의 Developer mode를 켰고, DevSpace 서버와 고정 HTTPS 주소가 모두 살아 있어야 한다. + +### 8-1. 현재 UI에서는 Plugins에서 시작한다 + +우리 팀에서 실제로 연결할 때 확인한 흐름은 다음과 같다. + +```text title="chatgpt-plugin-create.txt" +ChatGPT 웹 왼쪽 사이드바 +→ Plugins +→ 새 플러그인(New plugin) +``` + +OpenAI 공식 도움말에는 같은 underlying MCP integration 설정을 `Settings → Apps → Create`라고 설명하는 부분도 있다. 2026년 7월 Plugin Directory 전환 이후 UI와 문서 용어가 같이 존재하고 있으므로, **현재 화면에 Plugins가 보이면 Plugins → 새 플러그인 흐름을 우선 사용**하면 된다. + +Plugins 메뉴가 보이지 않지만 Developer mode 권한이 있는 관리형 워크스페이스라면 아래 경로도 확인한다. + + +```text title="chatgpt-create-app.txt" +Settings +→ Apps +→ Create +``` + +또는 관리자 계정에서는: + +```text title="chatgpt-workspace-create-app.txt" +Workspace Settings +→ Apps +→ Create +``` + +### 8-2. DevSpace MCP 정보를 입력한다 + +새 플러그인 또는 custom app 생성 화면에서 이름은 팀에서 통일하면 찾기 편하다. + +```text title="app-name.txt" +devspace-codex +``` + +Endpoint에는 이번에는 `/mcp`까지 붙인다. + +```text title="mcp-endpoint.txt" +https://example-name.ngrok-free.app/mcp +``` + +내 환경이라면: + +```text title="my-mcp-endpoint.txt" +https://devspace.oosu.dev/mcp +``` + +### 8-3. Authentication은 OAuth를 선택한다 + +DevSpace는 MCP endpoint를 OAuth로 보호한다. 따라서 인증 방식 선택 항목이 나오면 **OAuth**를 선택한다. + +```text title="chatgpt-auth.txt" +Authentication +→ OAuth +``` + +이 OAuth는 Google/GitHub 로그인 같은 별도 서비스 계정 인증이 아니다. DevSpace가 제공하는 OAuth 승인 페이지가 열리고, 여기서 `devspace init` 때 발급된 **Owner password**로 이 ChatGPT 연결을 승인하는 구조다. + +Owner password는 다음 위치에도 저장돼 있다. + +```text title="devspace-owner-password.txt" +~/.devspace/auth.json +``` + +파일 전체를 팀 채팅에 올리거나 password를 다른 팀원과 공유하면 안 된다. 네 명이 각각 자신의 DevSpace를 설치했다면 각자 서로 다른 Owner password를 사용한다. + +### 8-4. 실제 OAuth 승인과 Tool Scan + +현재 ChatGPT UI에서는 대략 다음 순서로 진행된다. + +1. 플러그인 이름을 `devspace-codex`로 입력한다. +2. MCP endpoint에 `https://고정주소/mcp`를 입력한다. +3. Authentication을 `OAuth`로 선택한다. +4. `Scan Tools` 또는 연결 검사를 실행한다. +5. 브라우저에 DevSpace OAuth 승인 페이지가 열리면 **내 Owner password**를 입력한다. +6. ChatGPT로 돌아와 tool scan이 끝날 때까지 기다린다. +7. `Create`, `Save` 또는 현재 UI의 생성 완료 버튼을 누른다. +8. 새 채팅에서 `@devspace-codex`가 선택되는지 확인한다. + +정상이라면 ChatGPT가 DevSpace의 `open_workspace`, `read`, `apply_patch`, `exec_command` 같은 MCP tool을 스캔해 사용할 수 있게 된다. + +OAuth 승인이 끝났는데 다시 인증을 반복해서 요구한다면 다음부터 확인한다. + +```text title="oauth-troubleshooting.txt" +1. DevSpace publicBaseUrl이 현재 고정 hostname과 같은가? +2. ChatGPT endpoint 끝에 /mcp가 있는가? +3. DevSpace 서버를 재시작한 뒤 OAuth client 상태가 바뀌지 않았는가? +4. Owner password를 내 ~/.devspace/auth.json 기준으로 입력했는가? +``` + +DevSpace 공식 문서상 MCP client가 연결될 때 Owner password 승인 페이지가 열리며, OAuth redirect host와 public URL도 서버 설정의 일부다. + +여기서 가장 흔한 실수는 URL 두 개를 반대로 쓰는 것이다. + +```text title="url-rule.txt" +DevSpace 설정 +https://host.example.com + +ChatGPT 설정 +https://host.example.com/mcp +``` + +DevSpace 공식 troubleshooting에도 별도 항목으로 나오는 실수다. + +--- + +## 9. 처음에는 코드를 수정시키지 말고 연결만 확인하기 + +첫 테스트는 읽기 전용으로 한다. + +ChatGPT 새 채팅에서 연결한 DevSpace app을 선택하거나 이름으로 호출한다. + +그리고 다음 프롬프트를 그대로 사용한다. + +```text title="first-test-prompt.txt" +@devspace-codex + +내 로컬 팀 저장소를 checkout 모드로 열어줘. + +<내 절대경로>/team-repos/ontology_dashboard + +코드는 수정하지 말고 아래만 확인해줘. +1. 현재 branch +2. git status +3. origin remote +4. 최근 commit 3개 +``` + +이 결과가 실제 로컬 저장소와 같으면 연결 성공이다. + +다음으로 `gen_data`도 같은 방식으로 확인한다. + +```text title="gen-data-test-prompt.txt" +@devspace-codex + +<내 절대경로>/team-repos/gen_data 를 열고 +코드는 수정하지 말고 git status와 최근 commit만 확인해줘. +``` + +--- + +## 10. 실제 팀 작업에서는 worktree를 기본으로 쓰기 + +우리 팀은 같은 저장소를 여러 명, 여러 세션이 병렬로 작업할 일이 많다. + +기존 checkout branch를 직접 바꾸게 하면 다른 작업과 충돌하기 쉽다. + +그래서 실제 코드 작업을 맡길 때는 다음 문장을 기본으로 붙이는 것을 추천한다. + +```text title="worktree-prompt.txt" +@devspace-codex + +기존 checkout branch는 변경하지 마. + +최신 origin 상태를 fetch한 뒤 +이번 작업용 독립 DevSpace worktree를 만들어서 작업해줘. + +작업 후에는 +1. 테스트 +2. git diff 확인 +3. git status 확인 +까지 진행해줘. +``` + +커밋과 push까지 맡길 작업이라면 그 부분을 명시적으로 추가한다. + +--- + +## 11. Wi-Fi가 바뀌었는데 갑자기 연결이 안 될 때 + +고정 주소를 제대로 썼다면 ChatGPT 설정부터 지우지 않는다. + +다음 순서로 확인한다. + +### 1. DevSpace 확인 + +```bash title="terminal" +devspace doctor +``` + +### 2. DevSpace 서버 확인 + +```bash title="terminal" +devspace serve +``` + +### 3. ngrok 확인 + +```bash title="terminal" +ngrok http 7676 +``` + +출력되는 domain이 기존에 ChatGPT에 등록한 domain과 같은지 본다. + +### Tailscale 사용자라면 + +```bash title="terminal" +tailscale funnel status +``` + +고정 hostname이 그대로라면 ChatGPT app 설정은 그대로 둔다. + +--- + +## 12. 정말 URL이 바뀐 경우 + +Quick Tunnel 같은 임시 주소를 사용했다면 URL 자체가 바뀔 수 있다. + +그때는 DevSpace 설정부터 바꾼다. + +```bash title="terminal" +devspace config set publicBaseUrl https://NEW-HOST.example.com +``` + +그리고 다시 실행한다. + +```bash title="terminal" +devspace serve +``` + +ChatGPT custom MCP endpoint도 새 주소의 `/mcp`로 수정하거나 다시 만든다. + +```text title="new-chatgpt-url.txt" +https://NEW-HOST.example.com/mcp +``` + +이 번거로움을 피하려고 처음부터 ngrok 할당 dev domain, Tailscale Funnel, Named Cloudflare Tunnel처럼 고정 hostname을 쓰는 것이다. + +--- + +## 13. `unknown workspaceId`는 인증 실패가 아니다 + +DevSpace를 재시작하거나 세션 상태가 바뀐 뒤 ChatGPT가 예전 `workspaceId`를 다시 사용하면 다음과 비슷한 오류가 날 수 있다. + +```text title="workspace-error.txt" +unknown workspaceId +``` + +이 경우 Owner password나 tunnel 설정을 처음부터 다시 만들 필요는 없다. + +해당 프로젝트를 다시 `open_workspace` 하면 된다. + +`workspaceId`는 프로젝트 자체의 영구 ID가 아니라 작업 세션을 위한 식별자로 보는 편이 맞다. + +--- + +## 14. 자주 막히는 문제 + +### `devspace` command not found + +전역 npm bin 경로 문제일 수 있다. + +일단 `npx`로 확인한다. + +```bash title="terminal" +npx @waishnav/devspace doctor +``` + +### Node version 오류 + +```bash title="terminal" +node --version +``` + +DevSpace 공식 범위인 `>=22.19 <27`인지 확인한다. + +### 403 또는 Host header 오류 + +DevSpace의 `publicBaseUrl`과 실제 tunnel hostname이 같은지 본다. + +```bash title="terminal" +devspace doctor +``` + +### ChatGPT에서 MCP tool scan 실패 + +다시 한번 URL을 확인한다. + +```text title="scan-check.txt" +잘못된 예 +DevSpace publicBaseUrl = https://host.example.com/mcp + +올바른 예 +DevSpace publicBaseUrl = https://host.example.com +ChatGPT endpoint = https://host.example.com/mcp +``` + +### Owner password가 안 맞음 + +자기 컴퓨터에서만 다음 파일을 확인한다. + +```text title="owner-password.txt" +~/.devspace/auth.json +``` + +필요하면 setup을 다시 생성할 수 있다. + +```bash title="terminal" +devspace init --force +``` + +공식 troubleshooting: + +- [DevSpace Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md) + +--- + +## 15. 보안 규칙은 꼭 지키기 + +DevSpace는 단순한 파일 뷰어가 아니다. + +연결된 MCP 클라이언트는 승인된 workspace 안에서 파일을 수정할 수 있고, shell 명령도 실행할 수 있다. + +DevSpace 공식 문서도 이를 선택한 개발 머신에 대한 remote access로 취급하라고 안내한다. + +우리 팀에서는 다음 규칙을 지키는 것이 좋다. + +### Allowed root는 좁게 + +좋은 예: + +```text title="good-roots.txt" +~/Documents/team-repos +~/work/Biz-CollabCraft +``` + +피해야 할 예: + +```text title="bad-roots.txt" +~ +/ +C:\ +``` + +### Owner password 공유 금지 + +다음 파일은 Git에 올리지 않는다. + +```text title="private-file.txt" +~/.devspace/auth.json +``` + +### `.env`, SSH key, cloud credential 주의 + +가능하면 협업 저장소와 민감한 개인 credential 디렉터리를 같은 allowed root 안에 두지 않는다. + +### Shell 권한을 가볍게 보지 않기 + +DevSpace file tool에는 경로 제한이 적용되지만 shell 명령은 로컬 사용자 권한으로 실행되는 강력한 기능이다. + +연결하는 ChatGPT 계정과 MCP 앱을 신뢰할 수 있는 코딩 파트너처럼 취급해야 한다. + +공식 보안 문서: + +- [DevSpace Security Model](https://github.com/Waishnav/devspace/security) + +--- + +## 16. 우리 팀 권장 조합 + +### 개인 도메인이 없는 대부분의 팀원 + +```text title="recommended-default.txt" +DevSpace ++ ngrok 무료 계정 ++ 자동 할당 *.ngrok-free.app ++ ChatGPT custom MCP app +``` + +가장 설치가 빠르고 네트워크가 바뀌어도 공개 hostname을 유지하기 쉽다. + +### 이미 Tailscale을 쓰는 팀원 + +```text title="recommended-tailscale.txt" +DevSpace ++ Tailscale Funnel ++ *.ts.net ++ ChatGPT custom MCP app +``` + +### 개인 도메인을 이미 관리하는 경우 + +```text title="recommended-cloudflare.txt" +DevSpace ++ Named Cloudflare Tunnel ++ devspace.example.com ++ ChatGPT custom MCP app +``` + +이게 현재 내 Mac mini에서 사용하는 방식과 가장 가깝다. + +--- + +## 17. 실제로 써보면 좋은 점: 코딩 화면이 한 컴퓨터에 묶이지 않는다 + +DevSpace의 장점은 단순히 "ChatGPT가 로컬 파일을 읽는다"에서 끝나지 않는다. + +Codex Desktop만 사용할 때는 보통 코딩 작업과 대화가 그 컴퓨터의 데스크톱 앱을 중심으로 돌아간다. DevSpace를 ChatGPT 웹에 연결하면 **실제 코드는 내 컴퓨터에 그대로 두면서 ChatGPT 대화를 작업 제어 화면처럼 사용할 수 있다.** + +예를 들어 교육장에서 노트북으로 다음과 같이 작업할 수 있다. + +```text title="cross-device-example.txt" +노트북의 DevSpace 서버 + ↓ +ChatGPT 웹에서 +"PR 리뷰 반영 → 테스트 → commit → push까지 진행해줘" + ↓ +작업 결과와 대화가 ChatGPT 계정의 같은 conversation에 남음 + ↓ +이동 중 휴대폰에서 같은 conversation을 열어 +진행 결과, diff 요약, 에러 내용을 확인 + ↓ +후속 요구사항이나 다음 작업 방향을 바로 메시지로 남김 +``` + +즉, 프로젝트 폴더를 휴대폰에 복사하거나 원격 데스크톱으로 작은 화면을 조작하지 않아도 **같은 ChatGPT 대화 흐름을 여러 기기에서 확인하고 이어갈 수 있다는 점**이 꽤 편하다. + +특히 우리 팀처럼 여러 PR과 저장소를 병렬로 다루는 경우에는 다음 장점이 있다. + +- 자리를 비운 뒤에도 휴대폰에서 작업 결과와 실패 원인을 확인하기 쉽다. +- 팀 채팅에서 새 요구사항이 나오면 같은 ChatGPT conversation에 바로 추가해 다음 작업 컨텍스트로 남길 수 있다. +- 데스크톱 Codex 앱의 특정 로컬 UI에만 작업 기록이 갇히지 않고 ChatGPT conversation을 기준으로 이어가기 쉽다. +- 다음에 컴퓨터 앞에 돌아왔을 때 앞에서 무슨 작업을 했는지 다시 설명하는 비용이 줄어든다. + +다만 **2026년 8월 13일 현재 OpenAI 공식 안내에서 MCP apps는 web-only**다. Plugin Directory는 ChatGPT 웹과 데스크톱에서 사용할 수 있지만, 모바일 앱에서 DevSpace MCP tool을 직접 호출하는 흐름까지 공식 지원되는 것은 아니다. + +따라서 현재 기준으로는 이렇게 이해하는 게 정확하다. + +```text title="mobile-boundary.txt" +모바일에서 잘하는 것 +→ 같은 ChatGPT conversation 열기 +→ 이전 작업 내용과 결과 확인 +→ 다음 요구사항과 수정 방향 전달 +→ 작업 지시를 대화 컨텍스트에 남기기 + +현재 공식 지원을 전제로 하면 웹에서 하는 것 +→ DevSpace MCP를 실제 호출 +→ 로컬 파일 읽기/수정 +→ shell 명령 실행 +→ 테스트, commit, push +``` + +그리고 당연하지만 **DevSpace가 설치된 컴퓨터가 꺼져 있거나 잠자기 상태로 네트워크 연결이 끊기면 로컬 작업은 실행할 수 없다.** DevSpace 서버와 tunnel이 살아 있어야 ChatGPT 웹에서 그 컴퓨터에 접근할 수 있다. + +### macOS 팁: 이동 중에도 DevSpace 작업을 계속 돌리고 싶다면 + +나는 MacBook을 들고 이동할 때도 DevSpace 작업이 중간에 끊기지 않도록 **시스템 잠자기를 임시로 비활성화**해서 사용한다. 배터리와 전원 연결 상태에 모두 적용하려면 Terminal에서 다음 명령을 실행한다. + +```bash title="macos-disable-sleep.sh" +sudo pmset -a disablesleep 1 +``` + +그다음 화면 밝기는 가능한 한 낮춰 두고, DevSpace 서버와 tunnel이 계속 실행되는 상태로 둔다. 이렇게 해 두면 이동 중에도 MacBook이 잠들어서 장시간 작업이나 테스트가 중간에 끊기는 상황을 줄일 수 있다. + +사용을 마쳤다면 **반드시 원래 설정으로 복구**한다. + +```bash title="macos-restore-sleep.sh" +sudo pmset -a disablesleep 0 +``` + +이 설정은 macOS 전체의 자동 잠자기를 막는 것이므로 상시 켜 두는 용도로 사용하지 않는 편이 좋다. 특히 다음은 꼭 주의한다. + +- 잠자기를 막아 두면 배터리 소모가 계속된다. +- 빌드, 테스트, Docker 같은 작업이 돌아가는 동안에는 CPU 사용량과 발열이 올라갈 수 있다. +- **작동 중인 MacBook을 백팩, 파우치, 서랍처럼 통풍이 거의 없는 밀폐 공간에 넣어 두지 않는다.** 덮개를 닫아 둘 때도 열이 빠질 수 있는 곳에 둔다. +- 작업이 끝났거나 장시간 사용하지 않을 때는 `sudo pmset -a disablesleep 0`으로 되돌린다. + +즉, 이 방법은 "이동하는 동안 잠깐 원격 작업을 계속 돌려야 할 때" 쓰는 임시 운용 팁으로 생각하는 게 좋다. + +그래도 "로컬 코드는 내 컴퓨터에 두고, 작업 대화는 어디서든 확인한다"는 구조 자체가 기존 데스크톱 전용 코딩 에이전트와 비교했을 때 실사용에서 꽤 큰 차이다. + +--- + +## 설치 완료 체크리스트 + +- Node, npm, Git, Bash 버전 확인 +- DevSpace 설치 +- `devspace doctor` 통과 +- 고정 HTTPS hostname 확보 +- DevSpace `publicBaseUrl`에는 `/mcp` 없이 저장 +- ChatGPT endpoint에는 `/mcp` 포함 +- Owner password로 본인 연결만 승인 +- `ontology_dashboard` 읽기 전용 테스트 성공 +- `gen_data` 읽기 전용 테스트 성공 +- allowed roots가 필요한 협업 폴더 범위로 제한됨 + +여기까지 되면 팀원이 Codex Desktop만 사용하지 않고, ChatGPT 웹에서도 자신의 실제 로컬 Git 저장소를 직접 연결해서 작업할 수 있는 기본 환경이 준비된 것이다. + +--- + +## 참고 문서 + +- [DevSpace GitHub](https://github.com/Waishnav/devspace) +- [DevSpace Setup Guide](https://github.com/Waishnav/devspace/blob/main/docs/setup.md) +- [DevSpace Troubleshooting Gotchas](https://github.com/Waishnav/devspace/blob/main/docs/gotchas.md) +- [DevSpace Security Model](https://github.com/Waishnav/devspace/security) +- [OpenAI Developer mode and MCP apps](https://help.openai.com/en/articles/12584461-developer-mode-and-full-mcp-connectors-in-chatgpt-beta) +- [OpenAI Plugins in ChatGPT and Codex](https://help.openai.com/en/articles/20001256-plugins-in-codexOpenAI) +- [ngrok Free Plan Limits](https://ngrok.com/docs/pricing-limits/free-plan-limits) +- [ngrok Share Localhost Quickstart](https://ngrok.com/docs/guides/share-localhost/quickstart) +- [ngrok Agent background service](https://ngrok.com/docs/agent) +- [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) +- [Cloudflare Quick Tunnels](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/) diff --git a/content/blog/m1ddc-monitor-brightness.mdx b/content/blog/m1ddc-monitor-brightness.mdx new file mode 100644 index 0000000..4fd3cc3 --- /dev/null +++ b/content/blog/m1ddc-monitor-brightness.mdx @@ -0,0 +1,231 @@ +--- +title: "한성 TFX173T 모니터 밝기 초기화 문제, m1ddc와 Swift로 완전 자동화하기" +date: 2026-05-13 +tags: ["macOS", "m1ddc", "DDC/CI", "LaunchAgent", "Swift", "한성TFX173T", "모니터"] +description: "한성 포터블 모니터를 연결할 때마다 백라이트 밝기가 1로 초기화되는 문제를 터미널 명령어로 해결하고, Swift 이벤트 기반 데몬으로 자동화한 과정을 기록한다." +slug: "m1ddc-monitor-brightness" +published: true +--- + +## TL;DR + +- 한성 TFX173T는 보조전원 없이 Type-C만 연결하면 OSD 설정이 초기화되도록 설계되어 있다. +- `m1ddc` 툴로 DDC/CI를 통해 밝기를 소프트웨어로 제어할 수 있었다. +- 폴링 방식 대신 `NSApplication.didChangeScreenParametersNotification` 이벤트를 감지하는 Swift 데몬과 LaunchAgent로 완전 자동화했다. + +## 한성 TFX173T 밝기가 매번 1로 초기화되는 문제 + +한성 TFX173T 17인치 포터블 모니터를 맥북 프로에 Type-C로 연결해서 쓰고 있다. 화면 크기나 휴대성은 괜찮은데, 케이블을 뽑았다가 다시 꽂을 때마다 백라이트 밝기가 1로 초기화되는 문제가 있었다. + +처음에는 별일 아니라고 생각했는데, 매번 OSD 버튼을 눌러 밝기를 10까지 올리는 일이 반복되니까 꽤 귀찮았다. 그래서 공식 FAQ부터 찾아봤다. + +> TFX173T는 보조전원 없이 Type-C to C로 연결할 경우 OSD 설정 데이터가 초기화되도록 설계되어 있다. 이유는 보조전원 없이 연결 시 연결 기기에서 모니터가 출력될 수 있는 최소한의 전력만 사용하기 때문이다. + +공식 해결법은 보조전원을 먼저 연결한 뒤 화면 케이블을 연결하는 것이다. 분리할 때는 반대 순서로 해야 한다. 맞는 말이긴 한데, 매번 순서를 신경 써야 한다면 내가 해결하고 싶은 귀찮음이 그대로 남는다. + +그래서 방향을 바꿨다. 하드웨어 설정이 초기화된다면, 연결 직후 소프트웨어로 밝기를 다시 올리면 되지 않을까? + +## DDC/CI로 모니터 밝기를 제어할 수 있을까? + +모니터 밝기를 소프트웨어로 제어하는 표준 프로토콜로 DDC/CI가 있다. macOS에서 DDC/CI를 다루는 도구로 `ddcctl`이 많이 보였고, 먼저 이걸 시도했다. + +```bash title="terminal" +brew install ddcctl +ddcctl -d 1 -b 10 +``` + +결과는 여기서 막혔다. + +```text title="ddcctl-output.txt" +D: CGDisplay 67A24AAD-... (1080x1920 90°) +D: CGDisplay 2FFCCCE9-... (1920x1080 0°) +I: found 2 external displays +E: Failed to parse WindowServer's preferences! +E: Failed to acquire framebuffer device for display +``` + +디스플레이 자체는 인식됐다. 그런데 `Failed to acquire framebuffer device` 에러가 났다. macOS 최신 버전에서 SIP, 그러니까 시스템 무결성 보호가 강화되면서 `ddcctl`이 framebuffer에 직접 접근하는 방식이 막힌 것으로 보였다. + +여기서 `ddcctl`은 포기했다. 이 방향은 너무 낮은 레벨에서 막힌 느낌이었다. + +## m1ddc set luminance가 안 되는 것처럼 보인 이유 + +다음으로 시도한 도구는 `m1ddc`였다. `m1ddc`는 `ddcctl`과 달리 framebuffer 접근 없이 I2C로 직접 DDC 통신을 하는 도구라 SIP 문제를 우회할 수 있다. 특히 Apple Silicon Mac과 USB-C DisplayPort Alt Mode 환경에 맞춰져 있다. + +```bash title="terminal" +brew install m1ddc +m1ddc get luminance +# 110 +m1ddc set luminance 80 +# 80 +m1ddc get luminance +# 110 +``` + +이상했다. `set` 명령은 전달되는 것처럼 보이는데, 다시 `get`으로 확인하면 110으로 돌아왔다. 처음에는 이 모니터가 luminance VCP 코드, 즉 `0x10`을 외부에서 쓰는 걸 무시하는 줄 알았다. + +그런데 문제는 모니터가 아니라 내가 보고 있던 디스플레이 번호였다. + +## m1ddc display list detailed로 디스플레이 번호 확인하기 + +연결된 디스플레이 목록을 자세히 확인했다. + +```bash title="terminal" +m1ddc display list detailed +``` + +```text title="display-list.txt" +[1] (null) (37D8832A-...) ← 맥북 내장 디스플레이 +[2] TFX173T (67A24AAD-...) ← 한성 모니터 +[3] LG IPS FULLHD (2FFCCCE9-) ← LG 모니터 +``` + +여기서 중요한 사실을 발견했다. 맥북 프로의 외장 디스플레이로 연결되어 있다 보니, 내가 생각한 `display 1`과 실제 제어 대상이 엇갈리고 있었다. 디스플레이 번호를 명시하지 않으면 `display list`의 첫 번째 외장 디스플레이처럼 동작하는 구간도 있어서 더 헷갈렸다. + +또 하나 확인한 점은 LG 모니터였다. LG 모니터는 HDMI로 연결되어 있었고, `m1ddc`가 제어하지 못했다. `m1ddc` 공식 문서에도 내장 HDMI 포트로 연결된 디스플레이는 지원하지 않는다고 되어 있다. 내 환경에서는 DDC/CI 제어가 USB-C DisplayPort Alt Mode로 연결된 한성 모니터에서만 동작했다. + +디스플레이 번호를 정확히 지정해서 다시 시도했다. + +```bash title="terminal" +m1ddc display 2 set luminance 100 +m1ddc display 2 get luminance +# 100 +``` + +됐다. 이번에는 진짜로 반영됐다. + +최대 밝기도 확인했다. + +```bash title="terminal" +m1ddc display 2 max luminance +# 100 +``` + +결론은 간단했다. 한성 TFX173T는 `m1ddc display 2 set luminance 100`으로 제어하면 된다. + +## LaunchAgent 폴링 방식은 왜 별로였나? + +이제 남은 일은 자동화였다. 케이블을 연결할 때마다 내가 직접 명령을 치면 해결이라고 부르기 민망하다. 처음에는 LaunchAgent의 `StartInterval`로 주기 실행하는 방식을 시도했다. + +```xml title="com.user.monitor-brightness.plist" +StartInterval +30 +``` + +30초마다 아래 명령을 실행하는 방식이다. + +```bash title="terminal" +m1ddc display 2 set luminance 100 +``` + +작동은 했다. 하지만 바로 마음에 안 드는 점들이 보였다. + +1. 지연 시간이 들쑥날쑥하다. 케이블 연결 직후 타이머가 거의 끝나 있으면 금방 적용되지만, 타이머가 막 리셋됐으면 최대 30초 이상 기다려야 한다. +2. 모니터가 연결되어 있든 없든 30초마다 프로세스를 실행한다. +3. 이벤트가 분명히 있는 작업을 폴링으로 처리하는 느낌이 지저분했다. + +`sleep`도 줄여봤다. `sleep 3`은 괜찮았는데 `sleep 2`로 줄이면 DDC 통신이 준비되기 전에 명령이 날아가서 적용이 안 됐다. 내 환경에서는 연결 후 약 3초가 현실적인 최솟값이었다. + +## Swift 이벤트 기반 데몬으로 바꾸기 + +macOS는 디스플레이가 연결되거나 해제될 때 `NSApplication.didChangeScreenParametersNotification` 노티피케이션을 발생시킨다. 이걸 감지하면 폴링 없이 이벤트 기반으로 밝기 명령을 실행할 수 있다. + +다음 Swift 스크립트를 작성했다. + +```swift title="monitor-brightness.swift" +import Cocoa + +func setMonitorBrightness() { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + let task = Process() + task.launchPath = "/bin/sh" + task.arguments = ["-c", "/opt/homebrew/bin/m1ddc display 2 set luminance 100"] + try? task.run() + } +} + +NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main +) { _ in + setMonitorBrightness() +} + +setMonitorBrightness() +NSApplication.shared.run() +``` + +컴파일은 이렇게 했다. + +```bash title="terminal" +swiftc ~/monitor-brightness.swift -o ~/monitor-brightness +``` + +테스트는 단순했다. 케이블을 뽑았다가 다시 꽂았다. 약 3~4초 후 밝기가 자동으로 100까지 올라왔다. 됐다. 이벤트가 없을 때는 아무것도 하지 않고, 연결 이벤트가 생겼을 때만 반응한다. + +## LaunchAgent로 부팅 시 자동 실행하기 + +마지막으로 Swift 데몬을 macOS 로그인 시 자동 실행되도록 LaunchAgent에 등록했다. + +기존 폴링 방식 LaunchAgent를 먼저 제거했다. + +```bash title="terminal" +launchctl unload ~/Library/LaunchAgents/com.user.monitor-brightness.plist +rm ~/Library/LaunchAgents/com.user.monitor-brightness.plist +``` + +새 LaunchAgent를 만들었다. + +```bash title="terminal" +cat > ~/Library/LaunchAgents/com.user.monitor-brightness.plist << 'EOF' + + + + + Label + com.user.monitor-brightness + ProgramArguments + + /Users/gabriel/monitor-brightness + + RunAtLoad + + KeepAlive + + + +EOF + +launchctl load ~/Library/LaunchAgents/com.user.monitor-brightness.plist +``` + +설정 의미는 이렇다. + +- `RunAtLoad: true`: 로그인 시 자동 실행 +- `KeepAlive: true`: 데몬이 종료되면 자동 재시작 +- `StartInterval` 없음: 폴링 없이 이벤트 기반으로만 동작 + +등록 상태도 확인했다. + +```bash title="terminal" +launchctl list | grep monitor-brightness +# - 0 com.user.monitor-brightness +``` + +여기서 `0`은 에러 없이 정상 실행 중이라는 뜻이다. + +## 정리: Type-C만 꽂아도 5초 안에 밝기 복구 + +결과적으로 보조전원 없이 Type-C만 꽂아도 5초 이내에 자동으로 밝기 100으로 올라오게 됐다. 처음에 원했던 "연결하면 알아서 밝아지는" 동작이 완성됐다. + +이번 삽질을 정리하면 이렇다. + +- `ddcctl`은 SIP 때문에 framebuffer 접근에서 실패했다. +- `m1ddc`는 동작했지만, 디스플레이 번호를 잘못 이해해서 처음에는 반영이 안 되는 것처럼 보였다. +- `m1ddc display list detailed`로 TFX173T가 `display 2`임을 확인했다. +- LG 모니터는 HDMI 연결이라 이 환경에서는 `m1ddc` 제어 대상이 아니었다. +- LaunchAgent 폴링 방식은 작동했지만 지연과 낭비가 있었다. +- Swift 이벤트 기반 데몬으로 바꾸니 연결 직후 일정하게 밝기가 복구됐다. + + diff --git a/db/migrations/009_create_ask_events.sql b/db/migrations/009_create_ask_events.sql index dba876e..ca0919e 100644 --- a/db/migrations/009_create_ask_events.sql +++ b/db/migrations/009_create_ask_events.sql @@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS ask_events ( language text CHECK (language IS NULL OR language IN ('ko', 'en')), question text NOT NULL DEFAULT '', question_redacted text NOT NULL DEFAULT '', + answer_preview text, normalized_intent text, answer_mode text NOT NULL DEFAULT 'unknown' CHECK (answer_mode IN ('direct_cache', 'rag', 'fallback', 'smalltalk', 'safety', 'unknown')), @@ -23,7 +24,18 @@ CREATE TABLE IF NOT EXISTS ask_events ( utm_source text, utm_medium text, utm_campaign text, + ip_hash text, + cf_ray text, country text, + geo_city text, + geo_region text, + geo_region_code text, + geo_postal_code text, + geo_timezone text, + geo_latitude numeric(9, 6), + geo_longitude numeric(9, 6), + user_agent_hash text, + accept_language text, device_type text, browser text, os text diff --git a/db/migrations/010_add_ask_event_answer_preview.sql b/db/migrations/010_add_ask_event_answer_preview.sql new file mode 100644 index 0000000..07f007d --- /dev/null +++ b/db/migrations/010_add_ask_event_answer_preview.sql @@ -0,0 +1,2 @@ +ALTER TABLE ask_events + ADD COLUMN IF NOT EXISTS answer_preview text; diff --git a/db/migrations/011_add_visitor_events_and_identity_context.sql b/db/migrations/011_add_visitor_events_and_identity_context.sql new file mode 100644 index 0000000..87af3a7 --- /dev/null +++ b/db/migrations/011_add_visitor_events_and_identity_context.sql @@ -0,0 +1,65 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +ALTER TABLE ask_events + ADD COLUMN IF NOT EXISTS ip_hash text, + ADD COLUMN IF NOT EXISTS cf_ray text, + ADD COLUMN IF NOT EXISTS geo_city text, + ADD COLUMN IF NOT EXISTS geo_region text, + ADD COLUMN IF NOT EXISTS geo_region_code text, + ADD COLUMN IF NOT EXISTS geo_postal_code text, + ADD COLUMN IF NOT EXISTS geo_timezone text, + ADD COLUMN IF NOT EXISTS geo_latitude numeric(9, 6), + ADD COLUMN IF NOT EXISTS geo_longitude numeric(9, 6), + ADD COLUMN IF NOT EXISTS user_agent_hash text, + ADD COLUMN IF NOT EXISTS accept_language text; + +CREATE INDEX IF NOT EXISTS ask_events_ip_hash_idx + ON ask_events (ip_hash); + +CREATE TABLE IF NOT EXISTS visitor_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz NOT NULL DEFAULT now(), + session_id text NOT NULL DEFAULT '' CHECK (char_length(session_id) <= 128), + ip_hash text, + event_type text NOT NULL DEFAULT 'page_view' CHECK (event_type IN ('page_view')), + path text, + referrer text, + utm_source text, + utm_medium text, + utm_campaign text, + cf_ray text, + country text, + geo_city text, + geo_region text, + geo_region_code text, + geo_postal_code text, + geo_timezone text, + geo_latitude numeric(9, 6), + geo_longitude numeric(9, 6), + user_agent_hash text, + accept_language text, + device_type text, + browser text, + os text, + screen_width integer CHECK (screen_width IS NULL OR screen_width >= 0), + screen_height integer CHECK (screen_height IS NULL OR screen_height >= 0), + viewport_width integer CHECK (viewport_width IS NULL OR viewport_width >= 0), + viewport_height integer CHECK (viewport_height IS NULL OR viewport_height >= 0), + client_timezone text, + client_language text +); + +CREATE INDEX IF NOT EXISTS visitor_events_created_at_idx + ON visitor_events (created_at DESC); +CREATE INDEX IF NOT EXISTS visitor_events_session_id_idx + ON visitor_events (session_id); +CREATE INDEX IF NOT EXISTS visitor_events_ip_hash_idx + ON visitor_events (ip_hash); +CREATE INDEX IF NOT EXISTS visitor_events_path_idx + ON visitor_events (path); +CREATE INDEX IF NOT EXISTS visitor_events_referrer_idx + ON visitor_events (referrer); +CREATE INDEX IF NOT EXISTS visitor_events_country_idx + ON visitor_events (country); +CREATE INDEX IF NOT EXISTS visitor_events_utm_source_idx + ON visitor_events (utm_source); diff --git a/docs/analytics.md b/docs/analytics.md index 6518d28..e33d5b3 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -1,10 +1,11 @@ # AskOosu Analytics And Question Logging -Last updated: 2026-05-13 +Last updated: 2026-05-19 ## Storage Choice -AskOosu uses the existing PostgreSQL database for custom AskOosu question logs. +AskOosu uses the existing PostgreSQL database for custom AskOosu visitor and +question logs. This is the most natural option for this codebase because the project already has: @@ -19,11 +20,23 @@ pattern is already sufficient. ## What Is Logged +`visitor_events` stores page-view and acquisition signals: + +- anonymous `session_id` +- `ip_hash`, generated server-side with HMAC-SHA256 +- path, referrer without query string, UTM values +- Cloudflare country and optional city/region/timezone/lat-lon headers +- Cloudflare Ray ID +- user-agent hash plus coarse device/browser/OS labels +- screen, viewport, browser timezone, and browser language + `ask_events` stores privacy-conscious product-quality signals: - anonymous `session_id` +- `ip_hash`, so question behavior can be correlated with visits without raw IP storage - language - sanitized `question` and `question_redacted` +- sanitized `answer_preview`, a short redacted response excerpt for quality review - normalized intent - answer mode: `direct_cache`, `rag`, `fallback`, `smalltalk`, `safety`, `unknown` - confidence @@ -32,9 +45,19 @@ pattern is already sufficient. - latency - optional user feedback value - page path, referrer without query string, UTM values -- country code, device type, browser, OS +- country code, optional city/region fields, device type, browser, OS -Raw IP addresses and precise geolocation are not stored. +Raw IP addresses are not stored. `ip_hash` is intended for repeat-visitor +analysis, not identity proof. Set a stable production-only salt so the same IP +hashes consistently over time: + +```bash +ASKOOSU_IP_HASH_SALT= +``` + +City/region fields depend on Cloudflare request headers. In Cloudflare, enable +visitor location headers if city/region-level reporting is needed. Location from +IP is approximate and may reflect VPNs, carriers, or company networks. ## Cloudflare Web Analytics @@ -80,6 +103,92 @@ WHERE created_at < now() - interval '180 days'; ## Useful Read Queries +Daily visitor stats: + +```sql +SELECT + date_trunc('day', created_at) AS day, + count(*) AS page_views, + count(DISTINCT session_id) AS sessions, + count(DISTINCT ip_hash) FILTER (WHERE ip_hash IS NOT NULL) AS ip_hashes +FROM visitor_events +WHERE created_at >= now() - interval '30 days' +GROUP BY day +ORDER BY day DESC; +``` + +Top entry and content paths: + +```sql +SELECT path, count(*) AS page_views, count(DISTINCT session_id) AS sessions +FROM visitor_events +WHERE created_at >= now() - interval '30 days' +GROUP BY path +ORDER BY page_views DESC +LIMIT 30; +``` + +Referrer and campaign sources: + +```sql +SELECT + coalesce(utm_source, '(direct)') AS utm_source, + coalesce(referrer, '(none)') AS referrer, + count(*) AS page_views, + count(DISTINCT session_id) AS sessions +FROM visitor_events +WHERE created_at >= now() - interval '30 days' +GROUP BY utm_source, referrer +ORDER BY page_views DESC +LIMIT 30; +``` + +Repeat visitors by IP hash: + +```sql +SELECT + ip_hash, + min(created_at) AS first_seen_at, + max(created_at) AS last_seen_at, + count(*) AS page_views, + count(DISTINCT session_id) AS sessions, + array_agg(DISTINCT path) FILTER (WHERE path IS NOT NULL) AS paths +FROM visitor_events +WHERE ip_hash IS NOT NULL +GROUP BY ip_hash +HAVING count(*) > 1 +ORDER BY last_seen_at DESC +LIMIT 50; +``` + +Visitor timeline by session: + +```sql +SELECT created_at, path, referrer, utm_source, utm_campaign, country, geo_region, geo_city +FROM visitor_events +WHERE session_id = '' +ORDER BY created_at; +``` + +Question timeline with visitor context: + +```sql +SELECT + created_at, + session_id, + ip_hash, + country, + geo_region, + geo_city, + page_path, + question_redacted, + answer_preview, + answer_mode +FROM ask_events +ORDER BY created_at DESC +LIMIT 50; +``` + Total questions: ```sql @@ -119,6 +228,15 @@ ORDER BY created_at DESC LIMIT 50; ``` +Recent questions with answer previews: + +```sql +SELECT created_at, language, question_redacted, answer_preview, normalized_intent, answer_mode, confidence +FROM ask_events +ORDER BY created_at DESC +LIMIT 50; +``` + Questions with no source docs: ```sql diff --git a/docs/home-server-deploy.md b/docs/home-server-deploy.md index f6c2fa6..1d99f5b 100644 --- a/docs/home-server-deploy.md +++ b/docs/home-server-deploy.md @@ -144,10 +144,12 @@ This applies: - `db/migrations/007_add_answer_cache_invalidation.sql` - `db/migrations/008_create_rate_limit_buckets.sql` - `db/migrations/009_create_ask_events.sql` +- `db/migrations/010_add_ask_event_answer_preview.sql` +- `db/migrations/011_add_visitor_events_and_identity_context.sql` The script runs `psql` inside the `postgres` Compose service and reads `POSTGRES_USER` and `POSTGRES_DB` from `.env.production`. -Migration `003` is required for `answer_cache`, `ai_provider_usage`, and `ai_provider_status`. Migration `004` is required for language-specific RAG search and sync locking. Migration `005` is required for short-lived RAG search result caching. Migration `006` adds provider usage metadata. Migration `007` adds entity/source-chunk indexes plus soft invalidation for `answer_cache`, and lets RAG sync record deleted chunks. Migration `008` adds persistent `rate_limit_buckets` for Postgres-backed chat and feedback request limits. Migration `009` adds privacy-conscious `ask_events` logging for sanitized AskOosu question analytics. If production was already deployed before these migrations existed, run `scripts/prod-migrate.sh` again after pulling the latest code. +Migration `003` is required for `answer_cache`, `ai_provider_usage`, and `ai_provider_status`. Migration `004` is required for language-specific RAG search and sync locking. Migration `005` is required for short-lived RAG search result caching. Migration `006` adds provider usage metadata. Migration `007` adds entity/source-chunk indexes plus soft invalidation for `answer_cache`, and lets RAG sync record deleted chunks. Migration `008` adds persistent `rate_limit_buckets` for Postgres-backed chat and feedback request limits. Migration `009` adds privacy-conscious `ask_events` logging for sanitized AskOosu question analytics. Migration `010` adds a short sanitized `answer_preview` excerpt for answer-quality review. Migration `011` adds visitor page-view logging plus `ip_hash` and Cloudflare location context for visitor and question analytics. If production was already deployed before these migrations existed, run `scripts/prod-migrate.sh` again after pulling the latest code. After a successful `/api/rag/sync`, AskOosu invalidates generated `answer_cache` rows by changed `matched_entity_ids` first. If changed chunks do not have entity ids, sync falls back to `source_chunk_ids` when possible and otherwise leaves stale rows to expire via `ASKOOSU_ANSWER_CACHE_TTL_HOURS`. diff --git a/next.config.ts b/next.config.ts index 02d29dd..37491a9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,47 @@ const nextConfig = { images: { domains: ['images.unsplash.com', 'assets.aceternity.com'], }, + async headers() { + return [ + { + source: + '/:path(favicon.ico|favicon.svg|apple-touch-icon.png|llms.txt|llms-full.txt|ai-profile.md|ai-projects.md|ai-faq.md)', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=14400, s-maxage=86400, must-revalidate', + }, + ], + }, + { + source: '/images/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=86400, s-maxage=604800, must-revalidate', + }, + ], + }, + { + source: '/oosu-projects/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=86400, s-maxage=604800, must-revalidate', + }, + ], + }, + { + source: '/oosu-avatar/:path*', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=86400, s-maxage=604800, must-revalidate', + }, + ], + }, + ]; + }, eslint: { // Ne bloque PAS le build en cas d'erreurs eslint ignoreDuringBuilds: true, diff --git a/package.json b/package.json index ad1d57f..5a1d79e 100644 --- a/package.json +++ b/package.json @@ -41,18 +41,23 @@ "ai": "^6.0.174", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "feed": "^5.2.1", "framer-motion": "^12.5.0", + "gray-matter": "^4.0.3", "lottie-web": "^5.13.0", "lucide-react": "^0.483.0", "motion": "^12.23.0", "next": "15.1.9", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "pg": "^8.20.0", "react": "^19.0.1", "react-dom": "^19.0.1", "react-github-btn": "^1.4.0", "react-markdown": "^10.1.0", + "rehype-pretty-code": "^0.14.3", "remark-gfm": "^4.0.1", + "shiki": "^4.0.2", "sonner": "^2.0.1", "tailwind-merge": "^3.0.2", "tw-animate-css": "^1.2.4", @@ -71,6 +76,7 @@ "eslint-config-next": "15.2.3", "eslint-config-prettier": "^10.1.1", "eslint-plugin-prettier": "^5.2.3", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-tailwindcss": "^3.18.0", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa9b848..ca3c3d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 3.8.1 '@vercel/analytics': specifier: ^1.5.0 - version: 1.5.0(next@15.1.9(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(react@19.0.1) + version: 1.5.0(next@15.1.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(react@19.0.1) ai: specifier: ^6.0.174 version: 6.0.174(zod@3.25.76) @@ -74,9 +74,15 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + feed: + specifier: ^5.2.1 + version: 5.2.1 framer-motion: specifier: ^12.5.0 version: 12.5.0(react-dom@19.0.1(react@19.0.1))(react@19.0.1) + gray-matter: + specifier: ^4.0.3 + version: 4.0.3 lottie-web: specifier: ^5.13.0 version: 5.13.0 @@ -88,7 +94,10 @@ importers: version: 12.23.0(react-dom@19.0.1(react@19.0.1))(react@19.0.1) next: specifier: 15.1.9 - version: 15.1.9(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) + version: 15.1.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) + next-mdx-remote: + specifier: ^6.0.0 + version: 6.0.0(@types/react@19.0.12)(react@19.0.1) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.0.1(react@19.0.1))(react@19.0.1) @@ -107,9 +116,15 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.0.12)(react@19.0.1) + rehype-pretty-code: + specifier: ^0.14.3 + version: 0.14.3(shiki@4.0.2) remark-gfm: specifier: ^4.0.1 version: 4.0.1 + shiki: + specifier: ^4.0.2 + version: 4.0.2 sonner: specifier: ^2.0.1 version: 2.0.1(react-dom@19.0.1(react@19.0.1))(react@19.0.1) @@ -159,6 +174,9 @@ importers: eslint-plugin-prettier: specifier: ^5.2.3 version: 5.2.3(eslint-config-prettier@10.1.1(eslint@9.22.0(jiti@2.4.2)))(eslint@9.22.0(jiti@2.4.2))(prettier@3.5.3) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@9.22.0(jiti@2.4.2)) eslint-plugin-tailwindcss: specifier: ^3.18.0 version: 3.18.0(tailwindcss@4.0.14) @@ -245,6 +263,73 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + '@emnapi/core@1.3.1': resolution: {integrity: sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==} @@ -444,6 +529,22 @@ packages: cpu: [x64] os: [win32] + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lordicon/helpers@1.10.1': resolution: {integrity: sha512-GUOffYns36Y7j2c0oWWx0AJB80unvVnYrF1VCXJ7dAGuCyFhxuaR6NrzKgDPbY98Vn1q6b+7rRdfUEyXISiQgQ==} @@ -462,6 +563,15 @@ packages: react-native: optional: true + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@mdx-js/react@3.1.1': + resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + '@napi-rs/wasm-runtime@0.2.7': resolution: {integrity: sha512-5yximcFK5FNompXfJFoWanu5l8v1hNGqNHh9du1xETp9HWk/B/PzvchX55WYOPaIeNglG8++68AAiauBAtbnzw==} @@ -857,6 +967,37 @@ packages: '@rushstack/eslint-patch@1.11.0': resolution: {integrity: sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==} + '@shikijs/core@4.0.2': + resolution: {integrity: sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.0.2': + resolution: {integrity: sha512-7PW0Nm49DcoUIQEXlJhNNBHyoGMjalRETTCcjMqEaMoJRLljy1Bi/EGV3/qLBgLKQejdspiiYuHGQW6dX94Nag==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.0.2': + resolution: {integrity: sha512-UpCB9Y2sUKlS9z8juFSKz7ZtysmeXCgnRF0dlhXBkmQnek7lAToPte8DkxmEYGNTMii72zU/lyXiCB6StuZeJg==} + engines: {node: '>=20'} + + '@shikijs/langs@4.0.2': + resolution: {integrity: sha512-KaXby5dvoeuZzN0rYQiPMjFoUrz4hgwIE+D6Du9owcHcl6/g16/yT5BQxSW5cGt2MZBz6Hl0YuRqf12omRfUUg==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.0.2': + resolution: {integrity: sha512-M6UMPrSa3fN5ayeJwFVl9qWofl273wtK1VG8ySDZ1mQBfhCpdd8nEx7nPZ/tk7k+TYcpqBZzj/AnwxT9lO+HJw==} + engines: {node: '>=20'} + + '@shikijs/themes@4.0.2': + resolution: {integrity: sha512-mjCafwt8lJJaVSsQvNVrJumbnnj1RI8jbUKrPKgE6E3OvQKxnuRoBaYC51H4IGHePsGN/QtALglWBU7DoKDFnA==} + engines: {node: '>=20'} + + '@shikijs/types@4.0.2': + resolution: {integrity: sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1093,6 +1234,9 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1282,6 +1426,9 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1328,6 +1475,10 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -1353,6 +1504,11 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + engines: {node: '>=6.0.0'} + hasBin: true + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -1366,6 +1522,11 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} @@ -1392,6 +1553,9 @@ packages: caniuse-lite@1.0.30001706: resolution: {integrity: sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==} + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1421,6 +1585,9 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -1441,6 +1608,9 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1523,6 +1693,9 @@ packages: ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + electron-to-chromium@1.5.354: + resolution: {integrity: sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -1530,6 +1703,10 @@ packages: resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} engines: {node: '>=10.13.0'} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-abstract@1.23.9: resolution: {integrity: sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==} engines: {node: '>= 0.4'} @@ -1562,6 +1739,16 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1658,6 +1845,12 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + eslint-plugin-react@7.37.4: resolution: {integrity: sha512-BGP0jRmfYyvOyvMoRX/uoUeW+GqNj9y16bPQzqAHf3AYII/tDs+jMN0dBVkl88/OZwNGwrVFxE7riHsXVfy/LQ==} engines: {node: '>=4'} @@ -1696,6 +1889,11 @@ packages: resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} @@ -1708,9 +1906,27 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1719,6 +1935,10 @@ packages: resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1753,6 +1973,10 @@ packages: picomatch: optional: true + feed@5.2.1: + resolution: {integrity: sha512-jTynzYPWs9ALjro0GW8j7sv9y7cJBeOdD4Y88kVqYy/eyusIX3g+499JiTDIlD9Ge/unebx57T4Uzo6vpYvMtA==} + engines: {node: '>=20', pnpm: '>=10'} + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -1830,6 +2054,10 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1886,6 +2114,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -1913,15 +2145,45 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1992,6 +2254,10 @@ packages: is-decimal@2.0.1: resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -2080,10 +2346,19 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -2103,6 +2378,11 @@ packages: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -2116,6 +2396,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -2212,11 +2496,18 @@ packages: lottie-web@5.13.0: resolution: {integrity: sha512-+gfBXl6sxXMPe8tKQm7qzLnUy5DUPJPKIyRHwtpCpyUEYjHYRJC/5gjUvdkuO2c3JllrPtHXH5UJJK8LRYl5yQ==} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.483.0: resolution: {integrity: sha512-WldsY17Qb/T3VZdMnVQ9C3DDIP7h1ViDTHVdVGnLZcvHNg30zH/MTQ04RTORjexoGmpsXroiQXZ4QyR0kBy0FA==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -2254,6 +2545,9 @@ packages: mdast-util-mdx-jsx@3.2.0: resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + mdast-util-mdxjs-esm@2.0.1: resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} @@ -2297,12 +2591,30 @@ packages: micromark-extension-gfm@3.0.0: resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + micromark-factory-destination@2.0.1: resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} micromark-factory-label@2.0.1: resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + micromark-factory-space@2.0.1: resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} @@ -2333,6 +2645,9 @@ packages: micromark-util-encode@2.0.1: resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + micromark-util-html-tag-name@2.0.1: resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} @@ -2408,6 +2723,12 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + next-mdx-remote@6.0.0: + resolution: {integrity: sha512-cJEpEZlgD6xGjB4jL8BnI8FaYdN9BzZM4NwadPe1YQr7pqoWjg9EBCMv3nXBkuHqMRfv2y33SzUsuyNh9LFAQQ==} + engines: {node: '>=14', npm: '>=7'} + peerDependencies: + react: '>=16' + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -2444,6 +2765,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2476,6 +2800,12 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2499,6 +2829,12 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse-numeric-range@1.3.0: + resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -2717,17 +3053,55 @@ packages: resolution: {integrity: sha512-nVRaZCuEyvu69sWrkdwjP6QY57C+lY+uMNNMyWUFJb9Z/JlaBOQus7mSMfGYsblv7R691u6SSJA/dX9IRnyyLQ==} engines: {node: '>=0.10.0'} + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-pretty-code@0.14.3: + resolution: {integrity: sha512-Cz692FeYusTjT5cfFWLc4r7JhgC3/JlJptgUh4iffBxWxUnWW1oqbWFi7jGCeq00DYUm8yzoTnvpocaYa5x82g==} + engines: {node: '>=18'} + peerDependencies: + shiki: ^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + remark-parse@11.0.0: resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} @@ -2778,9 +3152,17 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -2814,6 +3196,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shiki@4.0.2: + resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} + engines: {node: '>=20'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -2843,6 +3229,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} @@ -2850,6 +3240,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -2883,6 +3276,10 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -3008,9 +3405,15 @@ packages: unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + unist-util-position@5.0.0: resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + unist-util-remove@4.0.0: + resolution: {integrity: sha512-b4gokeGId57UVRX/eVKej5gXqGlc9+trkORhFJpu9raqZkZhU0zm8Doi05+HaiBsMEIJowL+2WtQ5ItjsngPXg==} + unist-util-stringify-position@4.0.0: resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} @@ -3020,6 +3423,15 @@ packages: unist-util-visit@5.0.0: resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3054,12 +3466,21 @@ packages: react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-matter@5.0.1: + resolution: {integrity: sha512-o6roP82AiX0XfkyTHyRCMXgHfltUNlXSEqCIS80f+mbAyiQBE2fxtDVMtseyytGx75sihiJFo/zR6r/4LTs2Cw==} + vfile-message@4.0.2: resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -3089,14 +3510,32 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} @@ -3184,6 +3623,106 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@emnapi/core@1.3.1': dependencies: '@emnapi/wasi-threads': 1.0.1 @@ -3349,6 +3888,25 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@lordicon/helpers@1.10.1': {} '@lordicon/react@1.10.1(lottie-web@5.13.0)(react@19.0.1)': @@ -3358,6 +3916,42 @@ snapshots: optionalDependencies: lottie-web: 5.13.0 + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + acorn: 8.14.1 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.14.1) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.1 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@mdx-js/react@3.1.1(@types/react@19.0.12)(react@19.0.1)': + dependencies: + '@types/mdx': 2.0.13 + '@types/react': 19.0.12 + react: 19.0.1 + '@napi-rs/wasm-runtime@0.2.7': dependencies: '@emnapi/core': 1.3.1 @@ -3690,6 +4284,46 @@ snapshots: '@rushstack/eslint-patch@1.11.0': {} + '@shikijs/core@4.0.2': + dependencies: + '@shikijs/primitive': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/primitive@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/themes@4.0.2': + dependencies: + '@shikijs/types': 4.0.2 + + '@shikijs/types@4.0.2': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + '@standard-schema/spec@1.1.0': {} '@swc/counter@0.1.3': {} @@ -3976,6 +4610,8 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/mdx@2.0.13': {} + '@types/ms@2.1.0': {} '@types/node@20.17.24': @@ -4114,9 +4750,9 @@ snapshots: '@unrs/rspack-resolver-binding-win32-x64-msvc@1.2.2': optional: true - '@vercel/analytics@1.5.0(next@15.1.9(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(react@19.0.1)': + '@vercel/analytics@1.5.0(next@15.1.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(react@19.0.1)': optionalDependencies: - next: 15.1.9(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) + next: 15.1.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1) react: 19.0.1 '@vercel/oidc@3.2.0': {} @@ -4148,6 +4784,10 @@ snapshots: dependencies: color-convert: 2.0.1 + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} aria-hidden@1.2.4: @@ -4223,6 +4863,8 @@ snapshots: ast-types-flow@0.0.8: {} + astring@1.9.0: {} + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -4239,6 +4881,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.10.29: {} + bignumber.js@9.3.1: {} brace-expansion@1.1.11: @@ -4254,6 +4898,14 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.354 + node-releases: 2.0.44 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-equal-constant-time@1.0.1: {} busboy@1.6.0: @@ -4281,6 +4933,8 @@ snapshots: caniuse-lite@1.0.30001706: {} + caniuse-lite@1.0.30001792: {} + ccount@2.0.1: {} chalk@4.1.2: @@ -4304,6 +4958,8 @@ snapshots: clsx@2.1.1: {} + collapse-white-space@2.1.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -4326,6 +4982,8 @@ snapshots: concat-map@0.0.1: {} + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -4406,6 +5064,8 @@ snapshots: dependencies: safe-buffer: 5.2.1 + electron-to-chromium@1.5.354: {} + emoji-regex@9.2.2: {} enhanced-resolve@5.18.1: @@ -4413,6 +5073,8 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.1 + entities@6.0.1: {} + es-abstract@1.23.9: dependencies: array-buffer-byte-length: 1.0.2 @@ -4511,13 +5173,29 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - escape-string-regexp@4.0.0: {} - - escape-string-regexp@5.0.0: {} - - eslint-config-next@15.2.3(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2): + esast-util-from-estree@2.0.0: dependencies: - '@next/eslint-plugin-next': 15.2.3 + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.14.1 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.2 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-config-next@15.2.3(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2): + dependencies: + '@next/eslint-plugin-next': 15.2.3 '@rushstack/eslint-patch': 1.11.0 '@typescript-eslint/eslint-plugin': 8.27.0(@typescript-eslint/parser@8.27.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2))(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2) '@typescript-eslint/parser': 8.27.0(eslint@9.22.0(jiti@2.4.2))(typescript@5.8.2) @@ -4634,6 +5312,17 @@ snapshots: dependencies: eslint: 9.22.0(jiti@2.4.2) + eslint-plugin-react-hooks@7.1.1(eslint@9.22.0(jiti@2.4.2)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + eslint: 9.22.0(jiti@2.4.2) + hermes-parser: 0.25.1 + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + eslint-plugin-react@7.37.4(eslint@9.22.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 @@ -4719,6 +5408,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.14.1) eslint-visitor-keys: 4.2.0 + esprima@4.0.1: {} + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -4729,12 +5420,47 @@ snapshots: estraverse@5.3.0: {} + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.6 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + estree-util-is-identifier-name@3.0.0: {} + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.6 + esutils@2.0.3: {} eventsource-parser@3.0.8: {} + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -4769,6 +5495,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + feed@5.2.1: + dependencies: + xml-js: 1.6.11 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -4849,6 +5579,8 @@ snapshots: transitivePeerDependencies: - supports-color + gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4915,6 +5647,13 @@ snapshots: graphemer@1.4.0: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.2 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -4937,6 +5676,65 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.2 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.0.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.0.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.16 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.0.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.6 @@ -4957,12 +5755,32 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.0.0 + space-separated-tokens: 2.0.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + html-url-attributes@3.0.1: {} + html-void-elements@3.0.0: {} + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -5043,6 +5861,8 @@ snapshots: is-decimal@2.0.1: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -5129,10 +5949,17 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 + jsesc@3.1.0: {} + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -5149,6 +5976,8 @@ snapshots: dependencies: minimist: 1.2.8 + json5@2.2.3: {} + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.8 @@ -5171,6 +6000,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -5241,10 +6072,16 @@ snapshots: lottie-web@5.13.0: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + lucide-react@0.483.0(react@19.0.1): dependencies: react: 19.0.1 + markdown-extensions@2.0.0: {} + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -5358,6 +6195,16 @@ snapshots: transitivePeerDependencies: - supports-color + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.2 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + mdast-util-mdxjs-esm@2.0.1: dependencies: '@types/estree-jsx': 1.0.5 @@ -5481,6 +6328,57 @@ snapshots: micromark-util-combine-extensions: 2.0.1 micromark-util-types: 2.0.2 + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.2 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + micromark-factory-destination@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -5494,6 +6392,18 @@ snapshots: micromark-util-symbol: 2.0.1 micromark-util-types: 2.0.2 + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + micromark-factory-space@2.0.1: dependencies: micromark-util-character: 2.1.1 @@ -5546,6 +6456,16 @@ snapshots: micromark-util-encode@2.0.1: {} + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.6 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + vfile-message: 4.0.2 + micromark-util-html-tag-name@2.0.1: {} micromark-util-normalize-identifier@2.0.1: @@ -5636,12 +6556,26 @@ snapshots: natural-compare@1.4.0: {} + next-mdx-remote@6.0.0(@types/react@19.0.12)(react@19.0.1): + dependencies: + '@babel/code-frame': 7.29.0 + '@mdx-js/mdx': 3.1.1 + '@mdx-js/react': 3.1.1(@types/react@19.0.12)(react@19.0.1) + react: 19.0.1 + unist-util-remove: 4.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + vfile-matter: 5.0.1 + transitivePeerDependencies: + - '@types/react' + - supports-color + next-themes@0.4.6(react-dom@19.0.1(react@19.0.1))(react@19.0.1): dependencies: react: 19.0.1 react-dom: 19.0.1(react@19.0.1) - next@15.1.9(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1): + next@15.1.9(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1): dependencies: '@next/env': 15.1.9 '@swc/counter': 0.1.3 @@ -5651,7 +6585,7 @@ snapshots: postcss: 8.4.31 react: 19.0.1 react-dom: 19.0.1(react@19.0.1) - styled-jsx: 5.1.6(react@19.0.1) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.0.1) optionalDependencies: '@next/swc-darwin-arm64': 15.1.9 '@next/swc-darwin-x64': 15.1.9 @@ -5675,6 +6609,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-releases@2.0.44: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -5717,6 +6653,14 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5754,6 +6698,12 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse-numeric-range@1.3.0: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -5908,6 +6858,35 @@ snapshots: react@19.0.1: {} + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.14.1): + dependencies: + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.6 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -5919,6 +6898,16 @@ snapshots: get-proto: 1.0.1 which-builtin-type: 1.2.1 + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -5928,6 +6917,30 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-pretty-code@0.14.3(shiki@4.0.2): + dependencies: + '@types/hast': 3.0.4 + hast-util-to-string: 3.0.1 + parse-numeric-range: 1.3.0 + rehype-parse: 9.0.1 + shiki: 4.0.2 + unified: 11.0.5 + unist-util-visit: 5.0.0 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -5939,6 +6952,13 @@ snapshots: transitivePeerDependencies: - supports-color + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + remark-parse@11.0.0: dependencies: '@types/mdast': 4.0.4 @@ -6019,8 +7039,15 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + sax@1.6.0: {} + scheduler@0.25.0: {} + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@6.3.1: {} semver@7.7.1: {} @@ -6080,6 +7107,17 @@ snapshots: shebang-regex@3.0.0: {} + shiki@4.0.2: + dependencies: + '@shikijs/core': 4.0.2 + '@shikijs/engine-javascript': 4.0.2 + '@shikijs/engine-oniguruma': 4.0.2 + '@shikijs/langs': 4.0.2 + '@shikijs/themes': 4.0.2 + '@shikijs/types': 4.0.2 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -6120,10 +7158,14 @@ snapshots: source-map-js@1.2.1: {} + source-map@0.7.6: {} + space-separated-tokens@2.0.2: {} split2@4.2.0: {} + sprintf-js@1.0.3: {} + stable-hash@0.0.5: {} streamsearch@1.1.0: {} @@ -6183,6 +7225,8 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-bom-string@1.0.0: {} + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} @@ -6195,10 +7239,12 @@ snapshots: dependencies: inline-style-parser: 0.2.4 - styled-jsx@5.1.6(react@19.0.1): + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.0.1): dependencies: client-only: 0.0.1 react: 19.0.1 + optionalDependencies: + '@babel/core': 7.29.0 supports-color@7.2.0: dependencies: @@ -6315,10 +7361,20 @@ snapshots: dependencies: '@types/unist': 3.0.3 + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-position@5.0.0: dependencies: '@types/unist': 3.0.3 + unist-util-remove@4.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + unist-util-stringify-position@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -6334,6 +7390,18 @@ snapshots: unist-util-is: 6.0.0 unist-util-visit-parents: 6.0.1 + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -6366,6 +7434,16 @@ snapshots: - '@types/react' - '@types/react-dom' + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-matter@5.0.1: + dependencies: + vfile: 6.0.3 + yaml: 2.9.0 + vfile-message@4.0.2: dependencies: '@types/unist': 3.0.3 @@ -6376,6 +7454,8 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + web-namespaces@2.0.1: {} + web-streams-polyfill@3.3.3: {} which-boxed-primitive@1.1.1: @@ -6425,10 +7505,22 @@ snapshots: word-wrap@1.2.5: {} + xml-js@1.6.11: + dependencies: + sax: 1.6.0 + xtend@4.0.2: {} + yallist@3.1.1: {} + + yaml@2.9.0: {} + yocto-queue@0.1.0: {} + zod-validation-error@4.0.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod@3.25.76: {} zwitch@2.0.4: {} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 48f71a1..c57b3b6 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -1,52 +1,94 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { PublicPageShell, TextSection } from '@/components/seo/public-page-shell'; -import { createPageMetadata } from '@/lib/seo'; +import { + PublicPageShell, + TextSection, +} from '@/components/seo/public-page-shell'; +import { LocalizedText } from '@/components/localized-content'; +import { createPageMetadata, personJsonLd, siteUrl } from '@/lib/seo'; export const metadata: Metadata = createPageMetadata({ - title: 'About Oosu', + title: '장우수 소개 | Oosu · oosuhada 개발자', description: - 'Oosu is an AI-connected fullstack developer and product-minded builder focused on RAG portfolio systems, AI-assisted workflows, and trustworthy AI UX.', + '우수하다(oosuhada)라는 이름으로 활동하며 AI·RAG 서비스와 제품 중심 웹 애플리케이션을 만드는 AI·풀스택 개발자 장우수의 소개 페이지입니다.', path: '/about', keywords: ['About Oosu', 'oosuhada', 'AI-connected developer'], }); +const profilePageJsonLd = { + '@context': 'https://schema.org', + '@type': 'ProfilePage', + '@id': `${siteUrl}/about#profile`, + url: `${siteUrl}/about`, + mainEntity: personJsonLd, +}; + export default function AboutPage() { return ( - - -

- Oosu’s work focuses on connecting AI tools to real product contexts: - planning, design, engineering, content, deployment, and operational - feedback. -

-

- The public portfolio avoids inflated claims. It presents a grounded - profile: AI-connected fullstack developer, product-minded builder, and - RAG portfolio system builder. -

-
- -

- Visit{' '} - - Projects - - ,{' '} - - AskOosu - - , or{' '} - - AI-era Developer Competitiveness - - . -

-
-
+ <> +