mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Compare commits
12 commits
aabce2732a
...
32e41a8ea7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e41a8ea7 | ||
|
|
8448b1105c | ||
|
|
d66a463d62 | ||
|
|
1a6face04c | ||
|
|
f4c5d22f22 | ||
|
|
9a3424d68c | ||
|
|
b9f0b69cf1 | ||
|
|
b85f2889b0 | ||
|
|
e091d1790f | ||
|
|
a4d63edecd | ||
|
|
4024b8dd98 | ||
|
|
766a680a34 |
83 changed files with 5015 additions and 1965 deletions
316
CONTRIBUTING.ko.md
Normal file
316
CONTRIBUTING.ko.md
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
# Open Design 기여 가이드
|
||||
|
||||
기여를 고민하고 있다니 고맙습니다. OD는 일부러 작게 유지합니다. 대부분의 가치는 프레임워크 코드가 아니라 **파일**(skill, design system, 프롬프트 조각)에 담겨 있습니다. 그래서 가장 효과가 큰 기여는 대개 폴더 하나, Markdown 파일 하나, 또는 PR 한 건 크기의 adapter입니다.
|
||||
|
||||
이 문서는 어떤 종류의 기여를 어디서 시작해야 하는지, 그리고 PR이 머지되려면 어떤 기준을 넘어야 하는지 정확히 알려줍니다.
|
||||
|
||||
<p align="center"><a href="CONTRIBUTING.md">English</a> · <a href="CONTRIBUTING.pt-BR.md">Português (Brasil)</a> · <a href="CONTRIBUTING.de.md">Deutsch</a> · <a href="CONTRIBUTING.fr.md">Français</a> · <a href="CONTRIBUTING.zh-CN.md">简体中文</a> · <a href="CONTRIBUTING.ja-JP.md">日本語</a> · <b>한국어</b></p>
|
||||
|
||||
---
|
||||
|
||||
## 오후 한나절이면 끝나는 세 가지 기여
|
||||
|
||||
| 하고 싶은 일 | 실제로 추가하는 것 | 위치 | 규모 |
|
||||
|---|---|---|---|
|
||||
| OD가 새로운 종류의 artifact를 렌더링하게 만들기 (청구서, iOS 설정 화면, 한 장짜리 문서 등) | **Skill** | [`skills/<your-skill>/`](skills/) | 폴더 하나, 파일 약 2개 |
|
||||
| OD가 새 브랜드의 비주얼 언어를 구사하게 만들기 | **Design System** | [`design-systems/<brand>/DESIGN.md`](design-systems/) | Markdown 파일 하나 |
|
||||
| 새 coding-agent CLI 연결하기 | **Agent adapter** | [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts) | 배열 하나에 약 10줄 |
|
||||
| 기능 추가, 버그 수정, [`open-codesign`][ocod]에서 UX 패턴 가져오기 | 코드 | `apps/web/src/`, `apps/daemon/` | 일반 PR |
|
||||
| 문서 개선, 일부 섹션을 Français / Deutsch / 中文로 번역, 오타 수정 | 문서 | `README.md`, `README.fr.md`, `README.de.md`, `README.zh-CN.md`, `docs/`, `QUICKSTART.md` | PR 한 건 |
|
||||
|
||||
어느 쪽에 해당하는지 모르겠다면 [먼저 discussion이나 issue를 열어주세요](https://github.com/nexu-io/open-design/issues/new). 적절한 위치를 안내해 드리겠습니다.
|
||||
|
||||
---
|
||||
|
||||
## 로컬 환경 설정
|
||||
|
||||
한 페이지짜리 전체 설정 안내는 [`QUICKSTART.ko.md`](QUICKSTART.ko.md)에 있습니다. 기여자를 위한 요약은 다음과 같습니다.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/nexu-io/open-design.git
|
||||
cd open-design
|
||||
corepack enable # packageManager에 고정된 pnpm을 선택합니다
|
||||
pnpm install
|
||||
pnpm tools-dev run web # daemon + web 포그라운드 루프
|
||||
pnpm typecheck # tsc -b --noEmit
|
||||
pnpm --filter @open-design/web build # 필요할 때 web 패키지 빌드
|
||||
```
|
||||
|
||||
Node `~24`와 pnpm `10.33.x`가 필요합니다. `nvm`이나 `fnm`은 선택 사항입니다. Node를 그렇게 관리하는 게 편하다면 `nvm install 24 && nvm use 24` 또는 `fnm install 24 && fnm use 24`를 실행하세요. macOS, Linux, WSL2가 주요 지원 환경입니다. Windows 네이티브도 지원합니다. 흔히 겪는 설정 문제는 [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)를 참고하세요.
|
||||
|
||||
## Docker 설정
|
||||
|
||||
Node.js나 pnpm을 설치하지 않고도 Open Design을 실행할 수 있습니다.
|
||||
|
||||
### 사전 준비
|
||||
|
||||
Compose v2가 포함된 Docker Desktop이 설치되어 있는지 확인하세요.
|
||||
|
||||
```bash
|
||||
docker compose version
|
||||
```
|
||||
|
||||
### Open Design 실행
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
브라우저에서 다음 주소를 엽니다.
|
||||
|
||||
```text
|
||||
http://localhost:7456
|
||||
```
|
||||
|
||||
### 자주 쓰는 명령어
|
||||
|
||||
```bash
|
||||
# 로그 보기
|
||||
docker compose logs -f
|
||||
|
||||
# 컨테이너 재시작
|
||||
docker compose restart
|
||||
|
||||
# 컨테이너 중지
|
||||
docker compose down
|
||||
|
||||
# 최신 이미지 받기
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 선택적 환경 변수 재정의
|
||||
|
||||
`deploy/.env` 파일을 만듭니다.
|
||||
|
||||
```env
|
||||
OPEN_DESIGN_PORT=7456
|
||||
OPEN_DESIGN_MEM_LIMIT=384m
|
||||
OPEN_DESIGN_ALLOWED_ORIGINS=https://yourdomain.com
|
||||
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest
|
||||
```
|
||||
|
||||
> 프로젝트와 데이터베이스 데이터는 Docker 볼륨에 자동으로 보존됩니다.
|
||||
|
||||
전체 Docker 가이드와 고급 설정은 [`QUICKSTART.ko.md`](QUICKSTART.ko.md)를 참고하세요.
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 새 Skill 추가하기
|
||||
|
||||
skill은 [`skills/`](skills/) 아래에 두는 폴더로, 루트에 `SKILL.md`를 두고 Claude Code의 [`SKILL.md` 규약][skill]에 우리의 선택적 `od:` 확장을 더한 형태입니다. **등록 절차는 없습니다.** 폴더를 넣고 daemon을 재시작하면 picker에 바로 나타납니다.
|
||||
|
||||
### → 전체 가이드는 [`docs/skills-contributing.md`](docs/skills-contributing.md)를 보세요
|
||||
|
||||
이 문서가 다음 내용을 단계별로 안내합니다.
|
||||
|
||||
- **빠른 시작** — 저장소 클론 → 가장 비슷한 기존 skill 복사 → `pnpm tools-dev run web` 실행 → picker 확인 → PR 열기.
|
||||
- **skill이란 무엇이고 무엇이 아닌가** — 당신의 아이디어가 사실은 기능이나 vendor 연동이었다면, 일주일을 아껴줍니다.
|
||||
- **skill 구조** — 최소한의 폴더 구성과 `SKILL.md` frontmatter 치트시트.
|
||||
- **로컬 실행** — 실제로 중요한 네 가지 명령어.
|
||||
- **머지 기준** — 리뷰어가 확인할 항목을 그대로 복사해 쓸 수 있는 체크리스트.
|
||||
- **PR 설명 템플릿** — PR 본문에 붙여넣고 채우면 됩니다.
|
||||
- **자주 거절되는 패턴** — 최근 실제로 사용한 거절 사유와 구체적인 예시.
|
||||
|
||||
프로토콜 명세(전체 frontmatter 문법 — 타입이 지정된 입력, 슬라이더 파라미터, craft 참조, 테스트 프리미티브)는 [`docs/skills-protocol.md`](docs/skills-protocol.md)에 별도로 정리되어 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 새 Design System 추가하기
|
||||
|
||||
design system은 `design-systems/<slug>/` 아래에 두는 [`DESIGN.md`](design-systems/README.md) 파일 하나입니다. **파일 하나뿐, 코드는 없습니다.** 넣고 daemon을 재시작하면 picker에 카테고리별로 묶여 나타납니다.
|
||||
|
||||
### design system 폴더 구성
|
||||
|
||||
```text
|
||||
design-systems/your-brand/
|
||||
└── DESIGN.md
|
||||
```
|
||||
|
||||
### `DESIGN.md` 형식
|
||||
|
||||
```markdown
|
||||
# Design System Inspired by YourBrand
|
||||
|
||||
> Category: Developer Tools
|
||||
> One-line summary that shows in the picker preview.
|
||||
|
||||
## 1. Visual Theme & Atmosphere
|
||||
…
|
||||
|
||||
## 2. Color
|
||||
- Primary: `#hex` / `oklch(...)`
|
||||
- …
|
||||
|
||||
## 3. Typography
|
||||
…
|
||||
|
||||
## 4. Spacing & Grid
|
||||
## 5. Layout & Composition
|
||||
## 6. Components
|
||||
## 7. Motion & Interaction
|
||||
## 8. Voice & Brand
|
||||
## 9. Anti-patterns
|
||||
```
|
||||
|
||||
9개 섹션 구조는 고정입니다. skill 본문이 이 구조를 grep으로 찾기 때문입니다. 첫 H1이 picker 라벨이 되고(`Design System Inspired by` 접두사는 자동으로 제거됩니다), `> Category: …` 줄이 어느 그룹에 들어갈지 결정합니다. 기존 카테고리는 [`design-systems/README.md`](design-systems/README.md)에 정리되어 있습니다. 브랜드가 정말 어디에도 안 맞으면 새 카테고리를 만들 수 있지만, **먼저 기존 카테고리부터 검토하세요**.
|
||||
|
||||
### 새 design system 머지 기준
|
||||
|
||||
1. **9개 섹션이 모두 있어야 합니다.** 찾기 어려운 데이터(예: motion 토큰)는 섹션 본문이 비어 있어도 괜찮지만, 제목은 반드시 있어야 합니다. 없으면 프롬프트의 grep이 깨집니다.
|
||||
2. **hex 코드는 실제 값이어야 합니다.** 기억이나 AI 추측이 아니라 브랜드의 사이트나 제품에서 직접 추출하세요. README의 "brand-spec extraction" 5단계 프로토콜은 maintainer에게도 똑같이 적용됩니다.
|
||||
3. **강조 색상의 OKLch 값**은 있으면 좋습니다. 라이트/다크 모드에서 팔레트가 예측 가능하게 보간됩니다.
|
||||
4. **마케팅 문구는 빼세요.** 브랜드 슬로건은 design 토큰이 아닙니다. 잘라내세요.
|
||||
5. **slug는 ASCII로 작성하세요.** `linear.app`은 `linear-app`이 되고 `x.ai`는 `x-ai`가 됩니다. 이미 가져온 69개 시스템이 이 규칙을 따르니 그대로 맞추세요.
|
||||
|
||||
우리가 제공하는 69개 제품 시스템은 [`scripts/sync-design-systems.ts`](scripts/sync-design-systems.ts)를 통해 [`VoltAgent/awesome-design-md`][acd2]에서 가져온 것입니다. 브랜드가 그 upstream에 속한다면 **그쪽에 먼저 PR을 보내세요.** 다음 동기화 때 자동으로 반영됩니다. `design-systems/` 폴더는 upstream에 맞지 않는 시스템과, 우리가 직접 작성한 스타터 2개를 위한 곳입니다.
|
||||
|
||||
---
|
||||
|
||||
## 새 coding-agent CLI 추가하기
|
||||
|
||||
새 agent(예: 어느 신생 업체의 `foo-coder` CLI)를 연결하는 일은 [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)에 항목 하나를 추가하는 것입니다.
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 'foo',
|
||||
name: 'Foo Coder',
|
||||
bin: 'foo',
|
||||
versionArgs: ['--version'],
|
||||
buildArgs: (prompt) => ['exec', '-p', prompt],
|
||||
streamFormat: 'plain', // 또는 해당 형식을 지원하면 'claude-stream-json'
|
||||
}
|
||||
```
|
||||
|
||||
이게 전부입니다. daemon이 `PATH`에서 감지하고, picker에 나타나며, 채팅 경로가 동작합니다. CLI가 (Claude Code의 `--output-format stream-json`처럼) **타입이 지정된 이벤트**를 내보낸다면 [`apps/daemon/src/claude-stream.ts`](apps/daemon/src/claude-stream.ts)에 파서를 연결하고 `streamFormat: 'claude-stream-json'`으로 설정하세요.
|
||||
|
||||
머지 기준:
|
||||
|
||||
1. 새 agent로 **실제 세션이 처음부터 끝까지 동작**해야 합니다. artifact가 스트리밍으로 통과한 것을 보여주는 daemon 로그를 PR 설명에 붙여넣으세요.
|
||||
2. CLI의 특이사항을 **`docs/agent-adapters.md`**에 정리하세요(키 파일이 필요한가? 이미지 입력을 지원하는가? 비대화형 플래그는 무엇인가?).
|
||||
3. **README의 "Supported coding agents" 표**에 한 줄을 추가하세요.
|
||||
|
||||
---
|
||||
|
||||
## 모델 `max_tokens` 메타데이터 갱신하기
|
||||
|
||||
API 모드 채팅은 매 요청마다 upstream provider에 `max_tokens`를 보냅니다. web 클라이언트는 [`apps/web/src/state/maxTokens.ts`](apps/web/src/state/maxTokens.ts)의 3단계 조회로 이 값을 정합니다.
|
||||
|
||||
1. 설정에서 사용자가 직접 지정한 값(있는 경우).
|
||||
2. 없으면 [`apps/web/src/state/litellm-models.json`](apps/web/src/state/litellm-models.json)의 모델별 기본값. 이는 [BerriAI/litellm][litellm]의 `model_prices_and_context_window.json`(MIT)에서 잘라온 vendored 데이터로, Anthropic, OpenAI, DeepSeek, Groq, Together, Mistral, Gemini, Bedrock, Vertex, OpenRouter 등 약 2천 개의 채팅 모델을 포함합니다.
|
||||
3. 그래도 없으면 `FALLBACK_MAX_TOKENS = 8192`.
|
||||
|
||||
새로 출시된 모델을 반영하려면 vendored JSON을 다시 생성하세요.
|
||||
|
||||
```bash
|
||||
node --experimental-strip-types scripts/sync-litellm-models.ts
|
||||
```
|
||||
|
||||
이 스크립트는 LiteLLM의 카탈로그를 가져와 `mode: 'chat'` 항목만 걸러내고, 각 항목을 `max_output_tokens`(없으면 `max_tokens`)로 매핑한 뒤 정렬된 스냅샷을 씁니다. 갱신을 유발한 PR과 함께 다시 생성된 `litellm-models.json`을 커밋하세요.
|
||||
|
||||
`maxTokens.ts`의 OVERRIDES 표는 실제로 쓰는 모델 id에 대해 LiteLLM에 값이 없거나 틀린 드문 경우를 위한 것입니다. 예를 들어 `mimo-v2.5-pro`가 그렇습니다(LiteLLM은 MiMo를 `openrouter/xiaomi/...`와 `novita/xiaomimimo/...` 별칭으로만 제공하는데, 둘 다 Xiaomi 직접 API가 쓰는 정식 id와 맞지 않습니다). 이 표는 작게 유지하세요. LiteLLM이 제대로 다루는 것은 모두 upstream에 두는 게 맞습니다.
|
||||
|
||||
[litellm]: https://github.com/BerriAI/litellm
|
||||
|
||||
---
|
||||
|
||||
## 현지화 유지보수
|
||||
|
||||
독일어는 격식 있는 `Sie`를 씁니다. OD는 1인 창작자, 에이전시, 엔지니어링 팀이 뒤섞인 사용자층에 말을 걸기 때문입니다. 비격식 `du` 어조가 더 잘 맞는다는 프로젝트 피드백이 나오기 전까지는, 격식 독일어가 가장 무난한 기본값입니다. 로케일 PR은 UI 요소, 핵심 문서, 그리고 `apps/web/src/i18n/content.ts`의 표시 전용 갤러리 메타데이터를 번역해야 하지만, `skills/`나 `design-systems/`, 또는 agent가 실행하는 프롬프트 본문은 번역하면 안 됩니다. 이런 원본 프롬프트는 워크플로우 입력으로 관리되며, 원본 언어를 하나로 유지해야 로케일마다 프롬프트 QA가 늘어나는 일을 막을 수 있습니다. skill, design system, 프롬프트 템플릿을 추가하거나 이름을 바꿀 때는 독일어 표시 메타데이터를 갱신하고 `pnpm --filter @open-design/web test`를 실행하세요. 독일어 표시 항목이 누락되면 `content.test.ts`가 실패합니다. daemon 오류, export 파일명, agent가 생성한 artifact 텍스트는 PR이 명시적으로 범위에 넣지 않는 한 알려진 한계로 둡니다.
|
||||
|
||||
새 로케일을 추가하는 단계별 안내(UI 사전, README, 언어 전환기, 지역별 용어)는 [`TRANSLATIONS.md`](TRANSLATIONS.md)를 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 코드 스타일
|
||||
|
||||
포매팅에 까다롭게 굴지는 않지만(저장 시 Prettier로 충분합니다), 두 가지 규칙은 양보할 수 없습니다. 프롬프트 스택과 사용자에게 노출되는 API에 그대로 드러나기 때문입니다.
|
||||
|
||||
1. **JS/TS는 작은따옴표.** escape 때문에 보기 흉해지는 경우가 아니라면 문자열은 작은따옴표로 감쌉니다. 코드베이스는 이미 일관되니 맞춰주세요.
|
||||
2. **주석은 영어로.** PR이 Deutsch나 中文로 번역하는 작업이더라도 코드 주석은 영어로 둡니다. grep으로 찾을 수 있는 참조를 한 벌로 유지하기 위해서입니다.
|
||||
|
||||
그 외에:
|
||||
|
||||
- **설명조 주석은 쓰지 마세요.** `// import the module`이나 `// loop through items` 같은 것 말입니다. 코드만 봐도 명백하다면 그 주석은 잡음입니다. 주석은 코드로 표현할 수 없는 의도나 제약에만 쓰세요.
|
||||
- **`apps/web/src/`는 TypeScript를 씁니다.** daemon(`apps/daemon/`)은 타입이 중요한 곳에 JSDoc을 붙인 순수 ESM JavaScript입니다. 그대로 유지하세요.
|
||||
- **새 최상위 의존성을 추가하지 마세요.** 추가한다면 얻는 것과 늘어나는 번들 크기를 PR 설명에 한 단락으로 적으세요. [`package.json`](package.json)의 의존성 목록은 일부러 작게 둡니다.
|
||||
- **푸시 전에 `pnpm typecheck`를 실행하세요.** CI에서도 돌립니다. 실패하면 "고쳐주세요" 코멘트를 받게 됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 커밋과 pull request
|
||||
|
||||
- **PR 하나에 관심사 하나.** skill 추가 + 파서 리팩터링 + 의존성 버전 업은 PR 세 개입니다.
|
||||
- **제목은 명령형 + 범위.** `add dating-web skill`, `fix daemon SSE backpressure when CLI hangs`, `docs: clarify .od layout`처럼 씁니다.
|
||||
- **PR 템플릿을 사용하세요.** [`.github/pull_request_template.md`](.github/pull_request_template.md)의 모든 섹션(Why, What users will see, Surface area, Screenshots(UI인 경우), Bug fix verification(버그 수정인 경우), Validation)을 채우세요. 빈 섹션에는 "채워주세요" 답변이 달립니다.
|
||||
- **본문에는 이유를 적으세요.** "이게 뭘 하는지"는 보통 diff만 봐도 알 수 있습니다. 정작 드러나지 않는 것은 "이게 왜 있어야 하는지"입니다.
|
||||
- **issue가 있다면 연결하세요.** issue가 없고 PR이 사소하지 않다면 먼저 issue를 열어주세요. 시간을 쏟기 전에 그 변경을 원하는지 합의할 수 있습니다.
|
||||
- **리뷰 중에는 squash하지 마세요.** fixup 커밋을 푸시하면 머지할 때 우리가 squash합니다.
|
||||
- **공유 브랜치에 force-push하지 마세요.** 리뷰어가 요청한 경우는 예외입니다.
|
||||
|
||||
CLA는 요구하지 않습니다. Apache-2.0으로 충분하며, 당신의 기여도 같은 라이선스를 따릅니다.
|
||||
|
||||
---
|
||||
|
||||
## 버그 신고하기
|
||||
|
||||
다음 내용을 담아 issue를 열어주세요.
|
||||
|
||||
- 무엇을 실행했는지(정확한 `pnpm tools-dev ...` 호출).
|
||||
- 어떤 agent CLI를 선택했는지(또는 BYOK 경로였는지).
|
||||
- 이를 유발한 skill + design system 조합.
|
||||
- 관련된 **daemon stderr 끝부분**. "artifact가 렌더링되지 않았다"는 신고 대부분은 `spawn ENOENT`나 CLI의 실제 오류가 보이면 30초 만에 원인이 잡힙니다.
|
||||
- UI 문제라면 스크린샷.
|
||||
|
||||
프롬프트 스택 버그("agent가 보라색 그라데이션 hero를 내보냈는데, slop 블랙리스트가 그걸 막았어야 했다")라면 **assistant 메시지 전문**을 함께 넣어주세요. 위반이 모델 탓인지 프롬프트 탓인지 볼 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 질문하기
|
||||
|
||||
- 아키텍처 질문, 설계 질문, "이게 버그인지 오용인지" → [GitHub Discussions](https://github.com/nexu-io/open-design/discussions) (권장 — 다음 사람이 검색할 수 있습니다).
|
||||
- "X를 하는 skill을 어떻게 작성하나요" → discussion을 열어주세요. 답해드리고, 빠져 있던 패턴이라면 그 답을 [`docs/skills-protocol.md`](docs/skills-protocol.md)에 정리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 받지 않는 것
|
||||
|
||||
프로젝트의 초점을 유지하기 위해, 다음과 같은 PR은 열지 말아주세요.
|
||||
|
||||
- **모델 런타임을 vendor로 포함.** OD의 핵심 베팅은 "이미 쓰고 있는 CLI면 충분하다"입니다. `pi-ai`나 OpenAI 키, 모델 로더를 제공하지 않습니다.
|
||||
- **사전 논의 없이 현재 스택에서 벗어나는 프론트엔드 재작성.** Next.js 16 App Router + React 18 + TS가 기준선입니다. maintainer가 명시적으로 그 마이그레이션을 원하지 않는 한 Astro, Solid, Svelte 같은 다른 프레임워크로의 재작성은 받지 않습니다.
|
||||
- **daemon을 serverless 함수로 대체.** daemon의 존재 이유는 실제 `cwd`를 소유하고 실제 CLI를 spawn하는 것입니다. SPA를 Vercel에 배포하는 것은 괜찮지만, daemon은 daemon으로 남습니다.
|
||||
- **텔레메트리 / 분석 / phone-home 추가.** OD는 local-first입니다. 외부로 나가는 호출은 사용자가 명시적으로 설정한 provider로 향하는 것뿐입니다.
|
||||
- **바이너리 번들링** 시 라이선스 파일과 저작자 표기를 옆에 두지 않는 경우.
|
||||
|
||||
아이디어가 적합한지 모르겠다면 코드를 작성하기 전에 discussion을 열어주세요.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer 되기
|
||||
|
||||
꾸준히 기여해 왔고 Maintainer가 되는 길이 궁금하다면, 규칙은 **[`MAINTAINERS.ko.md`](MAINTAINERS.ko.md)**에 있습니다. 요약하면 이렇습니다.
|
||||
|
||||
- Maintainer는 issue를 리뷰하고 승인하고 닫을 수 있습니다. 머지 버튼은 Core Team이 쥐고 있지만, 당신의 승인은 머지에 필요한 승인으로 그대로 인정됩니다.
|
||||
- 기준은 **머지된 PR 20건 이상**에 더해, 공개된 계정 품질 검증(봇 방지, sock-puppet 방지)과 기여 품질에 대한 Core Team의 판단입니다. 지원서 양식은 없습니다. Core Team이 내부적으로 후보를 올리고 직접 연락합니다.
|
||||
- **할당량도, SLA도, 정해진 임기도 없습니다.** 물러나는 일은 쉽고 되돌릴 수 있습니다(Emeritus → 여유가 생기면 복귀).
|
||||
- 모든 기준, 추천 절차, 물러나는 규칙, 초기 프로젝트 면제 조항은 [`MAINTAINERS.ko.md`](MAINTAINERS.ko.md)에 있습니다. 위 내용 중 관심 가는 게 있다면 그 문서를 읽어보세요.
|
||||
|
||||
요컨대 좋은 PR을 내고, 사려 깊게 리뷰하고, [Discussions][discussions]와 [Discord][discord]에서 어울리다 보면 나머지는 알아서 따라옵니다.
|
||||
|
||||
[discussions]: https://github.com/nexu-io/open-design/discussions
|
||||
[discord]: https://discord.gg/qhbcCH8Am4
|
||||
|
||||
---
|
||||
|
||||
## 라이선스
|
||||
|
||||
기여하면, 당신의 기여가 이 저장소의 [Apache-2.0 License](LICENSE)를 따른다는 데 동의하는 것입니다. 단 [`design-templates/guizang-ppt/`](design-templates/guizang-ppt/) 안의 파일은 예외로, 원래의 MIT 라이선스와 [op7418](https://github.com/op7418)에 대한 저작자 표기를 그대로 유지합니다.
|
||||
|
||||
[skill]: https://docs.anthropic.com/en/docs/claude-code/skills
|
||||
[guizang]: https://github.com/op7418/guizang-ppt-skill
|
||||
[acd2]: https://github.com/VoltAgent/awesome-design-md
|
||||
[ocod]: https://github.com/OpenCoworkAI/open-codesign
|
||||
191
MAINTAINERS.ko.md
Normal file
191
MAINTAINERS.ko.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
# Maintainers
|
||||
|
||||
<p align="center"><a href="MAINTAINERS.md">English</a> · <a href="MAINTAINERS.pt-BR.md">Português (Brasil)</a> · <a href="MAINTAINERS.de.md">Deutsch</a> · <a href="MAINTAINERS.fr.md">Français</a> · <a href="MAINTAINERS.zh-CN.md">简体中文</a> · <a href="MAINTAINERS.ja-JP.md">日本語</a> · <b>한국어</b></p>
|
||||
|
||||
이 문서는 `nexu-io/open-design`의 Maintainer가 되고, 그 역할을 맡고, 물러나는 규칙을 정합니다. Core Team의 개별 명단은 내부에서 관리하므로 여기에 적지 않습니다. 공개적으로 중요한 건 모두가 따르는 규칙 그 자체입니다.
|
||||
|
||||
> **상태**: v1, 2026-05-11 초안 작성. [`CONTRIBUTING.ko.md`](CONTRIBUTING.ko.md#maintainer-되기)와 짝을 이루는 문서입니다. CONTRIBUTING.ko.md는 전체 규칙을 확인하려는 기여자를 이 문서로 안내합니다.
|
||||
|
||||
---
|
||||
|
||||
## 역할
|
||||
|
||||
| 역할 | 권한 |
|
||||
|---|---|
|
||||
| **Contributor** | 머지된 PR이 1건 이상인 모든 사람. 특별한 권한은 없습니다. |
|
||||
| **External Maintainer** | 아래 규칙에 따라 승격된 커뮤니티 기여자. 리뷰, 승인, issue 닫기/다시 열기, issue 셀프 배정을 할 수 있습니다. **머지 버튼은 누를 수 없습니다** — 이 권한은 Core Team이 갖습니다. |
|
||||
| **Core Team** | Open Design의 내부 팀. 저장소 전체 쓰기 권한을 갖고, 거버넌스 결정의 최종 권한을 가집니다. 명단은 내부에서 관리합니다. |
|
||||
|
||||
별도 언급이 없으면 이 문서의 나머지는 모두 **External Maintainer**에 관한 내용입니다.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer만 할 수 있고 Contributor는 할 수 없는 것
|
||||
|
||||
| 동작 | Contributor | Maintainer |
|
||||
|---|:---:|:---:|
|
||||
| PR 승인 | ⚠️ 단순 코멘트로 처리되며, 머지에 필요한 승인으로 **인정되지 않음** | ✓ 머지에 필요한 승인으로 인정됨 |
|
||||
| issue 닫기 / 다시 열기 | 본인이 연 issue만 | ✓ 모든 issue |
|
||||
| 열려 있고 배정되지 않은 issue 셀프 배정(P0 우선) | ✗ | ✓ |
|
||||
|
||||
### 머지 조건
|
||||
|
||||
PR은 누가 작성했든 다음 **세 가지를 모두** 만족해야 합니다.
|
||||
|
||||
1. 코드 충돌이 없을 것.
|
||||
2. CI가 완전히 통과(green)할 것.
|
||||
3. Maintainer 또는 Core Team 멤버의 승인이 최소 1건 있을 것.
|
||||
|
||||
대부분의 PR은 Maintainer의 승인을 거쳐 머지됩니다. Maintainer의 신뢰가 프로젝트의 일상에 가장 직접적으로 드러나는 방식입니다.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer가 되는 방법
|
||||
|
||||
진입 조건은 **세 가지**이며, 모두 충족해야 합니다.
|
||||
|
||||
### 1. 기여량
|
||||
|
||||
- `nexu-io/open-design`에 머지된 PR **20건 이상**.
|
||||
|
||||
이 수치는 자동 통과 기준이 아니라 최소선입니다. PR 20건을 넘기면 검토 대상에 오를 뿐, 역할이 보장되지는 않습니다.
|
||||
|
||||
### 2. 계정 신뢰도(다중 계정·봇 방지)
|
||||
|
||||
후보의 GitHub 프로필을 일곱 가지 항목으로 점검합니다. **7개 중 5개 이상의 통과 기준을 만족하고, 거부 기준은 하나도 건드리지 않아야 합니다.**
|
||||
|
||||
| # | 항목 | 통과 기준 | 거부 기준 |
|
||||
|---|---|---|---|
|
||||
| 1 | GitHub 계정 나이 | 1년 이상 | 90일 미만 |
|
||||
| 2 | 공개 저장소 | 3개 이상 | 0개 |
|
||||
| 3 | 팔로워 | 10명 이상 | 3명 미만 |
|
||||
| 4 | 팔로워 / 팔로잉 비율 | 0.30 초과 | 0.05 미만(전형적인 팔로우 농장 패턴) |
|
||||
| 5 | 프로필 완성도 | 커스텀 아바타 **그리고** bio / company / blog / twitter 중 하나 이상 | 기본 아바타 **그리고** bio·company·blog가 모두 비어 있음 |
|
||||
| 6 | 다른 프로젝트 활동 | **다른** 공개 저장소에 머지된 PR이 하나 이상 있거나, issue/star 활동이 꾸준함 | 머지된 PR이 이 저장소에만 있음 |
|
||||
| 7 | 계정 상태 | GitHub 플랫폼 제재 이력 없음(스팸/차단/복구) | 위 항목 중 하나라도 해당 |
|
||||
|
||||
#### 초기 프로젝트 면제(저장소가 6개월이 되면 자동 만료)
|
||||
|
||||
`nexu-io/open-design`이 최초 커밋으로부터 6개월 미만인 동안에는, 다음 조건에서 **다른 프로젝트 활동**(#6) 거부 기준을 Core Team 합의로 면제할 수 있습니다.
|
||||
|
||||
- 항목 1, 2, 3, 5가 통과 기준을 명확히 넘을 것, **그리고**
|
||||
- 이 저장소에서의 PR 품질이 Core Team의 직접 리뷰로 높게 평가될 것.
|
||||
|
||||
면제할 때는 후보 이름과 날짜를 Core Team 내부 기록에 함께 남깁니다. 저장소가 6개월에 도달하면 이 면제 조항은 더 이상 적용되지 않습니다.
|
||||
|
||||
### 3. 기여 품질(Core Team 판단)
|
||||
|
||||
정성적인 평가이며 공식에 따르지 않습니다. Core Team은 다음을 봅니다.
|
||||
|
||||
- 머지된 PR의 **코드 품질**(정확성, 작업 범위 준수, 저장소 경계 존중).
|
||||
- 다른 사람의 PR에 남긴 리뷰 코멘트의 **리뷰 품질**.
|
||||
- **커뮤니티 참여** — Discussions, issue 분류, Discord 활동.
|
||||
- **협업 신호** — 피드백에 대한 반응, 기꺼이 수정하려는 자세.
|
||||
|
||||
앞의 두 조건을 통과하면 후보군에 들어갑니다. 이 세 번째 기준을 넘어서야 지명을 받습니다.
|
||||
|
||||
### 선정 절차
|
||||
|
||||
1. Core Team 멤버가 내부에서 후보를 제안합니다.
|
||||
2. Core Team이 합의에 이릅니다.
|
||||
3. Core Team 멤버가 비공개로 연락해 후보의 의사를 확인합니다.
|
||||
4. 온보딩.
|
||||
5. 공개 발표.
|
||||
|
||||
지명 PR도, 공개 투표도, 정해진 임기도 없습니다. 이는 의도적으로 **K8s/Apache의 승인자 투표 모델을 뒤집은** 방식입니다. 프로젝트 초기에는 가벼운 Core Team 합의가 더 빠르게 움직이면서도 같은 수준의 결과를 냅니다. Maintainer 인원이 External Maintainer 다섯 명을 넘어 늘어나면 이 부분을 다시 검토합니다.
|
||||
|
||||
---
|
||||
|
||||
## 책임과 기대
|
||||
|
||||
**엄격한 할당량은 없습니다.** 주간 PR 리뷰 횟수도, 최소 issue 분류 비율도, 응답 시간 SLA도 없습니다. Maintainer는 신뢰의 인정이지, 보수 없는 업무가 아닙니다.
|
||||
|
||||
다만 원칙적으로 부탁드리는 것은 다음과 같습니다.
|
||||
|
||||
- 맥락을 아는 PR은 승인하고, 모르는 PR은 판단을 보류하세요.
|
||||
- 머지 조건(§"머지 조건")을 지키세요. 당신의 승인은 형식적인 도장이 아니라 실질적인 신호입니다.
|
||||
- 오랜 기간 자리를 비울 때는 `#maintainers`에 알려 주세요.
|
||||
- `#maintainers`에서 공유되는 미공개 로드맵은 기밀로 다뤄 주세요.
|
||||
|
||||
Core Team이 나쁜 행태(형식적 승인, 악의적 issue 닫기, 미공개 로드맵 유출 등)의 패턴을 확인하면, §"사유 기반 사임"에 따라 권한을 회수합니다.
|
||||
|
||||
---
|
||||
|
||||
## Maintainer 전용 접근 권한
|
||||
|
||||
위에 적은 저장소 권한 외에도, Maintainer는 일반 커뮤니티가 받지 못하는 몇 가지를 받습니다.
|
||||
|
||||
- **Discord `#maintainers` 채널** — Core Team과 공유하는 비공개 작업 공간. 디자인 미리보기, RFC 초안, 그리고 아직 공개되지 않은 로드맵에 대한 내부 조율에 씁니다.
|
||||
- **기밀 로드맵** — 아직 발표되지 않은 작업을 미리 볼 수 있습니다. Maintainer는 Core Team 멤버가 공개로 발표하기 전까지 그 내용을 기밀로 다루기로 합의합니다.
|
||||
- **Core Team과의 직통 라인** — `#maintainers` 메시지는 공개 Discussions보다 더 빠르고 충실한 답을 받습니다. Core Team은 아키텍처와 로드맵 결정에 Maintainer의 의견을 진심으로 구합니다.
|
||||
- **Maintainer 배지** — GitHub 프로필과 MAINTAINERS 관련 저장소 화면에 표시되는 공개 신뢰 표식(GitHub 배지 기능이 준비되는 대로 적용).
|
||||
- **승격 시 공개 인정** — 합류할 때 Twitter, GitHub Discussions, Discord에서 발표합니다.
|
||||
|
||||
---
|
||||
|
||||
## 사임
|
||||
|
||||
Maintainer는 종신직이 아닙니다. 세 가지 퇴장 경로가 있습니다.
|
||||
|
||||
### 자발적 사임(스스로)
|
||||
|
||||
- Maintainer가 Core Team에 메시지를 보내거나 `#maintainers`에 글을 올립니다.
|
||||
- 권한은 24시간 안에 회수됩니다.
|
||||
- 해당 Maintainer는 **Emeritus** 상태로 전환됩니다.
|
||||
- 공개적인 사유는 필요 없습니다.
|
||||
|
||||
### 비활동 전환
|
||||
|
||||
다음 중 **하나라도** 해당하면 비활동 전환 대상으로 검토합니다.
|
||||
|
||||
- 활동 신호(머지된 PR, 리뷰 코멘트, issue 분류, 비중 있는 Discussion이나 Discord 참여)가 90일 연속 없거나, **또는**
|
||||
- @ 멘션(PR 리뷰 요청, issue 배정)에 60일 연속 응답이 없음.
|
||||
|
||||
절차:
|
||||
|
||||
1. Core Team이 `#maintainers`에서 해당 Maintainer를 비공개로 @ 멘션하고, **14일의 응답 기간**을 줍니다.
|
||||
2. 14일 안에 의미 있는 응답이 없으면 Emeritus로 전환되고 권한이 회수됩니다.
|
||||
3. GitHub Discussions에 짧고 따뜻한 공개 안내를 올립니다: "그동안의 기여에 감사합니다. Emeritus로 전환되었으며, 언제든 다시 돌아오셔도 좋습니다."
|
||||
4. 복귀는 쉽습니다 — 아래 "Emeritus"를 참고하세요.
|
||||
|
||||
### 사유 기반 사임
|
||||
|
||||
다음에 의해 발동됩니다.
|
||||
|
||||
- 반복되는 나쁜 행태(예: 함량 미달 PR에 대한 형식적 승인, 악의적 issue 닫기, 권한 남용).
|
||||
- 프로젝트 [행동 강령][coc] 위반.
|
||||
- 보안 등급의 사고(계정 탈취를 즉시 보고하지 않음, 미공개 로드맵 고의 유출 등).
|
||||
|
||||
절차:
|
||||
|
||||
1. Core Team 멤버는 누구나 논의를 시작할 수 있습니다.
|
||||
2. 조치를 취하기 전에 **Core Team 멤버 3명 이상**이 동의해야 합니다(Core Team 전원 합의까지는 필요하지 않습니다).
|
||||
3. 결정 후 24시간 안에: 권한 회수, `#maintainers`에서 제외, 모든 Maintainer 명단에서 제거(Emeritus로 전환되지 **않습니다**).
|
||||
4. 당사자에게 결정과 사유를 알리며, 한 차례 이의를 제기할 수 있습니다.
|
||||
|
||||
원칙은 **Maintainer를 가급적 유지하는 쪽**입니다. 작은 실수 한 번은 강제 사임의 사유가 되지 않습니다. 사유 기반 경로는 반복되는 패턴이나 심각한 일회성 사고에만 적용합니다.
|
||||
|
||||
[coc]: https://www.contributor-covenant.org/
|
||||
|
||||
---
|
||||
|
||||
## Emeritus
|
||||
|
||||
자발적으로 사임하거나 비활동으로 전환된 Maintainer는 **Emeritus**가 됩니다. Emeritus 상태는 다음과 같습니다.
|
||||
|
||||
- 쓰기/승인/닫기 권한을 회수합니다.
|
||||
- (내부) 명단의 Emeritus 항목에 이름을 남겨 공로를 기립니다.
|
||||
- Discord `#maintainers` 접근 권한을 유지합니다(읽기든 글쓰기든 Maintainer가 선택).
|
||||
- 이어지는 책임은 없습니다.
|
||||
|
||||
### Emeritus에서 복귀
|
||||
|
||||
가장 간단한 복귀 경로: 최근 30일 안에 머지된 PR 3건. 그러면 Core Team이 권한을 복원합니다. 다시 지명받을 필요는 없습니다.
|
||||
|
||||
Emeritus의 취지는 인생에는 늘 일이 생긴다는 점을 인정하는 것입니다. 안식년, 이직, 육아 같은 일을 양쪽 모두에게 아무런 부담이나 사회적 비용 없이 받아들이려는 것입니다.
|
||||
|
||||
---
|
||||
|
||||
## 이 문서의 변경
|
||||
|
||||
이 문서의 규칙은 Core Team 합의로 개정할 수 있습니다. 중대한 변경(진입 조건, 사임 기준)은 활동 중인 후보에게 적용되기 전에 GitHub Discussions에 발표합니다. 편집상의 명확화는 곧바로 반영할 수 있습니다.
|
||||
391
QUICKSTART.ko.md
Normal file
391
QUICKSTART.ko.md
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
# 빠른 시작
|
||||
|
||||
<p align="center"><a href="QUICKSTART.md">English</a> · <a href="QUICKSTART.pt-BR.md">Português (Brasil)</a> · <a href="QUICKSTART.de.md">Deutsch</a> · <a href="QUICKSTART.fr.md">Français</a> · <a href="QUICKSTART.ja-JP.md">日本語</a> · <a href="QUICKSTART.zh-CN.md">简体中文</a> · <a href="QUICKSTART.zh-TW.md">繁體中文</a> · <b>한국어</b></p>
|
||||
|
||||
제품 전체를 로컬에서 실행해 보세요.
|
||||
|
||||
## 환경 요구사항
|
||||
|
||||
- **Node.js:** `~24`(Node 24.x). `package.json#engines`로 버전을 강제합니다.
|
||||
- **pnpm:** `10.33.x`. `packageManager`에 `pnpm@10.33.2`를 고정해 두었으니, Corepack을 쓰면 고정된 버전이 자동으로 선택됩니다.
|
||||
- **OS:** macOS, Linux, WSL2가 주요 지원 환경입니다. Windows 네이티브도 지원합니다. 자주 겪는 설치 문제는 [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)를 참고하세요.
|
||||
- **선택: 로컬 에이전트 CLI:** Claude Code, Codex, Devin for Terminal, Gemini CLI, OpenCode, Cursor Agent, Qwen, Qoder CLI, GitHub Copilot CLI 등. 설치된 CLI가 없으면 Settings에서 BYOK API 모드를 쓰면 됩니다.
|
||||
|
||||
### 로컬 에이전트 CLI와 PATH
|
||||
|
||||
daemon은 **`PATH`**(여기에 더해 자주 쓰이는 사용자 툴체인 디렉터리)를 스캔합니다. **`npm install -g`**나 **Homebrew**로 CLI를 설치했는데도 Open Design이 *not installed*로 표시한다면, GUI가 최소한의 `PATH`로 시작하면서 전역 npm이나 Homebrew의 `bin` 디렉터리를 포함하지 못한 경우입니다(앱이 전체 로그인 셸에서 실행되지 않은 macOS에서 흔히 발생). daemon을 실행하는 프로세스의 `PATH`에 실행 파일 디렉터리가 들어 있는지 확인한 뒤, **Settings → Execution mode**에서 **Rescan**을 누르세요.
|
||||
|
||||
[`nvm`](https://github.com/nvm-sh/nvm) / [`fnm`](https://github.com/Schniz/fnm)은 편의를 위한 선택 도구일 뿐, 프로젝트 설정에 꼭 필요한 것은 아닙니다. 둘 중 하나를 쓴다면 pnpm을 실행하기 전에 Node 24를 설치하고 선택하세요.
|
||||
|
||||
```bash
|
||||
# nvm
|
||||
nvm install 24
|
||||
nvm use 24
|
||||
|
||||
# fnm
|
||||
fnm install 24
|
||||
fnm use 24
|
||||
```
|
||||
|
||||
그다음 Corepack을 켜고 리포지토리가 pnpm을 선택하도록 합니다.
|
||||
|
||||
```bash
|
||||
corepack enable
|
||||
corepack pnpm --version # 10.33.2가 출력되어야 합니다
|
||||
```
|
||||
|
||||
## Docker 설정
|
||||
|
||||
Node.js나 pnpm을 로컬에 설치하지 않고도, 완전히 컨테이너화된 환경에서 Open Design을 실행할 수 있습니다.
|
||||
|
||||
### 요구사항
|
||||
|
||||
* Docker Desktop
|
||||
* Docker Compose v2
|
||||
|
||||
Docker가 제대로 설치됐는지 확인하세요.
|
||||
|
||||
```bash
|
||||
docker compose version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open Design 시작하기
|
||||
|
||||
리포지토리 루트에서 진행합니다.
|
||||
|
||||
1. deploy 디렉터리로 이동한 뒤 환경 변수 템플릿을 복사합니다.
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. 안전한 토큰을 생성합니다.
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
3. 에디터에서 `.env`를 열고 `OD_API_TOKEN=`을 찾아, 방금 생성한 토큰을 붙여 넣습니다.
|
||||
|
||||
이제 서비스를 시작합니다.
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
브라우저에서 앱을 엽니다.
|
||||
|
||||
```text
|
||||
http://localhost:7456
|
||||
```
|
||||
|
||||
처음 시작할 때는 Docker가 최신 이미지를 받아오느라 몇 초 걸릴 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 자주 쓰는 Docker 명령어
|
||||
|
||||
### 로그 보기
|
||||
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
### 컨테이너 재시작
|
||||
|
||||
```bash
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
### 컨테이너 중지
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### 최신 이미지 받아오기
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 로컬 앱 데이터 전부 삭제
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 환경 설정
|
||||
|
||||
기본 설정을 덮어쓰려면 `deploy/.env` 파일을 만드세요. 제공된 예시에서 시작하면 됩니다.
|
||||
|
||||
```bash
|
||||
cp deploy/.env.example deploy/.env
|
||||
```
|
||||
|
||||
`deploy/.env`를 편집해 직접 만든 토큰을 넣고, 나머지 값도 필요에 맞게 조정합니다.
|
||||
|
||||
```env
|
||||
# 호스트에 노출할 포트
|
||||
OPEN_DESIGN_PORT=7456
|
||||
|
||||
# 컨테이너 메모리 제한
|
||||
OPEN_DESIGN_MEM_LIMIT=384m
|
||||
|
||||
# 허용할 CORS origin
|
||||
OPEN_DESIGN_ALLOWED_ORIGINS=https://yourdomain.com
|
||||
|
||||
# Docker 이미지 태그
|
||||
OPEN_DESIGN_IMAGE=docker.io/vanjayak/open-design:latest
|
||||
|
||||
# daemon 보안에 필요한 API 토큰
|
||||
# 생성 방법: openssl rand -hex 32
|
||||
OD_API_TOKEN=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 영구 저장소
|
||||
|
||||
Open Design은 프로젝트와 SQLite 데이터를 Docker 볼륨 안에 저장합니다.
|
||||
|
||||
```text
|
||||
open_design_data
|
||||
```
|
||||
|
||||
이 볼륨은 다음 경로에 마운트됩니다.
|
||||
|
||||
```text
|
||||
/app/.od
|
||||
```
|
||||
|
||||
데이터는 컨테이너를 재시작하거나 이미지를 업데이트해도 그대로 유지됩니다.
|
||||
|
||||
볼륨을 확인하려면:
|
||||
|
||||
```bash
|
||||
docker volume inspect open-design_open_design_data
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 참고
|
||||
|
||||
* Docker 모드는 로컬에 Node.js나 pnpm을 설치하고 싶지 않은 기여자에게 적합합니다.
|
||||
* 컨테이너는 프로덕션 daemon 빌드를 `7456` 포트에 직접 노출합니다.
|
||||
* 개발 워크플로우와 더 깊은 로컬 설정은 이 빠른 시작 가이드의 나머지 부분을 참고하세요.
|
||||
|
||||
---
|
||||
|
||||
## 한 번에 실행하기 (dev 모드)
|
||||
|
||||
```bash
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm tools-dev run web # daemon + web을 포그라운드로 시작합니다
|
||||
# tools-dev가 출력한 web URL을 엽니다
|
||||
```
|
||||
|
||||
데스크톱 셸과 관리 대상 sidecar 전부를 백그라운드로 실행하려면:
|
||||
|
||||
```bash
|
||||
pnpm tools-dev # daemon + web + desktop을 백그라운드로 시작합니다
|
||||
```
|
||||
|
||||
처음 로드하면 앱은 설치된 코드 에이전트 CLI(Claude Code / Codex / Devin for Terminal / Gemini / OpenCode / Cursor Agent / Qwen / Qoder CLI)를 감지해 자동으로 선택하고, 기본값으로 `web-prototype` skill과 `Neutral Modern` 디자인 시스템을 씁니다. 프롬프트를 입력하고 **Send**를 누르세요. 에이전트가 왼쪽 패널에 스트리밍되고, `<artifact>` 태그가 파싱되면서 HTML이 오른쪽에 실시간으로 렌더링됩니다. 작업이 끝나면 **Save to disk**를 눌러 artifact를 `./.od/artifacts/<timestamp>-<slug>/index.html`에 저장하세요.
|
||||
|
||||
**Design system** 드롭다운에는 71개의 내장 시스템이 들어 있습니다. 직접 작성한 스타터 2개(Neutral Modern, Warm Editorial)와 [`awesome-design-md`](https://github.com/VoltAgent/awesome-design-md)에서 가져온 69개의 제품 시스템으로, 카테고리별(AI & LLM, Developer Tools, Productivity, Backend, Design Tools, Fintech, E-Commerce, Media, Automotive)로 묶여 있습니다. 하나를 고르면 모든 프로토타입이 그 브랜드의 미감으로 입혀집니다. 여기에 [`awesome-design-skills`](https://github.com/bergside/awesome-design-skills)에서 가져온 57개의 디자인 skill도 함께 제공됩니다.
|
||||
|
||||
**Skill** 드롭다운은 모드별(Prototype / Deck / Template / Design system)로 묶이며, 각 모드의 기본 skill에는 `· default` 접미사가 붙습니다. 함께 제공되는 skill은 다음과 같습니다.
|
||||
|
||||
- **Prototype** — `web-prototype`(범용), `saas-landing`, `dashboard`, `pricing-page`, `docs-page`, `blog-post`, `mobile-app`.
|
||||
- **Deck / PPT** — `simple-deck`(단일 파일 가로 스와이프)과 `magazine-web-ppt`([`op7418/guizang-ppt-skill`](https://github.com/op7418/guizang-ppt-skill)에서 가져온 `guizang-ppt` 번들. deck 모드의 기본값이며, 자체 에셋/템플릿과 레퍼런스 4개를 함께 제공). 사이드 파일이 있는 skill에는 "Skill root (absolute)" 머리말이 자동으로 붙어, 에이전트가 `assets/template.html`이나 `references/*.md`를 CWD가 아니라 실제 디스크 경로 기준으로 해석할 수 있습니다.
|
||||
|
||||
skill과 디자인 시스템을 짝지으면, 프롬프트 하나로 선택한 시각 언어에 맞는 프로토타입이나 deck이 레이아웃에 맞게 생성됩니다.
|
||||
|
||||
## 그 밖의 스크립트
|
||||
|
||||
```bash
|
||||
pnpm tools-dev # daemon + web + desktop을 백그라운드로
|
||||
pnpm tools-dev start web # daemon + web을 백그라운드로
|
||||
pnpm tools-dev run web # daemon + web을 포그라운드로 (e2e/dev 서버)
|
||||
pnpm tools-dev restart # daemon + web + desktop 재시작
|
||||
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
|
||||
pnpm tools-dev status # 관리 중인 런타임 확인
|
||||
pnpm tools-dev logs # daemon/web/desktop 로그 보기
|
||||
pnpm tools-dev check # 상태 + 최근 로그 + 일반 진단
|
||||
pnpm tools-dev stop # 관리 중인 런타임 중지
|
||||
pnpm --filter @open-design/daemon build # `od`용 apps/daemon/dist/cli.js 빌드
|
||||
pnpm --filter @open-design/web build # 필요할 때 web 패키지 빌드
|
||||
pnpm typecheck # 워크스페이스 타입 체크
|
||||
```
|
||||
|
||||
로컬 라이프사이클의 진입점은 `pnpm tools-dev` 하나뿐입니다. 삭제된 옛 루트 alias(`pnpm dev`, `pnpm dev:all`, `pnpm daemon`, `pnpm preview`, `pnpm start`)는 쓰지 마세요.
|
||||
|
||||
로컬 개발 중에는 `tools-dev`가 daemon을 먼저 띄우고 그 포트를 `apps/web`에 넘깁니다. `apps/web/next.config.ts`는 `/api/*`, `/artifacts/*`, `/frames/*`를 그 daemon 포트로 다시 매핑하므로, App Router 앱이 CORS 설정 없이도 옆에서 도는 Express 프로세스와 통신할 수 있습니다.
|
||||
|
||||
## 미디어 생성 / 에이전트 dispatcher 점검
|
||||
|
||||
이미지, 비디오, 오디오, HyperFrames skill은 daemon이 에이전트를 spawn할 때 주입하는 환경 변수를 통해 로컬 `od` CLI를 호출합니다.
|
||||
|
||||
- `OD_BIN` — `apps/daemon/dist/cli.js`의 절대 경로.
|
||||
- `OD_DAEMON_URL` — 실행 중인 daemon URL.
|
||||
- `OD_PROJECT_ID` — 활성 프로젝트 id.
|
||||
- `OD_PROJECT_DIR` — 활성 프로젝트의 파일 디렉터리.
|
||||
|
||||
미디어 생성이 `OD_BIN: parameter not set`, `apps/daemon/dist/cli.js` 누락, `failed to reach daemon at http://127.0.0.1:0` 같은 메시지로 실패하면, daemon CLI를 다시 빌드하고 관리 중인 런타임을 재시작하세요.
|
||||
|
||||
```bash
|
||||
pnpm --filter @open-design/daemon build
|
||||
pnpm tools-dev restart --daemon-port 7457 --web-port 5175
|
||||
ls -la apps/daemon/dist/cli.js
|
||||
curl -s http://127.0.0.1:7457/api/health
|
||||
```
|
||||
|
||||
그다음 오래된 터미널 에이전트 세션을 이어 가지 말고, Open Design 앱에서 프로젝트를 다시 여세요. daemon이 spawn한 에이전트라면 다음과 같은 값이 보여야 합니다.
|
||||
|
||||
```bash
|
||||
echo "OD_BIN=$OD_BIN"
|
||||
echo "OD_PROJECT_ID=$OD_PROJECT_ID"
|
||||
echo "OD_PROJECT_DIR=$OD_PROJECT_DIR"
|
||||
echo "OD_DAEMON_URL=$OD_DAEMON_URL"
|
||||
ls -la "$OD_BIN"
|
||||
```
|
||||
|
||||
`OD_DAEMON_URL`은 `http://127.0.0.1:7457`처럼 실제 daemon 포트여야 하며, `http://127.0.0.1:0`이어서는 안 됩니다. `:0` 값은 "빈 포트를 골라라"라는 내부 실행 힌트일 뿐이라, 에이전트 세션으로 새어 나가면 안 됩니다.
|
||||
|
||||
daemon 단독 프로덕션 모드에서는 daemon이 정적 Next.js export를 `http://localhost:7456`에서 직접 서빙하므로, reverse proxy가 끼어들지 않습니다.
|
||||
|
||||
daemon 앞에 nginx를 둔다면, SSE 경로는 버퍼링과 압축을 끄세요. 흔한 실패는 80~90초 뒤 브라우저 콘솔에 `net::ERR_INCOMPLETE_CHUNKED_ENCODING 200 (OK)`가 뜨는 경우입니다. daemon이 `X-Accel-Buffering: no`를 보내도 nginx의 `gzip on`이 청크 SSE 응답을 버퍼링하기 때문입니다.
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:7456;
|
||||
|
||||
proxy_buffering off;
|
||||
gzip off;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
```
|
||||
|
||||
## 두 가지 실행 모드
|
||||
|
||||
| 모드 | 선택값 | 요청이 흐르는 경로 |
|
||||
|---|---|---|
|
||||
| **Local CLI** (daemon이 에이전트를 감지하면 기본값) | "Local CLI" | 프론트엔드 → daemon `/api/chat` → `spawn(<agent>, ...)` → stdout → SSE → artifact 파서 → 미리보기 |
|
||||
| **API 모드** (대체 수단 / CLI 없음) | "Anthropic API" / "OpenAI API" / "Azure OpenAI" / "Google Gemini" | 프론트엔드 → daemon `/api/proxy/{provider}/stream` → 프로바이더 SSE를 `delta/end/error`로 정규화 → artifact 파서 → 미리보기 |
|
||||
|
||||
두 모드 모두 **같은** `<artifact>` 파서와 **같은** 샌드박스 iframe으로 들어갑니다. 다른 점은 전송 방식과 시스템 프롬프트 전달 방식뿐입니다(로컬 CLI는 별도의 시스템 채널이 없어서, 조합된 프롬프트를 사용자 메시지 안에 접어 넣습니다).
|
||||
|
||||
## 프롬프트 구성
|
||||
|
||||
전송할 때마다 앱은 세 개의 레이어로 시스템 프롬프트를 만들어 프로바이더에 보냅니다.
|
||||
|
||||
```
|
||||
BASE_SYSTEM_PROMPT (출력 규약: <artifact>로 감싸고, 코드 펜스는 쓰지 않는다)
|
||||
+ 활성 디자인 시스템 본문 (DESIGN.md — 팔레트/타입/레이아웃)
|
||||
+ 활성 skill 본문 (SKILL.md — 워크플로우와 출력 규칙)
|
||||
```
|
||||
|
||||
상단 바에서 skill이나 디자인 시스템을 바꾸면, 다음 전송부터 새 조합이 적용됩니다. 본문은 세션별로 메모리에 캐시되므로, 한 번 선택할 때 daemon에 한 번만 요청합니다.
|
||||
|
||||
## 파일 맵
|
||||
|
||||
```
|
||||
open-design/
|
||||
├── apps/
|
||||
│ ├── daemon/ # Node/Express — 로컬 에이전트 spawn + API 서빙
|
||||
│ │ └── src/
|
||||
│ │ ├── cli.ts # `od` bin 진입점
|
||||
│ │ ├── server.ts # /api/* + 정적 서빙
|
||||
│ │ ├── agents.ts # claude/codex/devin/gemini/opencode/cursor-agent/qwen/qoder/copilot용 PATH 스캐너
|
||||
│ │ ├── skills.ts # SKILL.md 로더 (frontmatter 파서)
|
||||
│ │ └── design-systems.ts # DESIGN.md 로더
|
||||
│ │ ├── sidecar/ # tools-dev daemon sidecar 래퍼
|
||||
│ │ └── tests/ # daemon 패키지 테스트
|
||||
│ ├── web/ # Next.js 16 App Router + React 클라이언트
|
||||
│ ├── app/ # App Router 진입점
|
||||
│ ├── src/ # React + TypeScript 클라이언트/런타임 모듈
|
||||
│ │ ├── App.tsx # 모드 / skill / DS 선택기 + 전송 조율
|
||||
│ │ ├── providers/ # daemon + BYOK API 전송 계층
|
||||
│ │ ├── prompts/ # system, discovery, directions, deck framework
|
||||
│ │ ├── artifacts/ # 스트리밍 <artifact> 파서 + manifest
|
||||
│ │ ├── runtime/ # iframe srcdoc, markdown, export 헬퍼
|
||||
│ │ └── state/ # localStorage + daemon 기반 프로젝트 상태
|
||||
│ ├── sidecar/ # tools-dev web sidecar 래퍼
|
||||
│ └── next.config.ts # tools-dev rewrite + prod apps/web/out export 설정
|
||||
│ └── desktop/ # tools-dev가 실행/점검하는 Electron 런타임
|
||||
├── packages/
|
||||
│ ├── contracts/ # 공유 web/daemon 앱 contract
|
||||
│ ├── sidecar-proto/ # Open Design sidecar 프로토콜 contract
|
||||
│ ├── sidecar/ # 범용 sidecar 런타임 프리미티브
|
||||
│ └── platform/ # 범용 process/platform 프리미티브
|
||||
├── tools/dev/ # `pnpm tools-dev` 라이프사이클 및 inspect CLI
|
||||
├── e2e/ # Playwright UI + 외부 통합/Vitest 하네스
|
||||
├── skills/ # SKILL.md — 어떤 Claude Code skill 리포지토리에서든 그대로 넣을 수 있음
|
||||
│ ├── web-prototype/ # 범용 단일 화면 프로토타입 (prototype 모드 기본값)
|
||||
│ ├── saas-landing/ # 마케팅 페이지 (hero / features / pricing / CTA)
|
||||
│ ├── dashboard/ # 관리자 / 분석 대시보드
|
||||
│ ├── pricing-page/ # 단독 가격 + 비교
|
||||
│ ├── docs-page/ # 3단 문서 레이아웃
|
||||
│ ├── blog-post/ # 에디토리얼 롱폼
|
||||
│ ├── mobile-app/ # 휴대폰 프레임 단일 화면
|
||||
│ ├── simple-deck/ # 미니멀 가로 스와이프 deck
|
||||
│ └── guizang-ppt/ # magazine-web-ppt — 번들된 deck/PPT 기본값
|
||||
│ ├── SKILL.md
|
||||
│ ├── assets/template.html
|
||||
│ └── references/{themes,layouts,components,checklist}.md
|
||||
├── design-systems/ # DESIGN.md — 9개 섹션 스키마 (awesome-claude-design)
|
||||
│ ├── default/ # Neutral Modern (스타터)
|
||||
│ ├── warm-editorial/ # Warm Editorial (스타터)
|
||||
│ ├── README.md # 카탈로그 개요
|
||||
│ └── …129개 시스템 # 스타터 2개 · 제품 시스템 70개 · 디자인 skill 57개
|
||||
├── scripts/sync-design-systems.ts # 업스트림 getdesign tarball에서 다시 import
|
||||
├── docs/ # 제품 비전 + 스펙
|
||||
├── .od/ # 런타임 데이터 (gitignore 처리, 자동 생성)
|
||||
│ ├── app.sqlite # 프로젝트 / 대화 / 메시지 / 탭
|
||||
│ ├── artifacts/ # 일회성 "Save to disk" 렌더링
|
||||
│ └── projects/<id>/ # 프로젝트별 작업 디렉터리 + 에이전트 cwd
|
||||
├── pnpm-workspace.yaml # apps/* + packages/* + tools/* + e2e
|
||||
└── package.json # 루트 품질 스크립트 + `od` bin
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
- **Node.js 버전을 바꾼 뒤 `better-sqlite3`가 로드되지 않거나 ABI가 맞지 않을 때** — `pnpm install`이 `postinstall`을 자동으로 다시 돌려 현재 Node.js에 맞게 네이티브 애드온을 리빌드합니다. 직접 리빌드하거나 수정을 확인하려면 `pnpm --filter @open-design/daemon rebuild better-sqlite3`를 실행한 뒤 `pnpm --filter @open-design/daemon exec node -e "require('better-sqlite3')"`를 돌리세요. 빌드 도구 `python3`, `make`, `g++`(또는 `clang++`)가 필요합니다. `.npmrc`에 `ignore-scripts=true`가 있다면, `pnpm install` 후 `node scripts/postinstall.mjs`를 실행하세요.
|
||||
- **"no agents found on PATH"** — `claude`, `codex`, `devin`, `gemini`, `opencode`, `cursor-agent`, `qwen`, `qodercli`, `copilot` 중 하나를 설치하세요. 아니면 Settings에서 API 모드로 바꾸고 프로바이더 키를 붙여 넣으세요.
|
||||
- **Claude Code가 코드 1로 종료될 때** — Open Design이 `claude`를 시작하긴 했지만, spawn된 비대화형 실행이 응답을 내기 전에 실패한 경우입니다. Open Design을 시작하는 셸이나 앱 환경과 같은 곳에서 다음을 확인하세요.
|
||||
```bash
|
||||
claude --version
|
||||
claude auth status --text
|
||||
printf 'hello' | claude -p --output-format stream-json --verbose --permission-mode bypassPermissions
|
||||
```
|
||||
스모크 테스트가 커스텀 엔드포인트 없이 `401`, `apiKeySource: "none"`, 또는 다른 인증 오류를 낸다면, `claude`를 실행해 `/login`한 뒤 Claude를 종료하고 Open Design을 다시 시도하세요. Claude 프로필을 여러 개 쓴다면 **Settings -> Execution mode -> Claude Code config directory**를 `~/.claude-2` 같은 프로필 경로로 지정하세요. `ANTHROPIC_BASE_URL`이나 프록시가 설정돼 있다면 엔드포인트 URL, 프록시 자격 증명, 엔드포인트 인증 환경, 모델 접근 권한을 확인하세요. 표준 Claude Code 인증으로 다시 시도하려는 게 아니라면 커스텀 엔드포인트는 그대로 두세요. Windows에서는 네이티브 PowerShell과 WSL이 서로 다른 Claude 설치본과 자격 증명 저장소를 쓰므로, Open Design이 쓰는 것과 같은 환경에서 다시 인증하고, `/login`으로도 네이티브 Windows 자격 증명이 복구되지 않으면 Windows 자격 증명 관리자를 확인하세요.
|
||||
- **`/api/chat`에서 daemon 500** — daemon 터미널의 stderr 끝부분을 확인하세요. 대개 CLI가 자신의 인자를 거부한 경우입니다. CLI마다 받는 argv 형태가 다릅니다. 손봐야 한다면 `apps/daemon/src/agents.ts`의 `buildArgs`를 보세요.
|
||||
- **미디어 생성이 `OD_BIN` 누락 또는 daemon URL이 `:0`이라고 할 때** — 위의 미디어 dispatcher 점검을 실행하세요. 옛 CLI 세션을 이어 가지 말고, Open Design 앱에서 프로젝트를 다시 열어 daemon이 새 `OD_*` 변수를 주입하게 하세요.
|
||||
- **Codex가 플러그인 컨텍스트를 너무 많이 로드할 때** — `OD_CODEX_DISABLE_PLUGINS=1 pnpm tools-dev`로 Open Design을 시작하면, daemon이 spawn하는 Codex 프로세스가 `--disable plugins`로 실행됩니다.
|
||||
- **artifact가 끝내 렌더링되지 않을 때** — 모델이 `<artifact>`로 감싸지 않은 텍스트를 만든 경우입니다. 시스템 프롬프트가 제대로 전달되는지 확인하고(daemon 로그 확인), 더 강력한 모델이나 더 엄격한 skill로 바꿔 보세요.
|
||||
|
||||
## 비전과 다시 잇기
|
||||
|
||||
이 빠른 시작은 [`docs/`](docs/)에 담긴 스펙을 실제로 돌릴 수 있게 만든 씨앗입니다. 스펙은 이것이 어디로 자라날지 설명합니다([`docs/roadmap.md`](docs/roadmap.md) 참고). 핵심만 짚으면 다음과 같습니다.
|
||||
|
||||
- `docs/architecture.md`는 출시된 스택을 설명합니다. 앞단의 Next.js 16 App Router, 그 뒤의 로컬 daemon, 그리고 dev에서 `apps/web/next.config.ts`의 rewrite가 브라우저를 같은 `/api` 표면과 계속 통신하게 유지하는 구조입니다.
|
||||
- `docs/skills-protocol.md`는 `od:` frontmatter 전체(타입 입력, 슬라이더, 기능 게이팅)를 설명합니다. 이 MVP는 `name` / `description` / `triggers` / `od.mode` / `od.design_system.requires`만 읽습니다. 나머지를 추가하려면 `apps/daemon/src/skills.ts`를 확장하세요.
|
||||
- `docs/agent-adapters.md`는 더 풍부한 dispatch(기능 감지, 스트리밍 tool-call)를 내다봅니다. 우리의 `apps/daemon/src/agents.ts`는 배선을 증명할 만큼만 갖춘 최소 dispatcher입니다.
|
||||
- `docs/modes.md`는 네 가지 모드를 나열합니다. prototype / deck / template / design-system. 우리는 앞의 두 가지를 위한 skill을 제공하며, 선택기는 이미 `mode`로 필터링합니다.
|
||||
|
|
@ -800,7 +800,7 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
|
|||
شكراً لكلّ من ساعد في دفع Open Design للأمام — بكود، بوثائق، بملاحظات، بـ skills جديدة، بأنظمة تصميم جديدة، أو حتى بـ issue حادّة. كلّ مساهمة حقيقية تهمّ، والجدار أدناه أسهل طريقة لقول ذلك علناً.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
إن شحنت أوّل PR — مرحباً. تصنيف [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) هو نقطة الدخول.
|
||||
|
|
@ -817,9 +817,9 @@ Issues و PRs و skills جديدة وأنظمة تصميم جديدة، كلّه
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -726,7 +726,7 @@ Vollständiger Walkthrough, Merge-Messlatte, Code Style und was wir nicht annehm
|
|||
Danke an alle, die Open Design vorangebracht haben: durch Code, Docs, Feedback, neue Skills, neue Design Systems oder auch ein scharfes Issue. Jeder echte Beitrag zählt, und die Wand unten ist die einfachste Art, das laut zu sagen.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
Wenn Sie Ihren ersten PR gemergt haben: willkommen. Das Label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ist der Einstiegspunkt.
|
||||
|
|
@ -743,9 +743,9 @@ Das SVG oben wird täglich von [`.github/workflows/metrics.yml`](.github/workflo
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -787,7 +787,7 @@ Walkthrough completo, estándar de merge, code style y lo que no aceptamos → [
|
|||
Gracias a todas las personas que han ayudado a mover Open Design hacia adelante: con código, docs, feedback, nuevas skills, nuevos design systems o incluso un issue preciso. Toda contribución real cuenta, y el muro de abajo es la forma más simple de decirlo en voz alta.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contribuidores de Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contribuidores de Open Design" />
|
||||
</a>
|
||||
|
||||
Si ya enviaste tu primer PR, bienvenido. La etiqueta [`good-first-issue`](https://github.com/nexu-io/open-design/labels/good-first-issue) es el punto de entrada.
|
||||
|
|
@ -804,9 +804,9 @@ El SVG anterior se regenera diariamente mediante [`.github/workflows/metrics.yml
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Historial de estrellas de Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -733,7 +733,7 @@ Guide complet, critères de merge, style de code et refus fréquents → [`CONTR
|
|||
Merci à toutes les personnes qui font avancer Open Design : code, docs, retours, nouveaux Skills, nouveaux Design Systems ou issues bien ciblées. Chaque vraie contribution compte.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contributeurs Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contributeurs Open Design" />
|
||||
</a>
|
||||
|
||||
Si vous avez livré votre première PR, bienvenue. Le label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) est le point d’entrée.
|
||||
|
|
@ -750,9 +750,9 @@ Le SVG ci-dessus est régénéré chaque jour par [`.github/workflows/metrics.ym
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Historique des stars Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -723,7 +723,7 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
|
|||
コード、ドキュメント、フィードバック、新 Skill、新 Design System、あるいは鋭い Issue — あらゆる形で Open Design を前進させてくださったすべての方に感謝します。すべての実質的なコントリビューションは大切であり、以下のウォールは最もシンプルな感謝の表明です。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design コントリビューター" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design コントリビューター" />
|
||||
</a>
|
||||
|
||||
初めての PR を送った方 — ようこそ。[`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) ラベルがエントリポイントです。
|
||||
|
|
@ -740,9 +740,9 @@ Issue、PR、新 Skill、新 Design System を歓迎します。最も効果の
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
16
README.ko.md
16
README.ko.md
|
|
@ -33,7 +33,7 @@
|
|||
<a href="#디자인-시스템"><img alt="Design systems" src="https://img.shields.io/badge/design%20systems-149-orange?style=flat-square" /></a>
|
||||
<a href="#내장-skills"><img alt="Skills" src="https://img.shields.io/badge/skills-131-teal?style=flat-square" /></a>
|
||||
<a href="https://discord.gg/qhbcCH8Am4"><img alt="Discord" src="https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white" /></a>
|
||||
<a href="QUICKSTART.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
|
||||
<a href="QUICKSTART.ko.md"><img alt="Quickstart" src="https://img.shields.io/badge/quickstart-3%20commands-green?style=flat-square" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> · <a href="README.es.md">Español</a> · <a href="README.pt-BR.md">Português (Brasil)</a> · <a href="README.de.md">Deutsch</a> · <a href="README.fr.md">Français</a> · <a href="README.zh-CN.md">简体中文</a> · <a href="README.zh-TW.md">繁體中文</a> · <b>한국어</b> · <a href="README.ja-JP.md">日本語</a> · <a href="README.ar.md">العربية</a> · <a href="README.ru.md">Русский</a> · <a href="README.uk.md">Українська</a> · <a href="README.tr.md">Türkçe</a></p>
|
||||
|
|
@ -332,7 +332,7 @@ pnpm tools-dev run web
|
|||
|
||||
Windows 사용자는 네이티브 설치 경로와 작은 더블 클릭 런처에 대해서는 [`docs/windows-troubleshooting.md`](docs/windows-troubleshooting.md)를 참고하세요.
|
||||
|
||||
데스크톱/백그라운드 시작, 고정 포트 재시작, 미디어 생성 dispatcher 확인(`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`)은 [`QUICKSTART.md`](QUICKSTART.md)를 참고하세요.
|
||||
데스크톱/백그라운드 시작, 고정 포트 재시작, 미디어 생성 dispatcher 확인(`OD_BIN`, `OD_DAEMON_URL`, `apps/daemon/dist/cli.js`)은 [`QUICKSTART.ko.md`](QUICKSTART.ko.md)를 참고하세요.
|
||||
|
||||
첫 번째 로드 시:
|
||||
|
||||
|
|
@ -360,7 +360,7 @@ Daemon은 저장소 루트에 하나의 숨겨진 폴더를 소유합니다. 그
|
|||
| 초기 상태로 재설정 | `pnpm tools-dev stop`, `rm -rf .od`, `pnpm tools-dev run web` 재실행 |
|
||||
| 다른 위치로 이동 | 아직 지원되지 않음 — 경로가 저장소 상대 경로로 하드코딩됨 |
|
||||
|
||||
전체 파일 맵, 스크립트, 트러블슈팅 → [`QUICKSTART.md`](QUICKSTART.md).
|
||||
전체 파일 맵, 스크립트, 트러블슈팅 → [`QUICKSTART.ko.md`](QUICKSTART.ko.md).
|
||||
|
||||
## 저장소 구조
|
||||
|
||||
|
|
@ -719,14 +719,14 @@ daemon 부팅 시 `PATH`에서 자동 감지됩니다. 설정 필요 없음. 스
|
|||
- **디자인 시스템 추가** — 9섹션 스키마를 사용하여 [`design-systems/<brand>/`](design-systems/)에 `DESIGN.md`를 드롭하세요.
|
||||
- **새 코딩 에이전트 CLI 연결** — [`apps/daemon/src/agents.ts`](apps/daemon/src/agents.ts)에 항목 하나 추가.
|
||||
|
||||
전체 설명, 병합 기준, 코드 스타일, 받지 않는 것 → [`CONTRIBUTING.md`](CONTRIBUTING.md) ([Deutsch](CONTRIBUTING.de.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
|
||||
전체 설명, 병합 기준, 코드 스타일, 받지 않는 것 → [`CONTRIBUTING.ko.md`](CONTRIBUTING.ko.md) ([English](CONTRIBUTING.md), [Deutsch](CONTRIBUTING.de.md), [Français](CONTRIBUTING.fr.md), [简体中文](CONTRIBUTING.zh-CN.md)).
|
||||
|
||||
## 컨트리뷰터
|
||||
|
||||
Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다 — 코드, 문서, 피드백, 새 skill, 새 디자인 시스템, 또는 날카로운 이슈 하나라도. 모든 진짜 기여가 의미 있고, 아래의 벽이 가장 직접적인 "감사합니다"입니다.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design 컨트리뷰터" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design 컨트리뷰터" />
|
||||
</a>
|
||||
|
||||
첫 PR을 보냈다면 — 환영합니다. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 레이블이 시작점입니다.
|
||||
|
|
@ -743,9 +743,9 @@ Open Design을 앞으로 나아가게 도와준 모든 분께 감사드립니다
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -1040,7 +1040,7 @@ Full walkthrough, bar-for-merging, code style, and what we don't accept → [`CO
|
|||
Thanks to everyone who has helped move Open Design forward — through code, docs, feedback, new skills, new design systems, or even a sharp issue. Every real contribution counts, and the wall below is the easiest way to say so out loud.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
If you've shipped your first PR — welcome. The [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label is the entry point.
|
||||
|
|
@ -1057,9 +1057,9 @@ The SVG above is regenerated daily by [`.github/workflows/metrics.yml`](.github/
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -730,7 +730,7 @@ Walkthrough completo, barra para mergear, estilo de código e o que não aceitam
|
|||
Obrigado a todas as pessoas que ajudaram a empurrar o Open Design pra frente — via código, docs, feedback, novas skills, novos design systems ou até uma issue afiada. Toda contribuição real conta, e a parede abaixo é a forma mais simples de dizer isso em voz alta.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contribuidoras e contribuidores do Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contribuidoras e contribuidores do Open Design" />
|
||||
</a>
|
||||
|
||||
Se você acabou de mandar seu primeiro PR — bem-vindo. A label [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) é o ponto de entrada.
|
||||
|
|
@ -747,9 +747,9 @@ O SVG acima é regenerado diariamente por [`.github/workflows/metrics.yml`](.git
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Histórico de estrelas do Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,7 @@ Issues, PR, новые skills и новые design systems приветству
|
|||
Спасибо всем, кто помогает двигать Open Design вперёд — кодом, документацией, обратной связью, новыми skills, новыми design systems или просто точным issue. Вклад любого реального масштаба здесь важен, а стена ниже — самый простой способ сказать это вслух.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Contributors Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Contributors Open Design" />
|
||||
</a>
|
||||
|
||||
Если вы только что отправили свой первый PR — добро пожаловать. Метка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — хорошая точка входа.
|
||||
|
|
@ -746,9 +746,9 @@ SVG выше ежедневно пересобирается workflow [`.github/
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="История звёзд Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -887,7 +887,7 @@ Tam walkthrough, merge çıtası, code style ve kabul etmediklerimiz → [`CONTR
|
|||
Open Design'ı kod, doküman, feedback, yeni skill, yeni design system veya keskin bir issue ile ileri taşıyan herkese teşekkürler. Her gerçek katkı önemlidir; aşağıdaki wall bunu yüksek sesle söylemenin en kolay yolu.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design contributors" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design contributors" />
|
||||
</a>
|
||||
|
||||
İlk PR'ını gönderdiysen hoş geldin. [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) label'ı giriş noktasıdır.
|
||||
|
|
@ -904,9 +904,9 @@ Yukarıdaki SVG [`.github/workflows/metrics.yml`](.github/workflows/metrics.yml)
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,7 @@ OD не зупиняється на коді. Та сама поверхня ч
|
|||
Дякуємо всім, хто допоміг просувати Open Design — через код, документацію, зворотний зв'язок, нові навички, нові системи дизайну або навіть гостре питання. Кожен реальний внесок рахується, а стіна нижче — найпростіший спосіб сказати це вголос.
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Контриб'ютори Open Design" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Контриб'ютори Open Design" />
|
||||
</a>
|
||||
|
||||
Якщо ви злили свій перший PR — ласкаво просимо. Мітка [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) — це точка входу.
|
||||
|
|
@ -746,9 +746,9 @@ SVG вище перегенерується щодня [`.github/workflows/metri
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Історія зірок Open Design" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -722,7 +722,7 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
|
|||
感谢每一位让 Open Design 变得更好的朋友 —— 无论是写代码、修文档、提 issue、加 skill 还是加 design system,每一次真实贡献都会被记住。下面这面墙是最直观的「Thank you」。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design 贡献者" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design 贡献者" />
|
||||
</a>
|
||||
|
||||
第一次提 PR?欢迎从 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 标签起步。
|
||||
|
|
@ -739,9 +739,9 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。流式分发逻辑在 [
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -1006,7 +1006,7 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
|
|||
感謝每一位讓 Open Design 變得更好的朋友 —— 無論是寫程式碼、修文檔、提 issue、加 skill 還是加 design system,每一次真實貢獻都會被記住。下面這面牆是最直觀的「Thank you」。
|
||||
|
||||
<a href="https://github.com/nexu-io/open-design/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-29" alt="Open Design 貢獻者" />
|
||||
<img src="https://contrib.rocks/image?repo=nexu-io/open-design&cache_bust=2026-05-30" alt="Open Design 貢獻者" />
|
||||
</a>
|
||||
|
||||
第一次提 PR?歡迎從 [`good-first-issue`/`help-wanted`](https://github.com/nexu-io/open-design/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%2C%22help+wanted%22) 標籤起步。
|
||||
|
|
@ -1023,9 +1023,9 @@ Daemon 啟動時從 `PATH` 自動檢測,無需配置。流式分發邏輯在 [
|
|||
|
||||
<a href="https://star-history.com/#nexu-io/open-design&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-29" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&theme=dark&cache_bust=2026-05-30" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
<img alt="Open Design star history" src="https://api.star-history.com/svg?repos=nexu-io/open-design&type=Date&cache_bust=2026-05-30" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ Open Design currently supports **19 languages** across different surfaces:
|
|||
| Bahasa Indonesia | `id` | — | ✅ | — | active |
|
||||
| Italiano | `it` | — | ✅ | — | active |
|
||||
| 日本語 (Japanese) | `ja` | ✅ | ✅ | ✅ | active |
|
||||
| 한국어 (Korean) | `ko` | ✅ | ✅ | — | active |
|
||||
| 한국어 (Korean) | `ko` | ✅ | ✅ | ✅ | active |
|
||||
| Polski (Polish) | `pl` | — | ✅ | — | active |
|
||||
| Português (Brasil) | `pt-BR` | ✅ | ✅ | ✅ | active |
|
||||
| Русский (Russian) | `ru` | ✅ | ✅ | — | active |
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import path from 'node:path';
|
||||
|
||||
import { redactSecrets } from './redact.js';
|
||||
|
||||
export interface ClaudeCliDiagnosticInput {
|
||||
|
|
@ -7,6 +9,7 @@ export interface ClaudeCliDiagnosticInput {
|
|||
stderrTail?: string | null;
|
||||
stdoutTail?: string | null;
|
||||
env?: Record<string, unknown> | null;
|
||||
resolvedBin?: string | null;
|
||||
}
|
||||
|
||||
export interface ClaudeCliDiagnostic {
|
||||
|
|
@ -51,6 +54,15 @@ function withContext(
|
|||
};
|
||||
}
|
||||
|
||||
function selectedClaudeCompatibleRuntime(input: ClaudeCliDiagnosticInput): 'claude' | 'openclaude' {
|
||||
if (typeof input.resolvedBin !== 'string' || !input.resolvedBin.trim()) return 'claude';
|
||||
const base = path
|
||||
.basename(input.resolvedBin.trim().replace(/\\/g, '/'))
|
||||
.replace(/\.(exe|cmd|bat)$/i, '')
|
||||
.toLowerCase();
|
||||
return base === 'openclaude' ? 'openclaude' : 'claude';
|
||||
}
|
||||
|
||||
export function diagnoseClaudeCliFailure(
|
||||
input: ClaudeCliDiagnosticInput,
|
||||
): ClaudeCliDiagnostic | null {
|
||||
|
|
@ -61,6 +73,8 @@ export function diagnoseClaudeCliFailure(
|
|||
const normalized = text.toLowerCase();
|
||||
const hasCustomBaseUrl = envValue(input.env, 'ANTHROPIC_BASE_URL') !== null;
|
||||
const hasConfigDir = envValue(input.env, 'CLAUDE_CONFIG_DIR') !== null;
|
||||
const runtime = selectedClaudeCompatibleRuntime(input);
|
||||
const isOpenClaude = runtime === 'openclaude';
|
||||
|
||||
const customEndpointConnectionFailure =
|
||||
hasCustomBaseUrl &&
|
||||
|
|
@ -90,6 +104,13 @@ export function diagnoseClaudeCliFailure(
|
|||
);
|
||||
}
|
||||
if (authFailure) {
|
||||
if (isOpenClaude) {
|
||||
return withContext(
|
||||
'OpenClaude could not authenticate with its configured endpoint.',
|
||||
'The spawned OpenClaude process exited before producing a response. Check the OpenClaude API key, endpoint, and local configuration, then retry.',
|
||||
input,
|
||||
);
|
||||
}
|
||||
const configHint = hasConfigDir
|
||||
? 'The configured Claude config directory may contain stale or expired auth state.'
|
||||
: 'If you use multiple Claude profiles, set CLAUDE_CONFIG_DIR in Settings so Open Design spawns the same profile that works in your terminal.';
|
||||
|
|
@ -147,6 +168,13 @@ export function diagnoseClaudeCliFailure(
|
|||
}
|
||||
|
||||
if (!text.trim() && input.exitCode === 1) {
|
||||
if (isOpenClaude) {
|
||||
return withContext(
|
||||
'OpenClaude exited before producing diagnostics.',
|
||||
'Check the OpenClaude API key, endpoint, and local configuration, then retry.',
|
||||
input,
|
||||
);
|
||||
}
|
||||
const message = hasConfigDir
|
||||
? 'Claude Code exited before producing diagnostics while using the configured Claude profile.'
|
||||
: 'Claude Code exited before producing diagnostics.';
|
||||
|
|
|
|||
|
|
@ -1862,6 +1862,8 @@ async function testAgentConnectionInternal(
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredAgentEnv,
|
||||
undefined,
|
||||
{ resolvedBin: executableResolution.selectedPath },
|
||||
);
|
||||
const env = applyAgentLaunchEnv(baseEnv, executableResolution);
|
||||
const auth = await probeAgentAuthStatus(input.agentId, executableResolution.launchPath, env);
|
||||
|
|
@ -2026,6 +2028,7 @@ async function testAgentConnectionInternal(
|
|||
stderrTail,
|
||||
stdoutTail: rawStdoutTail || buffered,
|
||||
env,
|
||||
resolvedBin: executableResolution.selectedPath,
|
||||
});
|
||||
if (claudeDiagnostic) {
|
||||
console.warn(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
inlineRelativeAssets,
|
||||
type InlineAssetReader,
|
||||
} from './inline-assets.js';
|
||||
import { isSandboxModeEnabled } from './sandbox-mode.js';
|
||||
|
||||
export interface RegisterImportRoutesDeps extends RouteDeps<'db' | 'http' | 'uploads' | 'node' | 'ids' | 'paths' | 'imports' | 'auth' | 'projectStore' | 'conversations' | 'projectFiles' | 'validation'> {}
|
||||
|
||||
|
|
@ -28,6 +29,11 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
const { insertConversation } = ctx.conversations;
|
||||
const { setTabs } = ctx.projectFiles;
|
||||
const { validateProjectDesignSystemId } = ctx.validation;
|
||||
const rejectSandboxFolderImport = () =>
|
||||
isSandboxModeEnabled(process.env)
|
||||
? 'folder imports are disabled when OD_SANDBOX_MODE is enabled'
|
||||
: null;
|
||||
|
||||
app.post(
|
||||
'/api/import/claude-design',
|
||||
importUpload.single('file'),
|
||||
|
|
@ -107,6 +113,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
if (typeof baseDir !== 'string' || !baseDir.trim()) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
|
||||
}
|
||||
const sandboxReason = rejectSandboxFolderImport();
|
||||
if (sandboxReason) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
|
||||
}
|
||||
let trustedPickerImport = false;
|
||||
if (isDesktopAuthGateActive()) {
|
||||
const secret = desktopAuthSecret();
|
||||
|
|
@ -204,6 +214,10 @@ export function registerImportRoutes(app: Express, ctx: RegisterImportRoutesDeps
|
|||
if (typeof baseDir !== 'string' || !baseDir.trim()) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', 'baseDir required');
|
||||
}
|
||||
const sandboxReason = rejectSandboxFolderImport();
|
||||
if (sandboxReason) {
|
||||
return sendApiError(res, 400, 'BAD_REQUEST', sandboxReason);
|
||||
}
|
||||
let trustedPickerImport = false;
|
||||
if (isDesktopAuthGateActive()) {
|
||||
const secret = desktopAuthSecret();
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import path from 'node:path';
|
|||
import { MEDIA_PROVIDERS } from './media-models.js';
|
||||
import { expandHomePrefix } from './home-expansion.js';
|
||||
import { resolveXAIBearer } from './xai-credentials.js';
|
||||
import { isSandboxModeEnabled } from './sandbox-mode.js';
|
||||
|
||||
const PROVIDER_IDS = MEDIA_PROVIDERS.map((p) => p.id);
|
||||
type ProviderEntry = { apiKey?: string; baseUrl?: string; model?: string };
|
||||
|
|
@ -291,6 +292,7 @@ function apiKeyFromCodexAuth(data: unknown): string {
|
|||
}
|
||||
|
||||
async function resolveOpenAIAuthFileCredential(): Promise<OAuthCredential | null> {
|
||||
if (isSandboxModeEnabled(process.env)) return null;
|
||||
const home = os.homedir();
|
||||
const codexAuth = await readJsonIfPresent(
|
||||
path.join(home, '.codex', 'auth.json'),
|
||||
|
|
@ -318,6 +320,8 @@ async function resolveXAIOAuthCredential(
|
|||
};
|
||||
}
|
||||
|
||||
if (isSandboxModeEnabled(process.env)) return null;
|
||||
|
||||
// 2. Borrow the xAI OAuth token Hermes wrote to ~/.hermes/auth.json
|
||||
// when the user ran `hermes auth add xai-oauth`. A user who has already authorized
|
||||
// Hermes doesn't have to run a second OAuth dance inside OD.
|
||||
|
|
|
|||
|
|
@ -865,7 +865,13 @@ async function callLocalCli(provider, system, user, options) {
|
|||
}
|
||||
|
||||
const env = applyAgentLaunchEnv(
|
||||
spawnEnvForAgent(def.id, { ...process.env, ...(def.env || {}) }, configuredAgentEnv),
|
||||
spawnEnvForAgent(
|
||||
def.id,
|
||||
{ ...process.env, ...(def.env || {}) },
|
||||
configuredAgentEnv,
|
||||
undefined,
|
||||
{ resolvedBin: launch.selectedPath },
|
||||
),
|
||||
launch,
|
||||
);
|
||||
const invocation = createCommandInvocation({
|
||||
|
|
|
|||
23
apps/daemon/src/project-root.ts
Normal file
23
apps/daemon/src/project-root.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import path from 'node:path';
|
||||
|
||||
export function resolveProjectRoot(moduleDir: string): string {
|
||||
const base = path.basename(moduleDir);
|
||||
const daemonDir =
|
||||
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
|
||||
return path.resolve(daemonDir, '../..');
|
||||
}
|
||||
|
||||
export function resolveProjectRootFromNestedModule(moduleDir: string): string {
|
||||
let current = path.resolve(moduleDir);
|
||||
while (true) {
|
||||
const base = path.basename(current);
|
||||
if (base === 'dist' || base === 'src') {
|
||||
return resolveProjectRoot(current);
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return resolveProjectRoot(moduleDir);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Express } from 'express';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
defaultScenarioPluginIdForProjectMetadata,
|
||||
type PluginManifest,
|
||||
|
|
@ -21,6 +22,25 @@ import { auditDesignSystemPackage } from './tools-connectors-cli.js';
|
|||
|
||||
export interface RegisterProjectRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'paths' | 'projectStore' | 'projectFiles' | 'conversations' | 'templates' | 'status' | 'events' | 'ids' | 'telemetry' | 'validation'> {}
|
||||
|
||||
function projectDetailResolvedDir(
|
||||
projectsRoot: string,
|
||||
project: any,
|
||||
resolveProjectDir: (
|
||||
projectsRoot: string,
|
||||
projectId: string,
|
||||
metadata?: unknown,
|
||||
opts?: { allowUnavailableSandboxImportedProject?: boolean },
|
||||
) => string,
|
||||
): string {
|
||||
const baseDir = typeof project?.metadata?.baseDir === 'string'
|
||||
? path.normalize(project.metadata.baseDir)
|
||||
: null;
|
||||
if (baseDir && path.isAbsolute(baseDir)) return baseDir;
|
||||
return resolveProjectDir(projectsRoot, project.id, project.metadata, {
|
||||
allowUnavailableSandboxImportedProject: true,
|
||||
});
|
||||
}
|
||||
|
||||
const URL_PREVIEW_SCROLL_BRIDGE = `<script data-od-url-scroll-bridge>
|
||||
(function(){
|
||||
if (window.__odUrlScrollBridge) return;
|
||||
|
|
@ -419,7 +439,7 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
|
|||
const project = getProject(db, req.params.id);
|
||||
if (!project)
|
||||
return sendApiError(res, 404, 'PROJECT_NOT_FOUND', 'not found');
|
||||
const resolvedDir = resolveProjectDir(PROJECTS_DIR, project.id, project.metadata);
|
||||
const resolvedDir = projectDetailResolvedDir(PROJECTS_DIR, project, resolveProjectDir);
|
||||
/** @type {import('@open-design/contracts').ProjectResponse} */
|
||||
const body = { project, resolvedDir };
|
||||
res.json(body);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
isPublicationGuardedArtifactKind,
|
||||
} from './artifact-publication-guard.js';
|
||||
import { isIgnoredProjectDirName } from './project-ignored-dirs.js';
|
||||
import { isSandboxModeEnabled } from './sandbox-mode.js';
|
||||
|
||||
const FORBIDDEN_SEGMENT = /^$|^\.\.?$/;
|
||||
const RESERVED_PROJECT_FILE_SEGMENTS = new Set(['.live-artifacts']);
|
||||
|
|
@ -40,13 +41,42 @@ export function projectDir(projectsRoot, projectId) {
|
|||
return path.join(projectsRoot, projectId);
|
||||
}
|
||||
|
||||
export class SandboxImportedProjectError extends Error {
|
||||
code = 'SANDBOX_IMPORTED_PROJECT_UNAVAILABLE';
|
||||
|
||||
constructor() {
|
||||
super(
|
||||
'Imported-folder projects are not available in OD_SANDBOX_MODE until their files are mirrored into the managed project directory.',
|
||||
);
|
||||
this.name = 'SandboxImportedProjectError';
|
||||
}
|
||||
}
|
||||
|
||||
function hasExternalProjectRoot(metadata?) {
|
||||
if (typeof metadata?.baseDir !== 'string') return false;
|
||||
return path.isAbsolute(path.normalize(metadata.baseDir));
|
||||
}
|
||||
|
||||
export function assertSandboxProjectRootAvailable(metadata?) {
|
||||
if (isSandboxModeEnabled(process.env) && hasExternalProjectRoot(metadata)) {
|
||||
throw new SandboxImportedProjectError();
|
||||
}
|
||||
}
|
||||
|
||||
function usesExternalProjectRoot(metadata?) {
|
||||
if (isSandboxModeEnabled(process.env)) return false;
|
||||
return hasExternalProjectRoot(metadata);
|
||||
}
|
||||
|
||||
// Returns the folder a project's files live in. For git-linked projects
|
||||
// (metadata.baseDir set), this is the user's own folder. Otherwise falls
|
||||
// back to the standard computed path under projectsRoot.
|
||||
export function resolveProjectDir(projectsRoot, projectId, metadata?) {
|
||||
if (typeof metadata?.baseDir === 'string') {
|
||||
const p = path.normalize(metadata.baseDir);
|
||||
if (path.isAbsolute(p)) return p;
|
||||
export function resolveProjectDir(projectsRoot, projectId, metadata?, opts = {}) {
|
||||
if (!opts.allowUnavailableSandboxImportedProject) {
|
||||
assertSandboxProjectRootAvailable(metadata);
|
||||
}
|
||||
if (usesExternalProjectRoot(metadata)) {
|
||||
return path.normalize(metadata.baseDir);
|
||||
}
|
||||
if (!isSafeId(projectId)) throw new Error('invalid project id');
|
||||
return path.join(projectsRoot, projectId);
|
||||
|
|
@ -55,7 +85,7 @@ export function resolveProjectDir(projectsRoot, projectId, metadata?) {
|
|||
export async function ensureProject(projectsRoot, projectId, metadata?) {
|
||||
const dir = resolveProjectDir(projectsRoot, projectId, metadata);
|
||||
// Git-linked folders already exist; skip mkdir to avoid side-effects.
|
||||
if (typeof metadata?.baseDir !== 'string') {
|
||||
if (!usesExternalProjectRoot(metadata)) {
|
||||
await mkdir(dir, { recursive: true });
|
||||
}
|
||||
return dir;
|
||||
|
|
@ -67,7 +97,7 @@ export async function listFiles(projectsRoot, projectId, opts = {}) {
|
|||
const out = [];
|
||||
// Skip build/install dirs for linked folders so node_modules doesn't stall
|
||||
// the walk on large repos.
|
||||
const skipDirs = metadata?.baseDir ? isIgnoredProjectDirName : undefined;
|
||||
const skipDirs = usesExternalProjectRoot(metadata) ? isIgnoredProjectDirName : undefined;
|
||||
await collectFiles(dir, '', out, skipDirs, dir);
|
||||
// Newest first — matches the visual order users expect after generating.
|
||||
out.sort((a, b) => b.mtime - a.mtime);
|
||||
|
|
|
|||
|
|
@ -151,6 +151,8 @@ async function probe(
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredEnv,
|
||||
undefined,
|
||||
{ resolvedBin: launch.selectedPath },
|
||||
),
|
||||
launch,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { mergeProxyAwareEnv, resolveSystemProxyEnv } from '@open-design/platform';
|
||||
import { resolveProjectRelativePath } from '../home-expansion.js';
|
||||
import { expandConfiguredEnv } from './paths.js';
|
||||
import { resolveAmrOpenCodeExecutable } from './executables.js';
|
||||
import { amrVelaProfileEnv } from '../integrations/vela-profile.js';
|
||||
import { resolveProjectRootFromNestedModule } from '../project-root.js';
|
||||
import {
|
||||
applySandboxRuntimeEnv,
|
||||
isSandboxModeEnabled,
|
||||
resolveSandboxRuntimeConfig,
|
||||
type SandboxRuntimeConfig,
|
||||
} from '../sandbox-mode.js';
|
||||
|
||||
type RuntimeEnvMap = NodeJS.ProcessEnv | Record<string, string>;
|
||||
type SpawnEnvOptions = {
|
||||
resolvedBin?: string | null;
|
||||
};
|
||||
|
||||
const RUNTIME_MODULE_PROJECT_ROOT = resolveProjectRootFromNestedModule(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
);
|
||||
|
||||
// Build the env passed to spawn() for a given agent adapter.
|
||||
//
|
||||
|
|
@ -38,7 +54,9 @@ export function spawnEnvForAgent(
|
|||
baseEnv: RuntimeEnvMap,
|
||||
configuredEnv: unknown = {},
|
||||
systemProxyEnv: RuntimeEnvMap = resolveSystemProxyEnv(),
|
||||
options: SpawnEnvOptions = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
const sandboxRuntime = sandboxRuntimeConfigForBaseEnv(baseEnv);
|
||||
const env = mergeProxyAwareEnv(
|
||||
process.platform,
|
||||
systemProxyEnv,
|
||||
|
|
@ -58,20 +76,52 @@ export function spawnEnvForAgent(
|
|||
const opencodeBin = resolveAmrOpenCodeExecutable(env);
|
||||
if (opencodeBin) env.VELA_OPENCODE_BIN = opencodeBin;
|
||||
}
|
||||
return env;
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
if (agentId === 'claude') {
|
||||
if (!isOpenClaudeExecutable(options.resolvedBin)) {
|
||||
stripUnlessCustomBaseUrl(env, 'ANTHROPIC_BASE_URL', ['ANTHROPIC_API_KEY']);
|
||||
return env;
|
||||
}
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
if (agentId === 'codex') {
|
||||
stripUnlessCustomBaseUrl(env, 'OPENAI_BASE_URL', [
|
||||
'OPENAI_API_KEY',
|
||||
'CODEX_API_KEY',
|
||||
]);
|
||||
return env;
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
return env;
|
||||
return reapplySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
|
||||
function isOpenClaudeExecutable(resolvedBin: string | null | undefined): boolean {
|
||||
if (typeof resolvedBin !== 'string' || !resolvedBin.trim()) return false;
|
||||
const base = path
|
||||
.basename(resolvedBin.trim().replace(/\\/g, '/'))
|
||||
.replace(/\.(exe|cmd|bat)$/i, '')
|
||||
.toLowerCase();
|
||||
return base === 'openclaude';
|
||||
}
|
||||
|
||||
function sandboxRuntimeConfigForBaseEnv(
|
||||
baseEnv: RuntimeEnvMap,
|
||||
): SandboxRuntimeConfig | null {
|
||||
if (!isSandboxModeEnabled(baseEnv)) return null;
|
||||
const dataDir = baseEnv.OD_DATA_DIR?.trim();
|
||||
if (!dataDir) return null;
|
||||
const resolvedDataDir = resolveProjectRelativePath(
|
||||
dataDir,
|
||||
RUNTIME_MODULE_PROJECT_ROOT,
|
||||
);
|
||||
return resolveSandboxRuntimeConfig(true, resolvedDataDir);
|
||||
}
|
||||
|
||||
function reapplySandboxRuntimeEnv(
|
||||
env: NodeJS.ProcessEnv,
|
||||
sandboxRuntime: SandboxRuntimeConfig | null,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!sandboxRuntime) return env;
|
||||
return applySandboxRuntimeEnv(env, sandboxRuntime);
|
||||
}
|
||||
|
||||
// Remove `secretKeys` from `env` unless `baseUrlKey` is set to a non-empty
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ import { accessSync, constants, existsSync, statSync } from 'node:fs';
|
|||
import { delimiter } from 'node:path';
|
||||
import path from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { wellKnownUserToolchainBins } from '@open-design/platform';
|
||||
import { resolveSandboxRuntimeConfigFromEnv } from '../sandbox-mode.js';
|
||||
import { expandHomePath } from './paths.js';
|
||||
import type { RuntimeAgentDef } from './types.js';
|
||||
|
||||
const RUNTIME_PROJECT_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../../..',
|
||||
);
|
||||
|
||||
const AGENT_BIN_ENV_KEYS = new Map<string, string>([
|
||||
['amr', 'VELA_BIN'],
|
||||
['aider', 'AIDER_BIN'],
|
||||
|
|
@ -35,7 +42,12 @@ let cachedToolchainDirs: string[] | null = null;
|
|||
let cachedToolchainDirsAt = 0;
|
||||
|
||||
function userToolchainDirs() {
|
||||
const homeOverride = process.env.OD_AGENT_HOME;
|
||||
const sandboxRuntime = resolveSandboxRuntimeConfigFromEnv(
|
||||
process.env,
|
||||
RUNTIME_PROJECT_ROOT,
|
||||
);
|
||||
const homeOverride =
|
||||
sandboxRuntime?.roots.agentHomeDir ?? process.env.OD_AGENT_HOME;
|
||||
const home = homeOverride || homedir();
|
||||
const now = Date.now();
|
||||
if (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
isSandboxModeEnabled,
|
||||
resolveSandboxRuntimeConfigFromEnv,
|
||||
sandboxAgentProfilesConfigPath,
|
||||
} from '../sandbox-mode.js';
|
||||
import { DEFAULT_MODEL_OPTION, sanitizeCustomModel } from './models.js';
|
||||
import type {
|
||||
RuntimeAgentDef,
|
||||
|
|
@ -9,10 +15,44 @@ import type {
|
|||
RuntimeModelOption,
|
||||
} from './types.js';
|
||||
|
||||
function localAgentProfilesFile(): string {
|
||||
const RUNTIME_PROJECT_ROOT = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../../../..',
|
||||
);
|
||||
|
||||
function isInsideDir(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return (
|
||||
relative === '' ||
|
||||
(!relative.startsWith('..') && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
function localAgentProfilesFile(): string | null {
|
||||
const explicit = process.env.OD_AGENT_PROFILES_CONFIG;
|
||||
if (typeof explicit === 'string' && explicit.trim()) {
|
||||
return explicit.trim();
|
||||
const explicitPath =
|
||||
typeof explicit === 'string' && explicit.trim()
|
||||
? path.resolve(explicit.trim())
|
||||
: null;
|
||||
|
||||
if (isSandboxModeEnabled(process.env)) {
|
||||
if (!process.env.OD_DATA_DIR?.trim()) return null;
|
||||
const sandboxRuntime = resolveSandboxRuntimeConfigFromEnv(
|
||||
process.env,
|
||||
RUNTIME_PROJECT_ROOT,
|
||||
);
|
||||
if (!sandboxRuntime?.enabled) return null;
|
||||
if (
|
||||
explicitPath &&
|
||||
isInsideDir(sandboxRuntime.roots.agentHomeDir, explicitPath)
|
||||
) {
|
||||
return explicitPath;
|
||||
}
|
||||
return sandboxAgentProfilesConfigPath(sandboxRuntime);
|
||||
}
|
||||
|
||||
if (explicitPath) {
|
||||
return explicitPath;
|
||||
}
|
||||
return path.join(homedir(), '.open-design', 'agents.local.json');
|
||||
}
|
||||
|
|
@ -152,9 +192,11 @@ function createLocalAgentDef(
|
|||
export function readLocalAgentProfileDefs(
|
||||
baseDefs: RuntimeAgentDef[],
|
||||
): RuntimeAgentDef[] {
|
||||
const profilesFile = localAgentProfilesFile();
|
||||
if (profilesFile == null) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(localAgentProfilesFile(), 'utf8'));
|
||||
parsed = JSON.parse(readFileSync(profilesFile, 'utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
134
apps/daemon/src/sandbox-mode.ts
Normal file
134
apps/daemon/src/sandbox-mode.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { resolveProjectRelativePath } from './home-expansion.js';
|
||||
|
||||
export const SANDBOX_MODE_ENV = 'OD_SANDBOX_MODE';
|
||||
|
||||
export interface SandboxRuntimeRoots {
|
||||
agentHomeDir: string;
|
||||
cacheDir: string;
|
||||
configDir: string;
|
||||
generatedFilesDir: string;
|
||||
logsDir: string;
|
||||
mcpConfigDir: string;
|
||||
pluginStateDir: string;
|
||||
previewStateDir: string;
|
||||
skillsCacheDir: string;
|
||||
tempDir: string;
|
||||
toolConfigDir: string;
|
||||
}
|
||||
|
||||
export interface SandboxRuntimeConfig {
|
||||
enabled: boolean;
|
||||
dataDir: string;
|
||||
roots: SandboxRuntimeRoots;
|
||||
}
|
||||
|
||||
const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']);
|
||||
const FALSY_VALUES = new Set(['0', 'false', 'no', 'off', '']);
|
||||
|
||||
export function isSandboxModeEnabled(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
): boolean {
|
||||
const raw = env[SANDBOX_MODE_ENV];
|
||||
if (typeof raw !== 'string') return false;
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (TRUTHY_VALUES.has(value)) return true;
|
||||
if (FALSY_VALUES.has(value)) return false;
|
||||
throw new Error(
|
||||
`${SANDBOX_MODE_ENV} must be one of ${Array.from(TRUTHY_VALUES).join(', ')} ` +
|
||||
`or ${Array.from(FALSY_VALUES).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveSandboxRuntimeConfig(
|
||||
enabled: boolean,
|
||||
dataDir: string,
|
||||
): SandboxRuntimeConfig {
|
||||
const sandboxRoot = path.join(dataDir, 'sandbox');
|
||||
return {
|
||||
enabled,
|
||||
dataDir,
|
||||
roots: {
|
||||
agentHomeDir: path.join(sandboxRoot, 'agent-home'),
|
||||
cacheDir: path.join(sandboxRoot, 'cache'),
|
||||
configDir: path.join(sandboxRoot, 'config'),
|
||||
generatedFilesDir: path.join(dataDir, 'generated-files'),
|
||||
logsDir: path.join(dataDir, 'logs'),
|
||||
mcpConfigDir: dataDir,
|
||||
pluginStateDir: path.join(dataDir, 'plugins'),
|
||||
previewStateDir: path.join(dataDir, 'previews'),
|
||||
skillsCacheDir: path.join(dataDir, 'skills'),
|
||||
tempDir: path.join(sandboxRoot, 'tmp'),
|
||||
toolConfigDir: path.join(sandboxRoot, 'tools'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSandboxRuntimeConfigFromEnv(
|
||||
env: Record<string, string | undefined>,
|
||||
projectRoot: string,
|
||||
): SandboxRuntimeConfig | null {
|
||||
if (!isSandboxModeEnabled(env)) return null;
|
||||
const rawDataDir = env.OD_DATA_DIR?.trim();
|
||||
if (!rawDataDir) {
|
||||
throw new Error('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
|
||||
}
|
||||
return resolveSandboxRuntimeConfig(
|
||||
true,
|
||||
resolveProjectRelativePath(rawDataDir, projectRoot),
|
||||
);
|
||||
}
|
||||
|
||||
export function sandboxAgentProfilesConfigPath(
|
||||
config: SandboxRuntimeConfig,
|
||||
): string {
|
||||
return path.join(
|
||||
config.roots.agentHomeDir,
|
||||
'.open-design',
|
||||
'agents.local.json',
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureSandboxRuntimeDirs(config: SandboxRuntimeConfig): void {
|
||||
if (!config.enabled) return;
|
||||
for (const dir of new Set(Object.values(config.roots))) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function applySandboxRuntimeEnv(
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
config: SandboxRuntimeConfig,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!config.enabled) return baseEnv;
|
||||
|
||||
const env: NodeJS.ProcessEnv = { ...baseEnv };
|
||||
const { roots } = config;
|
||||
const codexHome = path.join(roots.agentHomeDir, '.codex');
|
||||
const claudeConfigDir = path.join(roots.configDir, 'claude');
|
||||
const opencodeHome = path.join(roots.agentHomeDir, '.opencode');
|
||||
const npmUserConfig = path.join(roots.toolConfigDir, 'npmrc');
|
||||
|
||||
env[SANDBOX_MODE_ENV] = '1';
|
||||
env.OD_DATA_DIR = config.dataDir;
|
||||
env.OD_AGENT_HOME = roots.agentHomeDir;
|
||||
env.HOME = roots.agentHomeDir;
|
||||
env.USERPROFILE = roots.agentHomeDir;
|
||||
env.XDG_CONFIG_HOME = roots.configDir;
|
||||
env.XDG_CACHE_HOME = roots.cacheDir;
|
||||
env.XDG_DATA_HOME = path.join(roots.configDir, 'data');
|
||||
env.XDG_STATE_HOME = path.join(roots.configDir, 'state');
|
||||
env.TMPDIR = roots.tempDir;
|
||||
env.TEMP = roots.tempDir;
|
||||
env.TMP = roots.tempDir;
|
||||
env.CODEX_HOME = codexHome;
|
||||
env.CLAUDE_CONFIG_DIR = claudeConfigDir;
|
||||
env.OPENCODE_TEST_HOME = opencodeHome;
|
||||
env.OD_AGENT_PROFILES_CONFIG = sandboxAgentProfilesConfigPath(config);
|
||||
env.NPM_CONFIG_USERCONFIG = npmUserConfig;
|
||||
env.npm_config_userconfig = npmUserConfig;
|
||||
|
||||
return env;
|
||||
}
|
||||
|
|
@ -25,7 +25,10 @@ import {
|
|||
shouldRenderCodexImagegenOverride,
|
||||
} from './prompts/system.js';
|
||||
import { expandHomePrefix, resolveProjectRelativePath } from './home-expansion.js';
|
||||
import { resolveProjectRoot } from './project-root.js';
|
||||
import { userFacingAgentLabel } from './user-facing-agent-label.js';
|
||||
|
||||
export { resolveProjectRoot };
|
||||
import { createCommandInvocation } from '@open-design/platform';
|
||||
import { SIDECAR_DEFAULTS, SIDECAR_ENV } from '@open-design/sidecar-proto';
|
||||
import {
|
||||
|
|
@ -90,6 +93,12 @@ import { buildWindowsFolderDialogCommand, parseFolderDialogStdout } from './nati
|
|||
import { listCodexPets, readCodexPetSpritesheet } from './codex-pets.js';
|
||||
import { syncCommunityPets } from './community-pets-sync.js';
|
||||
import { parseMediaExecutionPolicyInput } from './media-policy.js';
|
||||
import {
|
||||
applySandboxRuntimeEnv,
|
||||
ensureSandboxRuntimeDirs,
|
||||
isSandboxModeEnabled,
|
||||
resolveSandboxRuntimeConfig,
|
||||
} from './sandbox-mode.js';
|
||||
import {
|
||||
createUserDesignSystem,
|
||||
deleteUserDesignSystem,
|
||||
|
|
@ -252,6 +261,7 @@ import {
|
|||
type ObservabilityEventRequest,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import {
|
||||
mergeNoProxyWithLoopbackDefaults,
|
||||
redactSecrets,
|
||||
testAgentConnection,
|
||||
testProviderConnection,
|
||||
|
|
@ -335,6 +345,7 @@ import {
|
|||
buildBatchArchive,
|
||||
decodeMultipartFilename,
|
||||
deleteProjectFile,
|
||||
assertSandboxProjectRootAvailable,
|
||||
detectEntryFile,
|
||||
ensureProject,
|
||||
isSafeId,
|
||||
|
|
@ -346,6 +357,7 @@ import {
|
|||
renameProjectFile,
|
||||
removeProjectDir,
|
||||
resolveProjectDir,
|
||||
SandboxImportedProjectError,
|
||||
sanitizeName,
|
||||
searchProjectFiles,
|
||||
resolveProjectDir,
|
||||
|
|
@ -476,13 +488,6 @@ const __filename = fileURLToPath(import.meta.url);
|
|||
const __dirname = path.dirname(__filename);
|
||||
const require = createRequire(import.meta.url);
|
||||
const DAEMON_CLI_PATH_ENV = 'OD_DAEMON_CLI_PATH';
|
||||
export function resolveProjectRoot(moduleDir: string): string {
|
||||
const base = path.basename(moduleDir);
|
||||
const daemonDir =
|
||||
base === 'dist' || base === 'src' ? path.dirname(moduleDir) : moduleDir;
|
||||
return path.resolve(daemonDir, '../..');
|
||||
}
|
||||
|
||||
function cleanOptionalPath(value: string | undefined): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
? path.resolve(value)
|
||||
|
|
@ -1328,8 +1333,14 @@ function createMarketplaceFetcher(seedId, bundledMarketplaceEntries) {
|
|||
};
|
||||
}
|
||||
|
||||
export function resolveDataDir(raw, projectRoot) {
|
||||
if (!raw) return path.join(projectRoot, '.od');
|
||||
export function resolveDataDir(raw, projectRoot, options = {}) {
|
||||
const value = raw?.trim();
|
||||
if (!value) {
|
||||
if (options.requireExplicit) {
|
||||
throw new Error('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
|
||||
}
|
||||
return path.join(projectRoot, '.od');
|
||||
}
|
||||
// expandHomePrefix is shared with media-config.ts so OD_DATA_DIR and
|
||||
// OD_MEDIA_CONFIG_DIR can never split state under a $HOME-style value.
|
||||
// Some launchers (systemd unit files, NixOS modules, certain Docker
|
||||
|
|
@ -1338,7 +1349,7 @@ export function resolveDataDir(raw, projectRoot) {
|
|||
// expandHomePrefix turns those (and the ~ shorthand, with both / and \
|
||||
// separators) into os.homedir() before path.resolve runs so launch
|
||||
// surfaces stay consistent.
|
||||
const resolved = resolveProjectRelativePath(raw, projectRoot);
|
||||
const resolved = resolveProjectRelativePath(value, projectRoot);
|
||||
try {
|
||||
fs.mkdirSync(resolved, { recursive: true });
|
||||
fs.accessSync(resolved, fs.constants.W_OK);
|
||||
|
|
@ -1364,7 +1375,12 @@ export function resolveDataDir(raw, projectRoot) {
|
|||
}
|
||||
return resolved;
|
||||
}
|
||||
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT);
|
||||
const SANDBOX_MODE_ENABLED = isSandboxModeEnabled(process.env);
|
||||
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT, {
|
||||
requireExplicit: SANDBOX_MODE_ENABLED,
|
||||
});
|
||||
const SANDBOX_RUNTIME = resolveSandboxRuntimeConfig(SANDBOX_MODE_ENABLED, RUNTIME_DATA_DIR);
|
||||
ensureSandboxRuntimeDirs(SANDBOX_RUNTIME);
|
||||
const PLUGIN_LOCKFILE_PATH = path.join(RUNTIME_DATA_DIR, 'od-plugin-lock.json');
|
||||
// Canonical (realpath-resolved) form of RUNTIME_DATA_DIR for the few callers
|
||||
// that compare it against a user-supplied realpath() result. On macOS, /var
|
||||
|
|
@ -1623,16 +1639,26 @@ export function createAgentRuntimeEnv(
|
|||
toolTokenGrant: { token?: string } | null = null,
|
||||
nodeBin: string = process.execPath,
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
const env: NodeJS.ProcessEnv = applySandboxRuntimeEnv(
|
||||
{
|
||||
...baseEnv,
|
||||
OD_DATA_DIR: RUNTIME_DATA_DIR,
|
||||
OD_DAEMON_URL: daemonUrl,
|
||||
OD_NODE_BIN: nodeBin,
|
||||
};
|
||||
},
|
||||
SANDBOX_RUNTIME,
|
||||
);
|
||||
const sidecarIpcPath = baseEnv[SIDECAR_ENV.IPC_PATH];
|
||||
if (typeof sidecarIpcPath === 'string' && sidecarIpcPath.length > 0) {
|
||||
env[SIDECAR_ENV.IPC_PATH] = sidecarIpcPath;
|
||||
}
|
||||
if (SANDBOX_RUNTIME.enabled) {
|
||||
const noProxy = mergeNoProxyWithLoopbackDefaults(env.NO_PROXY ?? env.no_proxy);
|
||||
if (noProxy) {
|
||||
env.NO_PROXY = noProxy;
|
||||
if (process.platform !== 'win32') env.no_proxy = noProxy;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the node binary directory is on PATH so agent sub-processes —
|
||||
// in particular npm .cmd shims on Windows that run `"node" script.js` —
|
||||
|
|
@ -3842,10 +3868,18 @@ export async function startServer({
|
|||
// Active only when OD_API_TOKEN is set. Loopback origins skip the
|
||||
// check (the desktop UI / local CLI never carry a bearer); every
|
||||
// other request must present `Authorization: Bearer <token>` with a
|
||||
// value matching `OD_API_TOKEN`. Health / version / status remain
|
||||
// open so monitoring probes don't need the token.
|
||||
// value matching `OD_API_TOKEN`. Health / readiness / version remain
|
||||
// open so monitoring probes don't need the token. Rich daemon status
|
||||
// stays authenticated because it includes local runtime paths.
|
||||
if (apiToken.length > 0) {
|
||||
const openProbePaths = new Set(['/api/health', '/api/version', '/api/daemon/status']);
|
||||
const openProbePaths = new Set([
|
||||
'/health',
|
||||
'/api/health',
|
||||
'/ready',
|
||||
'/api/ready',
|
||||
'/version',
|
||||
'/api/version',
|
||||
]);
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (openProbePaths.has(req.path)) return next();
|
||||
// Loopback short-circuit. We ignore the proxied X-Forwarded-For
|
||||
|
|
@ -4356,6 +4390,16 @@ export async function startServer({
|
|||
res.json({ ok: true, version: versionInfo.version });
|
||||
});
|
||||
|
||||
app.get('/api/ready', async (_req, res) => {
|
||||
const versionInfo = await readCurrentAppVersionInfo();
|
||||
const ready = !daemonShuttingDown;
|
||||
res.status(ready ? 200 : 503).json({
|
||||
ok: ready,
|
||||
ready,
|
||||
version: versionInfo.version,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/version', async (_req, res) => {
|
||||
const version = await readCurrentAppVersionInfo();
|
||||
res.json({ version });
|
||||
|
|
@ -4409,6 +4453,10 @@ export async function startServer({
|
|||
port: Number(process.env.OD_PORT ?? 7456),
|
||||
dataDir: RUNTIME_DATA_DIR,
|
||||
mediaConfigDir: process.env.OD_MEDIA_CONFIG_DIR ?? null,
|
||||
sandboxMode: SANDBOX_RUNTIME.enabled,
|
||||
sandbox: SANDBOX_RUNTIME.enabled
|
||||
? { enabled: true, roots: SANDBOX_RUNTIME.roots }
|
||||
: { enabled: false },
|
||||
pid: process.pid,
|
||||
shuttingDown: daemonShuttingDown,
|
||||
installedPlugins: (() => {
|
||||
|
|
@ -10735,14 +10783,13 @@ export async function startServer({
|
|||
try {
|
||||
const chatProject = getProject(db, projectId);
|
||||
const chatMeta = chatProject?.metadata;
|
||||
if (chatMeta?.baseDir) {
|
||||
cwd = path.normalize(chatMeta.baseDir);
|
||||
assertSandboxProjectRootAvailable(chatMeta);
|
||||
cwd = await ensureProject(PROJECTS_DIR, projectId, chatMeta);
|
||||
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId, { metadata: chatMeta });
|
||||
} else {
|
||||
cwd = await ensureProject(PROJECTS_DIR, projectId);
|
||||
existingProjectFiles = await listFiles(PROJECTS_DIR, projectId);
|
||||
} catch (err) {
|
||||
if (err instanceof SandboxImportedProjectError) {
|
||||
return design.runs.fail(run, 'BAD_REQUEST', err.message);
|
||||
}
|
||||
} catch {
|
||||
cwd = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -11268,6 +11315,8 @@ export async function startServer({
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredAgentEnv,
|
||||
undefined,
|
||||
{ resolvedBin: agentLaunch.selectedPath },
|
||||
),
|
||||
agentLaunch,
|
||||
)
|
||||
|
|
@ -11650,6 +11699,8 @@ export async function startServer({
|
|||
...(def.env || {}),
|
||||
},
|
||||
configuredAgentEnv,
|
||||
undefined,
|
||||
{ resolvedBin: agentLaunch.selectedPath },
|
||||
);
|
||||
if (def.id === 'amr') {
|
||||
const loginStatus = readVelaLoginStatus(agentSpawnEnv, configuredAgentEnv);
|
||||
|
|
@ -12603,6 +12654,7 @@ export async function startServer({
|
|||
stderrTail: agentStderrTail,
|
||||
stdoutTail: agentStdoutTail,
|
||||
env: spawnedAgentEnv,
|
||||
resolvedBin: agentLaunch.selectedPath,
|
||||
});
|
||||
// A non-zero exit whose output reads as an auth / quota / upstream
|
||||
// problem (typical of Claude Code, codex, …) gets the specific code
|
||||
|
|
@ -13051,7 +13103,18 @@ export async function startServer({
|
|||
) {
|
||||
try {
|
||||
const convs = listConversations(db, meta.projectId);
|
||||
const defaultConv = Array.isArray(convs) && convs.length > 0 ? convs[0] : null;
|
||||
// listConversations is ordered for the UI by recent activity; this
|
||||
// fallback must bind to the seeded default conversation instead.
|
||||
const defaultConv = Array.isArray(convs) && convs.length > 0
|
||||
? [...convs].sort((a, b) => {
|
||||
const aCreated = Number(a?.createdAt);
|
||||
const bCreated = Number(b?.createdAt);
|
||||
if (Number.isFinite(aCreated) && Number.isFinite(bCreated) && aCreated !== bCreated) {
|
||||
return aCreated - bCreated;
|
||||
}
|
||||
return String(a?.id ?? '').localeCompare(String(b?.id ?? ''));
|
||||
})[0]
|
||||
: null;
|
||||
if (defaultConv && typeof defaultConv.id === 'string' && defaultConv.id) {
|
||||
meta.conversationId = defaultConv.id;
|
||||
if (typeof meta.assistantMessageId !== 'string' || !meta.assistantMessageId) {
|
||||
|
|
@ -13085,10 +13148,13 @@ export async function startServer({
|
|||
const cfgAgent = typeof appCfg.agentId === 'string' && appCfg.agentId
|
||||
? appCfg.agentId
|
||||
: null;
|
||||
if (cfgAgent) {
|
||||
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
||||
const cfgAgentAvailable = cfgAgent
|
||||
? agents.some((agent) => agent.id === cfgAgent && agent.available)
|
||||
: false;
|
||||
if (cfgAgent && cfgAgentAvailable) {
|
||||
meta.agentId = cfgAgent;
|
||||
} else {
|
||||
const agents = await detectAgents(appCfg.agentCliEnv ?? {}).catch(() => []);
|
||||
const firstAvailable = agents.find((a) => a.available)?.id ?? null;
|
||||
if (firstAvailable) meta.agentId = firstAvailable;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,19 @@ describe('agent runtime tool environment', () => {
|
|||
expect(env.OD_DATA_DIR).toBe(process.env.OD_DATA_DIR);
|
||||
});
|
||||
|
||||
it('keeps non-sandbox NO_PROXY behavior unchanged', () => {
|
||||
const env = createAgentRuntimeEnv(
|
||||
{ PATH: '/bin', HTTP_PROXY: 'http://127.0.0.1:9', NO_PROXY: '' },
|
||||
'http://127.0.0.1:7456',
|
||||
{ token: 'fresh-token' },
|
||||
'/opt/open-design/bin/node',
|
||||
);
|
||||
|
||||
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:9');
|
||||
expect(env.NO_PROXY).toBe('');
|
||||
expect(env.no_proxy).toBeUndefined();
|
||||
});
|
||||
|
||||
it('passes the daemon sidecar IPC path from the explicit base env into agent wrapper sessions', () => {
|
||||
const env = createAgentRuntimeEnv(
|
||||
{ PATH: '/bin', [SIDECAR_ENV.IPC_PATH]: '/tmp/open-design/ipc/daemon.sock' },
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// OD_API_TOKEN is set.
|
||||
// 2. When OD_API_TOKEN is set, every /api/* request from a non-loopback
|
||||
// peer must carry `Authorization: Bearer <OD_API_TOKEN>`. The
|
||||
// health/version/status probes stay open for monitoring.
|
||||
// health/readiness/version probes stay open for monitoring.
|
||||
//
|
||||
// Tests force the bearer-required code path by stamping the env vars
|
||||
// before startServer. The daemon listens on 127.0.0.1 throughout (so
|
||||
|
|
@ -77,8 +77,8 @@ describe('bearer middleware', () => {
|
|||
expect(resp.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps health / version / daemon-status open without a bearer', async () => {
|
||||
for (const path of ['/api/health', '/api/version', '/api/daemon/status']) {
|
||||
it('keeps health / readiness / version probes open without a bearer', async () => {
|
||||
for (const path of ['/api/health', '/api/ready', '/api/version']) {
|
||||
const resp = await fetch(`${baseUrl}${path}`);
|
||||
expect(resp.status).toBe(200);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,42 @@ async function withFakeAgent<T>(
|
|||
}
|
||||
}
|
||||
|
||||
async function withOnlyFakeAgent<T>(
|
||||
binName: string,
|
||||
script: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'od-conn-test-bin-'));
|
||||
const oldPath = process.env.PATH;
|
||||
const oldAgentHome = process.env.OD_AGENT_HOME;
|
||||
const oldClaudeBin = process.env.CLAUDE_BIN;
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
const runner = path.join(dir, `${binName}-test-runner.cjs`);
|
||||
await fsp.writeFile(runner, script);
|
||||
await fsp.writeFile(
|
||||
path.join(dir, `${binName}.cmd`),
|
||||
`@echo off\r\nnode "${runner}" %*\r\n`,
|
||||
);
|
||||
} else {
|
||||
const bin = path.join(dir, binName);
|
||||
await fsp.writeFile(bin, `#!/usr/bin/env node\n${script}`);
|
||||
await fsp.chmod(bin, 0o755);
|
||||
}
|
||||
process.env.PATH = dir;
|
||||
process.env.OD_AGENT_HOME = dir;
|
||||
delete process.env.CLAUDE_BIN;
|
||||
return await run();
|
||||
} finally {
|
||||
process.env.PATH = oldPath;
|
||||
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
|
||||
else process.env.OD_AGENT_HOME = oldAgentHome;
|
||||
if (oldClaudeBin === undefined) delete process.env.CLAUDE_BIN;
|
||||
else process.env.CLAUDE_BIN = oldClaudeBin;
|
||||
await fsp.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function withFakeCodex<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||
return withFakeAgent('codex', script, run);
|
||||
}
|
||||
|
|
@ -94,6 +130,10 @@ async function withFakeClaude<T>(script: string, run: () => Promise<T>): Promise
|
|||
return withFakeAgent('claude', script, run);
|
||||
}
|
||||
|
||||
async function withOnlyFakeOpenClaude<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||
return withOnlyFakeAgent('openclaude', script, run);
|
||||
}
|
||||
|
||||
async function withFakeOpenCode<T>(script: string, run: () => Promise<T>): Promise<T> {
|
||||
return withFakeAgent('opencode', script, run);
|
||||
}
|
||||
|
|
@ -2199,6 +2239,58 @@ process.stdin.on('end', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('preserves ANTHROPIC_API_KEY when Claude adapter launches the OpenClaude fallback', async () => {
|
||||
const envFile = path.join(os.tmpdir(), `od-openclaude-env-${Date.now()}-${Math.random()}.json`);
|
||||
const previousKey = process.env.ANTHROPIC_API_KEY;
|
||||
try {
|
||||
process.env.ANTHROPIC_API_KEY = 'sk-openclaude-test';
|
||||
await withOnlyFakeOpenClaude(
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
fs.writeFileSync(${JSON.stringify(envFile)}, JSON.stringify({
|
||||
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY || null,
|
||||
}));
|
||||
let input = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => { input += chunk; });
|
||||
process.stdin.on('end', () => {
|
||||
try {
|
||||
JSON.parse(input.trim());
|
||||
console.log(JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
id: 'msg_1',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
stop_reason: 'end_turn',
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
`,
|
||||
async () => {
|
||||
const result = await testAgentConnection({ agentId: 'claude' });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
kind: 'success',
|
||||
agentName: 'Claude Code',
|
||||
});
|
||||
await expect(fsp.readFile(envFile, 'utf8')).resolves.toBe(
|
||||
JSON.stringify({ ANTHROPIC_API_KEY: 'sk-openclaude-test' }),
|
||||
);
|
||||
expect(result.diagnostics?.binaryPath ?? '').toMatch(/openclaude/i);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
if (previousKey === undefined) delete process.env.ANTHROPIC_API_KEY;
|
||||
else process.env.ANTHROPIC_API_KEY = previousKey;
|
||||
await fsp.rm(envFile, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns Claude /login guidance when the spawned CLI cannot authenticate', async () => {
|
||||
await withFakeClaude(
|
||||
`console.error(JSON.stringify({ apiKeySource: 'none', error_status: 401 })); process.exit(1);`,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ describe('GET /api/daemon/status', () => {
|
|||
version: unknown;
|
||||
bindHost: unknown;
|
||||
port: unknown;
|
||||
sandboxMode: boolean;
|
||||
sandbox: { enabled: boolean };
|
||||
pid: unknown;
|
||||
installedPlugins: unknown;
|
||||
shuttingDown: boolean;
|
||||
|
|
@ -49,11 +51,32 @@ describe('GET /api/daemon/status', () => {
|
|||
expect(typeof body.port).toBe('number');
|
||||
expect(typeof body.pid).toBe('number');
|
||||
expect(typeof body.installedPlugins).toBe('number');
|
||||
expect(body.sandboxMode).toBe(false);
|
||||
expect(body.sandbox).toEqual({ enabled: false });
|
||||
expect(body.shuttingDown).toBe(false);
|
||||
expect(body).not.toHaveProperty('namespace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/ready', () => {
|
||||
it('returns a readiness snapshot for headless launchers', async () => {
|
||||
const resp = await fetch(`${baseUrl}/api/ready`);
|
||||
expect(resp.status).toBe(200);
|
||||
const body = (await resp.json()) as {
|
||||
ok: boolean;
|
||||
ready: boolean;
|
||||
version: unknown;
|
||||
};
|
||||
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.ready).toBe(true);
|
||||
expect(typeof body.version === 'string' || typeof body.version === 'object').toBe(true);
|
||||
expect(body).not.toHaveProperty('dataDir');
|
||||
expect(body).not.toHaveProperty('sandboxMode');
|
||||
expect(body).not.toHaveProperty('sandbox');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/daemon/shutdown', () => {
|
||||
it('only accepts requests from local-daemon-allowed origins', async () => {
|
||||
// Without the local-daemon header, the route is rejected. The
|
||||
|
|
|
|||
|
|
@ -4,7 +4,24 @@ import { tmpdir } from 'node:os';
|
|||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectEntryFile, listFiles, resolveProjectDir } from '../src/projects.js';
|
||||
import {
|
||||
assertSandboxProjectRootAvailable,
|
||||
detectEntryFile,
|
||||
listFiles,
|
||||
resolveProjectDir,
|
||||
SandboxImportedProjectError,
|
||||
} from '../src/projects.js';
|
||||
|
||||
function withSandboxMode<T>(run: () => T): T {
|
||||
const previous = process.env.OD_SANDBOX_MODE;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||
else process.env.OD_SANDBOX_MODE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveProjectDir', () => {
|
||||
const projectsRoot = '/var/od/projects';
|
||||
|
|
@ -50,6 +67,22 @@ describe('resolveProjectDir', () => {
|
|||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects metadata.baseDir in sandbox mode before resolving a project file root', () => {
|
||||
withSandboxMode(() => {
|
||||
const baseDir = '/Users/me/projects/site';
|
||||
expect(
|
||||
() => resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }),
|
||||
).toThrowError(SandboxImportedProjectError);
|
||||
expect(() =>
|
||||
assertSandboxProjectRootAvailable({ kind: 'prototype', baseDir }),
|
||||
).toThrowError(SandboxImportedProjectError);
|
||||
expect(() => resolveProjectDir(projectsRoot, '../escape', {
|
||||
kind: 'prototype',
|
||||
baseDir,
|
||||
})).toThrowError();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectEntryFile', () => {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,37 @@ describe('POST /api/import/folder', () => {
|
|||
});
|
||||
}
|
||||
|
||||
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env.OD_SANDBOX_MODE;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||
else process.env.OD_SANDBOX_MODE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRunStatus(
|
||||
runId: string,
|
||||
): Promise<{ status: string; error?: string | null; errorCode?: string | null }> {
|
||||
let lastStatus = 'unknown';
|
||||
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||
const statusResponse = await fetch(`${baseUrl}/api/runs/${runId}`);
|
||||
const statusBody = (await statusResponse.json()) as {
|
||||
status: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
};
|
||||
lastStatus = statusBody.status;
|
||||
if (statusBody.status !== 'queued' && statusBody.status !== 'running') {
|
||||
return statusBody;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error(`run did not reach a terminal status; last status: ${lastStatus}`);
|
||||
}
|
||||
|
||||
it('creates a project rooted at the submitted folder', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
|
@ -62,6 +93,80 @@ describe('POST /api/import/folder', () => {
|
|||
expect(body.entryFile).toBe('index.html');
|
||||
});
|
||||
|
||||
it('rejects folder imports in sandbox mode', async () => {
|
||||
await withSandboxMode(async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const resp = await importFolder({ baseDir: folder });
|
||||
expect(resp.status).toBe(400);
|
||||
const body = (await resp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/OD_SANDBOX_MODE/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails sandbox runs for imported folders instead of using an empty managed project', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await importFolder({ baseDir: folder });
|
||||
expect(importResp.status).toBe(200);
|
||||
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const runResp = await fetch(`${baseUrl}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
projectId: project.id,
|
||||
message: 'Inspect the imported project.',
|
||||
}),
|
||||
});
|
||||
expect(runResp.status).toBe(202);
|
||||
const { runId } = (await runResp.json()) as { runId: string };
|
||||
const status = await waitForRunStatus(runId);
|
||||
expect(status.status).toBe('failed');
|
||||
expect(status.errorCode).toBe('BAD_REQUEST');
|
||||
expect(status.error).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('still opens an imported-folder project record in sandbox mode', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await importFolder({ baseDir: folder });
|
||||
expect(importResp.status).toBe(200);
|
||||
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${project.id}`);
|
||||
expect(resp.status).toBe(200);
|
||||
const body = (await resp.json()) as {
|
||||
project?: { id?: string; metadata?: { baseDir?: string } };
|
||||
};
|
||||
expect(body.project?.id).toBe(project.id);
|
||||
expect(body.project?.metadata?.baseDir).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects imported-folder project file listing in sandbox mode', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await importFolder({ baseDir: folder });
|
||||
expect(importResp.status).toBe(200);
|
||||
const { project } = (await importResp.json()) as { project: { id: string } };
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const resp = await fetch(`${baseUrl}/api/projects/${project.id}/files`);
|
||||
expect(resp.status).toBe(400);
|
||||
const body = (await resp.json()) as { error?: { message?: string } };
|
||||
expect(body.error?.message).toMatch(/imported-folder projects.*OD_SANDBOX_MODE/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-detects the entry file when present', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '');
|
||||
|
|
|
|||
194
apps/daemon/tests/headless-runs.test.ts
Normal file
194
apps/daemon/tests/headless-runs.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import type http from 'node:http';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
|
||||
type StartedServer = {
|
||||
url: string;
|
||||
server: http.Server;
|
||||
shutdown?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
describe('POST /api/runs headless fallbacks', () => {
|
||||
let started: StartedServer | null = null;
|
||||
const oldPath = process.env.PATH;
|
||||
const oldAgentHome = process.env.OD_AGENT_HOME;
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.resolve(started?.shutdown?.());
|
||||
if (started?.server) {
|
||||
await new Promise<void>((resolve) => started?.server.close(() => resolve()));
|
||||
}
|
||||
started = null;
|
||||
if (oldPath === undefined) delete process.env.PATH;
|
||||
else process.env.PATH = oldPath;
|
||||
if (oldAgentHome === undefined) delete process.env.OD_AGENT_HOME;
|
||||
else process.env.OD_AGENT_HOME = oldAgentHome;
|
||||
});
|
||||
|
||||
it('binds omitted conversationId to the seeded project conversation', async () => {
|
||||
started = await startTestServer();
|
||||
const { projectId, conversationId: seededConversationId } = await createProject(
|
||||
started.url,
|
||||
'Headless default conversation project',
|
||||
);
|
||||
await delay(5);
|
||||
const newerConversationId = await createConversation(started.url, projectId, 'Newer user chat');
|
||||
|
||||
const conversationsResponse = await fetch(
|
||||
`${started.url}/api/projects/${encodeURIComponent(projectId)}/conversations`,
|
||||
);
|
||||
expect(conversationsResponse.status).toBe(200);
|
||||
const conversationsBody = await conversationsResponse.json() as {
|
||||
conversations: Array<{ id: string }>;
|
||||
};
|
||||
expect(conversationsBody.conversations[0]?.id).toBe(newerConversationId);
|
||||
|
||||
const runResponse = await fetch(`${started.url}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: `missing-agent-${randomUUID()}`,
|
||||
projectId,
|
||||
message: 'Headless prompt',
|
||||
}),
|
||||
});
|
||||
expect(runResponse.status).toBe(202);
|
||||
const runBody = await runResponse.json() as { conversationId: string | null };
|
||||
expect(runBody.conversationId).toBe(seededConversationId);
|
||||
});
|
||||
|
||||
it('falls back past a stale saved agent to the first detected available runtime', async () => {
|
||||
started = await startTestServer();
|
||||
const binDir = await mkdtemp(path.join(os.tmpdir(), 'od-headless-run-bin-'));
|
||||
const emptyAgentHome = await mkdtemp(path.join(os.tmpdir(), 'od-headless-run-home-'));
|
||||
const priorConfig = await readAppConfigFromServer(started.url);
|
||||
try {
|
||||
const opencodeBin = await writeFakeOpencode(binDir);
|
||||
process.env.PATH = '';
|
||||
process.env.OD_AGENT_HOME = emptyAgentHome;
|
||||
|
||||
const configResponse = await fetch(`${started.url}/api/app-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: 'claude',
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_BIN: path.join(binDir, 'missing-claude') },
|
||||
opencode: { OPENCODE_BIN: opencodeBin },
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(configResponse.status).toBe(200);
|
||||
|
||||
const { projectId } = await createProject(started.url, 'Headless stale agent project');
|
||||
const runResponse = await fetch(`${started.url}/api/runs`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectId,
|
||||
message: 'Headless prompt',
|
||||
}),
|
||||
});
|
||||
expect(runResponse.status).toBe(202);
|
||||
const runBody = await runResponse.json() as { runId: string };
|
||||
const statusResponse = await fetch(
|
||||
`${started.url}/api/runs/${encodeURIComponent(runBody.runId)}`,
|
||||
);
|
||||
expect(statusResponse.status).toBe(200);
|
||||
const statusBody = await statusResponse.json() as { agentId: string | null };
|
||||
expect(statusBody.agentId).toBe('opencode');
|
||||
} finally {
|
||||
await restoreAppConfig(started.url, priorConfig);
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
await rm(emptyAgentHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function startTestServer(): Promise<StartedServer> {
|
||||
return await startServer({ port: 0, returnServer: true }) as StartedServer;
|
||||
}
|
||||
|
||||
async function createProject(url: string, name: string): Promise<{
|
||||
projectId: string;
|
||||
conversationId: string;
|
||||
}> {
|
||||
const projectId = `project_${randomUUID()}`;
|
||||
const response = await fetch(`${url}/api/projects`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: projectId,
|
||||
name,
|
||||
metadata: { kind: 'prototype' },
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as { conversationId: string };
|
||||
return { projectId, conversationId: body.conversationId };
|
||||
}
|
||||
|
||||
async function createConversation(
|
||||
url: string,
|
||||
projectId: string,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
const response = await fetch(`${url}/api/projects/${encodeURIComponent(projectId)}/conversations`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as { conversation: { id: string } };
|
||||
return body.conversation.id;
|
||||
}
|
||||
|
||||
async function readAppConfigFromServer(url: string): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${url}/api/app-config`);
|
||||
expect(response.status).toBe(200);
|
||||
const body = await response.json() as { config?: Record<string, unknown> };
|
||||
return body.config ?? {};
|
||||
}
|
||||
|
||||
async function restoreAppConfig(url: string, config: Record<string, unknown>): Promise<void> {
|
||||
await fetch(`${url}/api/app-config`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
agentId: Object.hasOwn(config, 'agentId') ? config.agentId : null,
|
||||
agentCliEnv: Object.hasOwn(config, 'agentCliEnv') ? config.agentCliEnv : null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function writeFakeOpencode(dir: string): Promise<string> {
|
||||
const bin = path.join(dir, 'opencode');
|
||||
await writeFile(bin, `#!/usr/bin/env node
|
||||
if (process.argv.includes('--version')) {
|
||||
console.log('opencode 0.0.0');
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.argv[2] === 'models') {
|
||||
console.log('test/model');
|
||||
process.exit(0);
|
||||
}
|
||||
if (process.argv[2] === 'run') {
|
||||
process.stdin.resume();
|
||||
process.stdin.on('end', () => process.exit(0));
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
} else {
|
||||
process.exit(0);
|
||||
}
|
||||
`, 'utf8');
|
||||
await chmod(bin, 0o755);
|
||||
return bin;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
);
|
||||
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
||||
const originalDataDir = process.env.OD_DATA_DIR;
|
||||
const originalSandboxMode = process.env.OD_SANDBOX_MODE;
|
||||
let homedirSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
|
|
@ -42,6 +43,7 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
}
|
||||
delete process.env.OD_MEDIA_CONFIG_DIR;
|
||||
delete process.env.OD_DATA_DIR;
|
||||
delete process.env.OD_SANDBOX_MODE;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -67,6 +69,11 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
} else {
|
||||
process.env.OD_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalSandboxMode == null) {
|
||||
delete process.env.OD_SANDBOX_MODE;
|
||||
} else {
|
||||
process.env.OD_SANDBOX_MODE = originalSandboxMode;
|
||||
}
|
||||
homedirSpy.mockRestore();
|
||||
await rm(homeDir, { recursive: true, force: true });
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
|
|
@ -124,6 +131,30 @@ describe('media-config OpenAI auth-file fallback', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not read host OpenAI auth files in sandbox mode', async () => {
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
await writeHomeJson('.hermes/auth.json', {
|
||||
providers: {
|
||||
'openai-codex': {
|
||||
tokens: { access_token: 'hermes-oauth-token' },
|
||||
},
|
||||
},
|
||||
});
|
||||
await writeHomeJson('.codex/auth.json', {
|
||||
tokens: { access_token: 'codex-oauth-token' },
|
||||
OPENAI_API_KEY: 'host-codex-api-key',
|
||||
});
|
||||
|
||||
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
||||
const masked = await readMaskedConfig(projectRoot);
|
||||
|
||||
expect(resolved.apiKey).toBe('');
|
||||
expect(openaiProvider(masked)).toMatchObject({
|
||||
configured: false,
|
||||
source: 'unset',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses explicit OPENAI_API_KEY from Codex auth files', async () => {
|
||||
await writeHomeJson('.codex/auth.json', {
|
||||
tokens: { access_token: 'codex-oauth-token' },
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import type http from 'node:http';
|
||||
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { startServer } from '../src/server.js';
|
||||
import { memoryDir, writeMemoryConfig } from '../src/memory.js';
|
||||
|
||||
type FakeMediaEndpoint = 'tool' | 'legacy';
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ describe('run-scoped media policy routes', () => {
|
|||
let binDir: string;
|
||||
let oldPath: string | undefined;
|
||||
let oldCapture: string | undefined;
|
||||
let oldMemoryConfigRaw: string | null = null;
|
||||
let server: http.Server | null = null;
|
||||
let shutdown: (() => Promise<void> | void) | undefined;
|
||||
|
||||
|
|
@ -28,6 +30,12 @@ describe('run-scoped media policy routes', () => {
|
|||
oldPath = process.env.PATH;
|
||||
oldCapture = process.env.OD_CAPTURE_MEDIA_RESPONSE;
|
||||
process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ''}`;
|
||||
const memoryConfig = memoryConfigPath();
|
||||
oldMemoryConfigRaw = await readFile(memoryConfig, 'utf8').catch(() => null);
|
||||
await writeMemoryConfig(process.env.OD_DATA_DIR!, {
|
||||
chatExtractionEnabled: false,
|
||||
extraction: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -41,6 +49,14 @@ describe('run-scoped media policy routes', () => {
|
|||
else process.env.PATH = oldPath;
|
||||
if (oldCapture === undefined) delete process.env.OD_CAPTURE_MEDIA_RESPONSE;
|
||||
else process.env.OD_CAPTURE_MEDIA_RESPONSE = oldCapture;
|
||||
const memoryConfig = memoryConfigPath();
|
||||
if (oldMemoryConfigRaw === null) {
|
||||
await rm(memoryConfig, { force: true });
|
||||
} else {
|
||||
await mkdir(path.dirname(memoryConfig), { recursive: true });
|
||||
await writeFile(memoryConfig, oldMemoryConfigRaw);
|
||||
}
|
||||
oldMemoryConfigRaw = null;
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
await rm(binDir, { recursive: true, force: true });
|
||||
});
|
||||
|
|
@ -468,6 +484,10 @@ describe('run-scoped media policy routes', () => {
|
|||
};
|
||||
}
|
||||
|
||||
function memoryConfigPath(): string {
|
||||
return path.join(memoryDir(process.env.OD_DATA_DIR!), '.config.json');
|
||||
}
|
||||
|
||||
async function writeFakeAgent(
|
||||
capturePath: string,
|
||||
requestBody: unknown,
|
||||
|
|
|
|||
|
|
@ -77,6 +77,35 @@ describe('GET /api/projects/:id resolvedDir', () => {
|
|||
expect(detail.resolvedDir).toBe(baseDir);
|
||||
});
|
||||
|
||||
it('keeps imported-folder resolvedDir stable in sandbox mode', async () => {
|
||||
const folder = makeFolder();
|
||||
await writeFile(path.join(folder, 'index.html'), '<!doctype html>');
|
||||
|
||||
const importResp = await fetch(`${baseUrl}/api/import/folder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ baseDir: folder }),
|
||||
});
|
||||
expect(importResp.status).toBe(200);
|
||||
const importBody = (await importResp.json()) as {
|
||||
project: { id: string; metadata?: { baseDir?: string } };
|
||||
};
|
||||
const projectId = importBody.project.id;
|
||||
const baseDir = importBody.project.metadata?.baseDir;
|
||||
expect(baseDir).toBeTruthy();
|
||||
|
||||
await withSandboxMode(async () => {
|
||||
const detailResp = await fetch(`${baseUrl}/api/projects/${projectId}`);
|
||||
expect(detailResp.status).toBe(200);
|
||||
const detail = (await detailResp.json()) as {
|
||||
project: { id: string };
|
||||
resolvedDir: string;
|
||||
};
|
||||
expect(detail.project.id).toBe(projectId);
|
||||
expect(detail.resolvedDir).toBe(baseDir);
|
||||
});
|
||||
});
|
||||
|
||||
it('returns resolvedDir under <projects root>/<id> for a native project', async () => {
|
||||
const projectId = `proj-routes-${Date.now()}`;
|
||||
const createResp = await fetch(`${baseUrl}/api/projects`, {
|
||||
|
|
@ -269,3 +298,14 @@ describe('GET /api/projects/:id resolvedDir', () => {
|
|||
expect(body.error?.message).toMatch(/fromTrustedPicker/i);
|
||||
});
|
||||
});
|
||||
|
||||
async function withSandboxMode<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env.OD_SANDBOX_MODE;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
||||
else process.env.OD_SANDBOX_MODE = previous;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,23 @@ describe('resolveDataDir', () => {
|
|||
expect(resolveDataDir('', projectRoot)).toBe(path.join(projectRoot, '.od'));
|
||||
});
|
||||
|
||||
it('requires an explicit OD_DATA_DIR when sandbox mode requires one', () => {
|
||||
expect(() =>
|
||||
resolveDataDir(undefined, projectRoot, { requireExplicit: true }),
|
||||
).toThrow('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
|
||||
expect(() => resolveDataDir('', projectRoot, { requireExplicit: true })).toThrow(
|
||||
'OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled',
|
||||
);
|
||||
expect(() =>
|
||||
resolveDataDir(' ', projectRoot, { requireExplicit: true }),
|
||||
).toThrow('OD_DATA_DIR is required when OD_SANDBOX_MODE is enabled');
|
||||
});
|
||||
|
||||
it('trims OD_DATA_DIR before resolving the storage root', () => {
|
||||
const out = resolveDataDir(' rel-od ', projectRoot, { requireExplicit: true });
|
||||
expect(out).toBe(path.join(projectRoot, 'rel-od'));
|
||||
});
|
||||
|
||||
it('expands a leading ~/ against the user home directory', () => {
|
||||
const out = resolveDataDir('~/od-test', projectRoot);
|
||||
expect(out).toBe(path.join(fakeHome, 'od-test'));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { symlinkSync } from 'node:fs';
|
||||
import { test, vi } from 'vitest';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as platform from '@open-design/platform';
|
||||
import {
|
||||
assert, chmodSync, detectAgents, inspectAgentExecutableResolution, join, minimalAgentDef, mkdirSync, mkdtempSync, opencode, resolveAgentExecutable, rmSync, spawnEnvForAgent, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
|
||||
|
|
@ -8,6 +10,7 @@ import {
|
|||
import { isCursorAuthFailureText } from '../../src/runtimes/auth.js';
|
||||
|
||||
const fsTest = process.platform === 'win32' ? test.skip : test;
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..');
|
||||
|
||||
// Issue #398: Claude Code prefers ANTHROPIC_API_KEY over `claude login`
|
||||
// credentials, silently billing API usage. Strip it for the claude
|
||||
|
|
@ -55,6 +58,113 @@ test('spawnEnvForAgent applies configured Codex env without mutating the base en
|
|||
assert.equal('CODEX_BIN' in base, false);
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent reapplies sandbox state roots after configured env overrides', () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-'));
|
||||
try {
|
||||
const codexEnv = spawnEnvForAgent(
|
||||
'codex',
|
||||
{
|
||||
OD_DATA_DIR: dataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
CODEX_HOME: '/Users/test/.codex-host',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
codexEnv.CODEX_HOME,
|
||||
join(dataDir, 'sandbox', 'agent-home', '.codex'),
|
||||
);
|
||||
assert.equal(codexEnv.HOME, join(dataDir, 'sandbox', 'agent-home'));
|
||||
|
||||
const claudeEnv = spawnEnvForAgent(
|
||||
'claude',
|
||||
{
|
||||
OD_DATA_DIR: dataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
CLAUDE_CONFIG_DIR: '/Users/test/.claude-host',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
claudeEnv.CLAUDE_CONFIG_DIR,
|
||||
join(dataDir, 'sandbox', 'config', 'claude'),
|
||||
);
|
||||
|
||||
const amrEnv = spawnEnvForAgent(
|
||||
'amr',
|
||||
{
|
||||
OD_DATA_DIR: dataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
OPENCODE_TEST_HOME: '/Users/test/.opencode-host',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
amrEnv.OPENCODE_TEST_HOME,
|
||||
join(dataDir, 'sandbox', 'agent-home', '.opencode'),
|
||||
);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent keeps sandbox roots pinned to the base OD_DATA_DIR', () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-base-'));
|
||||
try {
|
||||
const env = spawnEnvForAgent(
|
||||
'codex',
|
||||
{
|
||||
OD_DATA_DIR: dataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
CODEX_HOME: '/Users/test/.codex-host',
|
||||
OD_DATA_DIR: '/host/path/.od',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(env.OD_DATA_DIR, dataDir);
|
||||
assert.equal(env.CODEX_HOME, join(dataDir, 'sandbox', 'agent-home', '.codex'));
|
||||
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent resolves relative OD_DATA_DIR before applying sandbox roots', () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'od-agent-env-sandbox-relative-'));
|
||||
try {
|
||||
const relativeDataDir = relative(repoRoot, dataDir);
|
||||
const env = spawnEnvForAgent(
|
||||
'codex',
|
||||
{
|
||||
OD_DATA_DIR: relativeDataDir,
|
||||
OD_SANDBOX_MODE: '1',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{
|
||||
CODEX_HOME: '/Users/test/.codex-host',
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
env.CODEX_HOME,
|
||||
join(dataDir, 'sandbox', 'agent-home', '.codex'),
|
||||
);
|
||||
assert.equal(env.CLAUDE_CONFIG_DIR, join(dataDir, 'sandbox', 'config', 'claude'));
|
||||
assert.equal(env.HOME, join(dataDir, 'sandbox', 'agent-home'));
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent applies system proxy env to all agent runtimes before base env overrides', () => {
|
||||
const env = spawnEnvForAgent(
|
||||
'gemini',
|
||||
|
|
@ -847,6 +957,22 @@ test('spawnEnvForAgent strips ANTHROPIC_API_KEY case-insensitively for the claud
|
|||
assert.equal(env.PATH, '/usr/bin');
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY when claude resolves to OpenClaude fallback', () => {
|
||||
const env = spawnEnvForAgent(
|
||||
'claude',
|
||||
{
|
||||
ANTHROPIC_API_KEY: 'sk-openclaude',
|
||||
PATH: '/usr/bin',
|
||||
},
|
||||
{},
|
||||
{},
|
||||
{ resolvedBin: '/tools/openclaude' },
|
||||
);
|
||||
|
||||
assert.equal(env.ANTHROPIC_API_KEY, 'sk-openclaude');
|
||||
assert.equal(env.PATH, '/usr/bin');
|
||||
});
|
||||
|
||||
test('spawnEnvForAgent preserves ANTHROPIC_API_KEY for non-claude adapters', () => {
|
||||
for (const agentId of ['codex', 'gemini', 'opencode', 'devin']) {
|
||||
const env = spawnEnvForAgent(agentId, {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { test } from 'vitest';
|
||||
import { relative, resolve } from 'node:path';
|
||||
import {
|
||||
assert, chmodSync, claude, deepseek, gemini, join, minimalAgentDef, mkdirSync, mkdtempSync, resolveAgentExecutable, rmSync, tmpdir, withEnvSnapshot, withPlatform, writeFileSync,
|
||||
} from './helpers/test-helpers.js';
|
||||
|
|
@ -407,6 +408,77 @@ fsTest(
|
|||
},
|
||||
);
|
||||
|
||||
fsTest(
|
||||
'OD_SANDBOX_MODE scopes fallback toolchain discovery to OD_DATA_DIR',
|
||||
() => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'od-agents-sandbox-data-'));
|
||||
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
|
||||
const realPrefix = mkdtempSync(join(tmpdir(), 'od-agents-real-prefix-'));
|
||||
const realPrefixBin = join(realPrefix, 'bin');
|
||||
try {
|
||||
return withEnvSnapshot(
|
||||
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
|
||||
() => {
|
||||
mkdirSync(realPrefixBin, { recursive: true });
|
||||
writeFileSync(join(realPrefixBin, 'gemini'), '');
|
||||
chmodSync(join(realPrefixBin, 'gemini'), 0o755);
|
||||
|
||||
delete process.env.OD_AGENT_HOME;
|
||||
process.env.OD_DATA_DIR = dataDir;
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
process.env.PATH = emptyPath;
|
||||
process.env.NPM_CONFIG_PREFIX = realPrefix;
|
||||
|
||||
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
|
||||
assert.equal(
|
||||
resolved,
|
||||
null,
|
||||
`sandbox mode must not see the host $NPM_CONFIG_PREFIX bin; got ${resolved}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
rmSync(emptyPath, { recursive: true, force: true });
|
||||
rmSync(realPrefix, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsTest(
|
||||
'OD_SANDBOX_MODE resolves relative OD_DATA_DIR before fallback toolchain discovery',
|
||||
() => {
|
||||
const projectRoot = resolve(process.cwd(), '../..');
|
||||
const parent = mkdtempSync(join(tmpdir(), 'od-agents-relative-data-parent-'));
|
||||
const dataDir = join(parent, 'data');
|
||||
const sandboxBin = join(dataDir, 'sandbox', 'agent-home', '.local', 'bin');
|
||||
const emptyPath = mkdtempSync(join(tmpdir(), 'od-agents-empty-path-'));
|
||||
try {
|
||||
return withEnvSnapshot(
|
||||
['PATH', 'OD_AGENT_HOME', 'OD_DATA_DIR', 'OD_SANDBOX_MODE', 'NPM_CONFIG_PREFIX'],
|
||||
() => {
|
||||
mkdirSync(sandboxBin, { recursive: true });
|
||||
const geminiPath = join(sandboxBin, 'gemini');
|
||||
writeFileSync(geminiPath, '');
|
||||
chmodSync(geminiPath, 0o755);
|
||||
|
||||
delete process.env.OD_AGENT_HOME;
|
||||
delete process.env.NPM_CONFIG_PREFIX;
|
||||
process.env.OD_DATA_DIR = relative(projectRoot, dataDir);
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
process.env.PATH = emptyPath;
|
||||
|
||||
const resolved = resolveAgentExecutable(minimalAgentDef({ bin: 'gemini' }));
|
||||
assert.equal(resolved, geminiPath);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
rmSync(parent, { recursive: true, force: true });
|
||||
rmSync(emptyPath, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
fsTest(
|
||||
'OD_AGENT_HOME isolates resolution from $VP_HOME leakage',
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -102,6 +102,31 @@ test('local agent profiles skip explicit unknown baseAgent without falling back'
|
|||
}
|
||||
});
|
||||
|
||||
test('sandbox mode ignores implicit and host explicit local agent profiles', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'od-local-agent-profiles-sandbox-'));
|
||||
try {
|
||||
await withEnvSnapshot(['OD_AGENT_PROFILES_CONFIG', 'OD_SANDBOX_MODE', 'OD_DATA_DIR'], async () => {
|
||||
const config = join(dir, 'agents.local.json');
|
||||
writeFileSync(
|
||||
config,
|
||||
JSON.stringify({
|
||||
agents: [{ id: 'explicit-wrapper', bin: 'explicit-wrapper' }],
|
||||
}),
|
||||
);
|
||||
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
delete process.env.OD_DATA_DIR;
|
||||
delete process.env.OD_AGENT_PROFILES_CONFIG;
|
||||
assert.deepEqual(readLocalAgentProfileDefs(), []);
|
||||
|
||||
process.env.OD_AGENT_PROFILES_CONFIG = config;
|
||||
assert.deepEqual(readLocalAgentProfileDefs(), []);
|
||||
});
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('codex args disable plugins when OD_CODEX_DISABLE_PLUGINS is 1', () => {
|
||||
process.env.OD_CODEX_DISABLE_PLUGINS = '1';
|
||||
|
||||
|
|
|
|||
98
apps/daemon/tests/sandbox-mode.test.ts
Normal file
98
apps/daemon/tests/sandbox-mode.test.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { existsSync, mkdtempSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
applySandboxRuntimeEnv,
|
||||
ensureSandboxRuntimeDirs,
|
||||
isSandboxModeEnabled,
|
||||
resolveSandboxRuntimeConfig,
|
||||
} from '../src/sandbox-mode.js';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
function tempDataDir(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), 'od-sandbox-mode-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('sandbox mode env parsing', () => {
|
||||
it('is disabled when OD_SANDBOX_MODE is unset or false-like', () => {
|
||||
expect(isSandboxModeEnabled({})).toBe(false);
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: '0' })).toBe(false);
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'false' })).toBe(false);
|
||||
});
|
||||
|
||||
it('is enabled for explicit true-like values', () => {
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: '1' })).toBe(true);
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'true' })).toBe(true);
|
||||
expect(isSandboxModeEnabled({ OD_SANDBOX_MODE: 'YES' })).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects ambiguous non-empty values', () => {
|
||||
expect(() => isSandboxModeEnabled({ OD_SANDBOX_MODE: 'sandbox' })).toThrow(
|
||||
'OD_SANDBOX_MODE must be one of',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sandbox runtime roots', () => {
|
||||
it('keeps all run-scoped roots under OD_DATA_DIR', () => {
|
||||
const dataDir = tempDataDir();
|
||||
const config = resolveSandboxRuntimeConfig(true, dataDir);
|
||||
|
||||
expect(config.enabled).toBe(true);
|
||||
for (const dir of Object.values(config.roots)) {
|
||||
expect(dir === dataDir || dir.startsWith(dataDir + path.sep)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('creates scoped runtime directories only when enabled', () => {
|
||||
const dataDir = tempDataDir();
|
||||
const enabled = resolveSandboxRuntimeConfig(true, dataDir);
|
||||
const disabled = resolveSandboxRuntimeConfig(false, dataDir);
|
||||
|
||||
ensureSandboxRuntimeDirs(disabled);
|
||||
expect(existsSync(enabled.roots.agentHomeDir)).toBe(false);
|
||||
|
||||
ensureSandboxRuntimeDirs(enabled);
|
||||
expect(existsSync(enabled.roots.agentHomeDir)).toBe(true);
|
||||
expect(existsSync(enabled.roots.previewStateDir)).toBe(true);
|
||||
expect(existsSync(enabled.roots.toolConfigDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('pins agent home and tool config env to sandbox roots', () => {
|
||||
const dataDir = tempDataDir();
|
||||
const config = resolveSandboxRuntimeConfig(true, dataDir);
|
||||
const env = applySandboxRuntimeEnv(
|
||||
{
|
||||
HOME: '/real/home',
|
||||
CODEX_HOME: '/real/home/.codex',
|
||||
CLAUDE_CONFIG_DIR: '/real/home/.claude',
|
||||
OPENCODE_TEST_HOME: '/real/home/.opencode',
|
||||
NPM_CONFIG_USERCONFIG: '/real/home/.npmrc',
|
||||
OD_DATA_DIR: dataDir,
|
||||
PATH: '/bin',
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
expect(env.HOME).toBe(config.roots.agentHomeDir);
|
||||
expect(env.USERPROFILE).toBe(config.roots.agentHomeDir);
|
||||
expect(env.OD_AGENT_HOME).toBe(config.roots.agentHomeDir);
|
||||
expect(env.CODEX_HOME).toBe(path.join(config.roots.agentHomeDir, '.codex'));
|
||||
expect(env.CLAUDE_CONFIG_DIR).toBe(path.join(config.roots.configDir, 'claude'));
|
||||
expect(env.OPENCODE_TEST_HOME).toBe(path.join(config.roots.agentHomeDir, '.opencode'));
|
||||
expect(env.NPM_CONFIG_USERCONFIG).toBe(path.join(config.roots.toolConfigDir, 'npmrc'));
|
||||
expect(env.PATH).toBe('/bin');
|
||||
});
|
||||
});
|
||||
142
apps/daemon/tests/sandbox-runtime-bootstrap.test.ts
Normal file
142
apps/daemon/tests/sandbox-runtime-bootstrap.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test, vi } from 'vitest';
|
||||
|
||||
function withEnvSnapshot<T>(
|
||||
keys: readonly string[],
|
||||
run: () => T | Promise<T>,
|
||||
): T | Promise<T> {
|
||||
const snapshot = new Map(keys.map((key) => [key, process.env[key]]));
|
||||
const restore = () => {
|
||||
for (const key of keys) {
|
||||
const value = snapshot.get(key);
|
||||
if (value == null) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let result: T | Promise<T>;
|
||||
try {
|
||||
result = run();
|
||||
} catch (error) {
|
||||
restore();
|
||||
throw error;
|
||||
}
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(restore);
|
||||
}
|
||||
restore();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('sandbox runtime registry ignores host-local agent profiles at module load', async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'od-sandbox-registry-'));
|
||||
const dataDir = path.join(root, 'data');
|
||||
const hostHome = path.join(root, 'host-home');
|
||||
const hostConfigDir = path.join(hostHome, '.open-design');
|
||||
const hostConfig = path.join(hostConfigDir, 'agents.local.json');
|
||||
const sandboxConfigDir = path.join(
|
||||
dataDir,
|
||||
'sandbox',
|
||||
'agent-home',
|
||||
'.open-design',
|
||||
);
|
||||
const sandboxConfig = path.join(sandboxConfigDir, 'agents.local.json');
|
||||
|
||||
try {
|
||||
mkdirSync(hostConfigDir, { recursive: true });
|
||||
mkdirSync(sandboxConfigDir, { recursive: true });
|
||||
writeFileSync(
|
||||
hostConfig,
|
||||
JSON.stringify({
|
||||
agents: [{ id: 'host-wrapper', baseAgent: 'claude', bin: 'host-wrapper' }],
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
sandboxConfig,
|
||||
JSON.stringify({
|
||||
agents: [
|
||||
{
|
||||
id: 'sandbox-wrapper',
|
||||
baseAgent: 'claude',
|
||||
bin: 'sandbox-wrapper',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await withEnvSnapshot(
|
||||
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_PROFILES_CONFIG'],
|
||||
async () => {
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
process.env.OD_DATA_DIR = dataDir;
|
||||
process.env.OD_AGENT_PROFILES_CONFIG = hostConfig;
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('node:os', async () => ({
|
||||
...(await vi.importActual<typeof import('node:os')>('node:os')),
|
||||
homedir: () => hostHome,
|
||||
}));
|
||||
const { AGENT_DEFS } = await import('../src/runtimes/registry.js');
|
||||
const ids = AGENT_DEFS.map((def) => def.id);
|
||||
|
||||
assert.equal(ids.includes('host-wrapper'), false);
|
||||
assert.equal(ids.includes('sandbox-wrapper'), true);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('sandbox runtime registry ignores implicit profiles without OD_DATA_DIR', async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'od-sandbox-registry-missing-data-'));
|
||||
const hostHome = path.join(root, 'host-home');
|
||||
const hostConfigDir = path.join(hostHome, '.open-design');
|
||||
const hostConfig = path.join(hostConfigDir, 'agents.local.json');
|
||||
|
||||
try {
|
||||
mkdirSync(hostConfigDir, { recursive: true });
|
||||
writeFileSync(
|
||||
hostConfig,
|
||||
JSON.stringify({
|
||||
agents: [{ id: 'host-wrapper', baseAgent: 'claude', bin: 'host-wrapper' }],
|
||||
}),
|
||||
);
|
||||
|
||||
await withEnvSnapshot(
|
||||
['OD_SANDBOX_MODE', 'OD_DATA_DIR', 'OD_AGENT_PROFILES_CONFIG'],
|
||||
async () => {
|
||||
process.env.OD_SANDBOX_MODE = '1';
|
||||
delete process.env.OD_DATA_DIR;
|
||||
delete process.env.OD_AGENT_PROFILES_CONFIG;
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock('node:os', async () => ({
|
||||
...(await vi.importActual<typeof import('node:os')>('node:os')),
|
||||
homedir: () => hostHome,
|
||||
}));
|
||||
const { AGENT_DEFS } = await import('../src/runtimes/registry.js');
|
||||
const ids = AGENT_DEFS.map((def) => def.id);
|
||||
|
||||
assert.equal(ids.includes('host-wrapper'), false);
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
vi.doUnmock('node:os');
|
||||
vi.resetModules();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
---
|
||||
/*
|
||||
* Shared skill row used on `/skills/`, `/skills/mode/<slug>/`,
|
||||
* `/skills/scenario/<slug>/`, and any future faceted view.
|
||||
*
|
||||
* Renders a `<li class="catalog-row catalog-row-skill">` with the
|
||||
* canonical 5-column grid (index, thumb, body, meta, arrow). Centralizes
|
||||
* the markup so all faceted views stay visually identical to the
|
||||
* unfiltered index.
|
||||
*/
|
||||
import type { SkillRecord } from '../_lib/catalog';
|
||||
import { localeFromPath, localizedHref } from '../i18n';
|
||||
|
||||
export interface Props {
|
||||
skill: SkillRecord;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const { skill, index } = Astro.props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
|
||||
// Catalog row thumbs are tiny (~130×80 rendered, single-format PNGs)
|
||||
// so we deliberately bypass the precise IntersectionObserver pipeline.
|
||||
// On long lists like /skills/instructions/ (96 rows) the observer's
|
||||
// swap latency stranded mid-page rows on the SVG placeholder during
|
||||
// fast scrolls. Native lazy loading (the browser's own 1250-3000px
|
||||
// lookahead) keeps the upcoming rows pre-fetched without the
|
||||
// observer round-trip; only the first three rows go eager so they
|
||||
// paint immediately on first paint instead of waiting for the
|
||||
// browser's lazy queue.
|
||||
const eager = index < 3;
|
||||
---
|
||||
|
||||
<li class="catalog-row catalog-row-skill">
|
||||
<a href={href(`/skills/${skill.slug}/`)}>
|
||||
<span class="row-index">{String(index + 1).padStart(3, '0')}</span>
|
||||
<span class="row-thumb">
|
||||
{skill.previewUrl ? (
|
||||
<img
|
||||
src={skill.previewUrl}
|
||||
alt=""
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
fetchpriority={eager ? 'high' : 'auto'}
|
||||
/>
|
||||
) : (
|
||||
<span class="row-thumb-empty" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<span class="row-body">
|
||||
<span class="row-name">{skill.name}</span>
|
||||
<span class="row-desc">{skill.description}</span>
|
||||
</span>
|
||||
<span class="row-meta">
|
||||
{skill.modeLabel && <span class="meta-tag">{skill.modeLabel}</span>}
|
||||
{skill.scenarioLabel && <span class="meta-tag muted">{skill.scenarioLabel}</span>}
|
||||
{skill.platformLabel && <span class="meta-tag muted">{skill.platformLabel}</span>}
|
||||
</span>
|
||||
<span class="row-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
---
|
||||
/*
|
||||
* Shared system card used on `/systems/` and
|
||||
* `/systems/category/<slug>/`. Displays palette swatches, name,
|
||||
* category, and tagline as a clickable card.
|
||||
* Shared system card used on `/plugins/systems/`. Displays palette
|
||||
* swatches, name, category, and tagline as a clickable card.
|
||||
*
|
||||
* The card links to `/systems/<slug>/`, which `public/_redirects`
|
||||
* 301s to the bundled-plugin detail (`/plugins/design-system-<slug>/`)
|
||||
* for the 142 systems that have one, and degrades the 8 without a
|
||||
* detail page to `/plugins/systems/`. Linking through the redirect
|
||||
* (rather than hard-coding `design-system-<slug>`) keeps those 8 from
|
||||
* pointing at a non-existent detail page.
|
||||
*/
|
||||
import type { SystemRecord } from '../_lib/catalog';
|
||||
import { localeFromPath, localizedHref } from '../i18n';
|
||||
|
|
|
|||
|
|
@ -222,9 +222,9 @@ const INFO_PAGE_COPY: Partial<Record<LandingLocaleCode, InfoPageCopy>> = {
|
|||
{ label: 'Community', name: 'Discord' },
|
||||
{ label: 'Documentation', name: 'GitHub README' },
|
||||
{ label: 'License', name: 'Apache-2.0' },
|
||||
{ label: 'Skills catalog', name: '/skills/' },
|
||||
{ label: 'Systems catalog', name: '/systems/' },
|
||||
{ label: 'Templates catalog', name: '/templates/' },
|
||||
{ label: 'Skills catalog', name: '/plugins/skills/' },
|
||||
{ label: 'Systems catalog', name: '/plugins/systems/' },
|
||||
{ label: 'Templates catalog', name: '/plugins/templates/' },
|
||||
],
|
||||
aliasesTitle: 'Naming & aliases',
|
||||
aliasesLead:
|
||||
|
|
@ -538,9 +538,9 @@ INFO_PAGE_COPY.zh = {
|
|||
{ label: '社区', name: 'Discord' },
|
||||
{ label: '文档', name: 'GitHub README' },
|
||||
{ label: '许可证', name: 'Apache-2.0' },
|
||||
{ label: 'Skill 目录', name: '/skills/' },
|
||||
{ label: '系统目录', name: '/systems/' },
|
||||
{ label: '模板目录', name: '/templates/' },
|
||||
{ label: 'Skill 目录', name: '/plugins/skills/' },
|
||||
{ label: '系统目录', name: '/plugins/systems/' },
|
||||
{ label: '模板目录', name: '/plugins/templates/' },
|
||||
],
|
||||
aliasesTitle: '命名与别名',
|
||||
aliasesLead: '不同工具、受众和语言环境里,这个项目会以几种方式被搜索和书写:',
|
||||
|
|
@ -1027,9 +1027,9 @@ const sourceNames = [
|
|||
'Discord',
|
||||
'GitHub README',
|
||||
'Apache-2.0',
|
||||
'/skills/',
|
||||
'/systems/',
|
||||
'/templates/',
|
||||
'/plugins/skills/',
|
||||
'/plugins/systems/',
|
||||
'/plugins/templates/',
|
||||
] as const;
|
||||
|
||||
const aliasLabels = [
|
||||
|
|
|
|||
|
|
@ -730,23 +730,23 @@ export default function Page({
|
|||
</h2>
|
||||
</div>
|
||||
<div className='pills' data-reveal='right'>
|
||||
<a className='pill active' href={href('/skills/')}>
|
||||
<a className='pill active' href={href('/plugins/skills/')}>
|
||||
{home.labs.pills.all}
|
||||
<span className='count'>{skills}</span>
|
||||
</a>
|
||||
<a className='pill' href={href('/skills/mode/prototype/')}>
|
||||
<a className='pill' href={href('/plugins/templates/')}>
|
||||
{home.labs.pills.prototype}
|
||||
<span className='count'>{prototypeCount}</span>
|
||||
</a>
|
||||
<a className='pill' href={href('/skills/mode/deck/')}>
|
||||
<a className='pill' href={href('/plugins/templates/')}>
|
||||
{home.labs.pills.deck}
|
||||
<span className='count'>{deckCount}</span>
|
||||
</a>
|
||||
<a className='pill' href={href('/skills/')}>
|
||||
<a className='pill' href={href('/plugins/templates/')}>
|
||||
{home.labs.pills.mobile}
|
||||
<span className='count'>{mobileCount}</span>
|
||||
</a>
|
||||
<a className='pill' href={href('/skills/')}>
|
||||
<a className='pill' href={href('/plugins/templates/')}>
|
||||
{home.labs.pills.office}
|
||||
<span className='count'>—</span>
|
||||
</a>
|
||||
|
|
@ -839,7 +839,7 @@ export default function Page({
|
|||
{home.labs.foot(skills)}
|
||||
{NBSP}·{NBSP}
|
||||
<a
|
||||
href={href('/skills/')}
|
||||
href={href('/plugins/skills/')}
|
||||
className='library-link'
|
||||
style={{ color: 'var(--coral)' }}
|
||||
>
|
||||
|
|
@ -953,7 +953,7 @@ export default function Page({
|
|||
{home.work.titleSuffix}
|
||||
<span className='dot'>.</span>
|
||||
</h2>
|
||||
<a className='work-link' href={href('/skills/')}>
|
||||
<a className='work-link' href={href('/plugins/skills/')}>
|
||||
{home.work.viewAll(skills)}
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -1325,17 +1325,17 @@ export default function Page({
|
|||
<h5>{home.footer.columns.library}</h5>
|
||||
<ul>
|
||||
<li>
|
||||
<a href={href('/skills/')}>
|
||||
<a href={href('/plugins/skills/')}>
|
||||
{home.footer.libraryLinks.skills(skills)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={href('/systems/')}>
|
||||
<a href={href('/plugins/systems/')}>
|
||||
{home.footer.libraryLinks.systems(systems)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href={href('/templates/')}>
|
||||
<a href={href('/plugins/templates/')}>
|
||||
{home.footer.libraryLinks.templates}
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -2,17 +2,7 @@
|
|||
import { getCollection } from 'astro:content';
|
||||
import Layout from '../../_components/sub-page-layout.astro';
|
||||
import type { HeaderProps } from '../../_components/header';
|
||||
import LazyImg from '../../_components/lazy-img.astro';
|
||||
import {
|
||||
getCraftRecords,
|
||||
getSkillModeIndex,
|
||||
getSkillRecords,
|
||||
getSkillScenarioIndex,
|
||||
getSystemCategoryIndex,
|
||||
getSystemRecords,
|
||||
getTemplateRecords,
|
||||
tally,
|
||||
} from '../../_lib/catalog';
|
||||
import { getCraftRecords } from '../../_lib/catalog';
|
||||
import {
|
||||
PREFIXED_LOCALES,
|
||||
getCopy,
|
||||
|
|
@ -23,31 +13,17 @@ import {
|
|||
import '../../globals.css';
|
||||
import '../../sub-pages.css';
|
||||
|
||||
// Localized routing only generates listing/index pages. Detail pages
|
||||
// (individual skills, posts, templates, …) stay at canonical English
|
||||
// URLs to keep the static build bounded; the localized chrome links
|
||||
// straight to those canonical detail URLs.
|
||||
// Localized routing only generates the `craft` and `blog` listing pages.
|
||||
// Detail pages (individual posts, craft items, …) stay at canonical
|
||||
// English URLs to keep the static build bounded; the localized chrome
|
||||
// links straight to those canonical detail URLs.
|
||||
export async function getStaticPaths() {
|
||||
const skillModes = await getSkillModeIndex();
|
||||
const skillScenarios = await getSkillScenarioIndex();
|
||||
const systemCategories = await getSystemCategoryIndex();
|
||||
|
||||
const paths = [
|
||||
'skills',
|
||||
'systems',
|
||||
'craft',
|
||||
'templates',
|
||||
'blog',
|
||||
// Plugins library is generated via short-code wrappers under
|
||||
// `app/pages/[locale]/plugins/` (mirroring the `[locale]/skills/`,
|
||||
// `[locale]/systems/`, etc. pattern), so it does NOT participate
|
||||
// in this long-code catch-all. Both surfaces co-exist in `out/`
|
||||
// because `_redirects` maps `/zh-CN/*` → `/zh/*` for the long-form
|
||||
// routes; plugins lives under the short-form path only.
|
||||
...skillModes.map((item) => `skills/mode/${item.slug}`),
|
||||
...skillScenarios.map((item) => `skills/scenario/${item.slug}`),
|
||||
...systemCategories.map((item) => `systems/category/${item.slug}`),
|
||||
];
|
||||
// The skills / systems / templates catalogs moved under `/plugins/*`.
|
||||
// Their old localized listings are now 301'd by `public/_redirects`,
|
||||
// so this catch-all only renders the localized `craft` and `blog`
|
||||
// listings. Plugins itself is generated via short-code wrappers under
|
||||
// `app/pages/[locale]/plugins/`, so it does NOT participate here.
|
||||
const paths = ['craft', 'blog'];
|
||||
|
||||
return PREFIXED_LOCALES.flatMap((locale) =>
|
||||
paths.map((path) => ({
|
||||
|
|
@ -62,34 +38,18 @@ const copy = getCopy(locale);
|
|||
const pathParam = Astro.params.path ?? '';
|
||||
const segments = pathParam.split('/').filter(Boolean);
|
||||
|
||||
const [skills, systems, craft, templates, posts] = await Promise.all([
|
||||
getSkillRecords(),
|
||||
getSystemRecords(),
|
||||
const [craft, posts] = await Promise.all([
|
||||
getCraftRecords(),
|
||||
getTemplateRecords(),
|
||||
getCollection('blog'),
|
||||
]);
|
||||
// All cross-locale subpage links resolve to canonical (English) URLs.
|
||||
const href = (path: string) => path;
|
||||
const titleSuffix = 'Open Design';
|
||||
const routeRoot = segments[0] ?? '';
|
||||
const routeSecond = segments[1] ?? '';
|
||||
const routeThird = segments[2] ?? '';
|
||||
|
||||
const sortedPosts = posts.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
const modeTags = await getSkillModeIndex();
|
||||
const scenarioTags = await getSkillScenarioIndex();
|
||||
const systemCategories = await getSystemCategoryIndex();
|
||||
const platformTally = tally(skills.map((skill) => skill.platform).filter((item): item is string => Boolean(item)));
|
||||
|
||||
const pageTitle = routeRoot === 'skills'
|
||||
? `${copy.skillsTitle} — ${skills.length} | ${titleSuffix}`
|
||||
: routeRoot === 'systems'
|
||||
? `${copy.systemsTitle} — ${systems.length} | ${titleSuffix}`
|
||||
: routeRoot === 'templates'
|
||||
? `${copy.templatesTitle} — ${templates.length} | ${titleSuffix}`
|
||||
: routeRoot === 'craft'
|
||||
const pageTitle = routeRoot === 'craft'
|
||||
? `${copy.craftTitle} — ${craft.length} | ${titleSuffix}`
|
||||
: `${copy.blog} — ${titleSuffix}`;
|
||||
|
||||
|
|
@ -123,61 +83,6 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
|
|||
</>
|
||||
)}
|
||||
|
||||
{routeRoot === 'skills' && (
|
||||
<>
|
||||
<header class='catalog-head'>
|
||||
<span class='label'>{copy.catalog} · Nº 01</span>
|
||||
<h1 class='display'><em>{copy.skillsTitle}</em> — {skills.length} composable design capabilities<span class='dot'>.</span></h1>
|
||||
<p class='lead'>Each skill is a folder with one <code>SKILL.md</code>. Drop it in, restart the daemon, and the picker shows it.</p>
|
||||
</header>
|
||||
{routeSecond === '' && (
|
||||
<section class='filter-strip' aria-label='Skill filters'>
|
||||
<div class='filter-group'>
|
||||
<span class='filter-label'>{copy.mode}</span>
|
||||
<ul>{modeTags.map((tag) => <li><a class='chip chip-link' href={href(`/skills/mode/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
|
||||
</div>
|
||||
<div class='filter-group'>
|
||||
<span class='filter-label'>{copy.scenario}</span>
|
||||
<ul>{scenarioTags.slice(0, 12).map((tag) => <li><a class='chip chip-link' href={href(`/skills/scenario/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul>
|
||||
</div>
|
||||
<div class='filter-group'>
|
||||
<span class='filter-label'>{copy.platform}</span>
|
||||
<ul>{platformTally.map(([key, count]) => <li><span class='chip'>{key}<span class='chip-num'>{count}</span></span></li>)}</ul>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section class='catalog-grid catalog-grid-skills'>
|
||||
<ol>
|
||||
{skills
|
||||
.filter((skill) => routeSecond === 'mode' ? skill.mode === routeThird : routeSecond === 'scenario' ? skill.scenario === routeThird : true)
|
||||
.map((skill, index) => (
|
||||
<li class='catalog-row'>
|
||||
<a href={href(`/skills/${skill.slug}/`)}>
|
||||
<span class='row-index'>{String(index + 1).padStart(2, '0')}</span>
|
||||
<span class='row-body'><span class='row-name'>{skill.name}</span><span class='row-desc'>{skill.description}</span></span>
|
||||
{skill.mode && <span class='meta-tag'>{skill.mode}</span>}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{routeRoot === 'systems' && (
|
||||
<>
|
||||
<header class='catalog-head'>
|
||||
<span class='label'>{copy.catalog} · Nº 02</span>
|
||||
<h1 class='display'><em>{copy.systemsTitle}</em> — {systems.length} portable visual systems<span class='dot'>.</span></h1>
|
||||
<p class='lead'>Each system is a single <code>DESIGN.md</code> token spec that keeps colors, type, spacing, and components consistent.</p>
|
||||
</header>
|
||||
{routeSecond === '' && <section class='filter-strip'><div class='filter-group'><span class='filter-label'>{copy.category}</span><ul>{systemCategories.map((tag) => <li><a class='chip chip-link' href={href(`/systems/category/${tag.slug}/`)}>{tag.label}<span class='chip-num'>{tag.count}</span></a></li>)}</ul></div></section>}
|
||||
<section class='catalog-grid systems-grid'>
|
||||
<ul>{systems.filter((system) => routeSecond === 'category' ? system.category === routeThird : true).map((system) => <li class='system-card'><a href={href(`/systems/${system.slug}/`)}><span class='system-name'>{system.name}</span><p>{system.tagline}</p><span class='meta-tag'>{system.category}</span></a></li>)}</ul>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{routeRoot === 'craft' && (
|
||||
<>
|
||||
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 03</span><h1 class='display'><em>{copy.craftTitle}</em> — {craft.length} rendering principles<span class='dot'>.</span></h1><p class='lead'>Quality rules for accessibility, motion, color, type, and state coverage.</p></header>
|
||||
|
|
@ -185,11 +90,4 @@ const pageDescription = `Open Design ${routeRoot || 'landing'} page.`;
|
|||
</>
|
||||
)}
|
||||
|
||||
{routeRoot === 'templates' && (
|
||||
<>
|
||||
<header class='catalog-head'><span class='label'>{copy.catalog} · Nº 04</span><h1 class='display'><em>{copy.templatesTitle}</em> — {templates.length} ready-to-fork artifacts<span class='dot'>.</span></h1><p class='lead'>Pre-wired artifact bundles with examples, visual language, and agent instructions.</p></header>
|
||||
<section class='template-grid'><ul>{templates.map((template, index) => <li class='template-card'><a href={href(template.detailHref)}>{template.previewUrl && <span class='template-thumb'><LazyImg src={template.previewUrl} alt='' loading={index < 4 ? 'eager' : 'precise'} /></span>}<span class='template-name'>{template.name}</span><p class='template-summary'>{template.summary}</p></a></li>)}</ul></section>
|
||||
</>
|
||||
)}
|
||||
|
||||
</Layout>
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
import SkillPage, {
|
||||
getStaticPaths as getSkillStaticPaths,
|
||||
} from '../../skills/[slug]/index.astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const basePaths = await getSkillStaticPaths();
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
||||
(locale) =>
|
||||
basePaths.map((path) => ({
|
||||
params: { ...path.params, locale: locale.code },
|
||||
props: path.props,
|
||||
})),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<SkillPage {...Astro.props} />
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
import SkillsPage from '../../skills/index.astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
|
||||
(locale) => ({ params: { locale: locale.code } }),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<SkillsPage />
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
import SkillModePage, {
|
||||
getStaticPaths as getSkillModeStaticPaths,
|
||||
} from '../../../skills/mode/[mode].astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const basePaths = await getSkillModeStaticPaths();
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
||||
(locale) =>
|
||||
basePaths.map((path) => ({
|
||||
params: { ...path.params, locale: locale.code },
|
||||
props: path.props,
|
||||
})),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<SkillModePage {...Astro.props} />
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
import SkillScenarioPage, {
|
||||
getStaticPaths as getSkillScenarioStaticPaths,
|
||||
} from '../../../skills/scenario/[scenario].astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const basePaths = await getSkillScenarioStaticPaths();
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
||||
(locale) =>
|
||||
basePaths.map((path) => ({
|
||||
params: { ...path.params, locale: locale.code },
|
||||
props: path.props,
|
||||
})),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<SkillScenarioPage {...Astro.props} />
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
import SystemPage, {
|
||||
getStaticPaths as getSystemStaticPaths,
|
||||
} from '../../systems/[slug].astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const basePaths = await getSystemStaticPaths();
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
||||
(locale) =>
|
||||
basePaths.map((path) => ({
|
||||
params: { ...path.params, locale: locale.code },
|
||||
props: path.props,
|
||||
})),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<SystemPage {...Astro.props} />
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
import SystemCategoryPage, {
|
||||
getStaticPaths as getSystemCategoryStaticPaths,
|
||||
} from '../../../systems/category/[category].astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const basePaths = await getSystemCategoryStaticPaths();
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
||||
(locale) =>
|
||||
basePaths.map((path) => ({
|
||||
params: { ...path.params, locale: locale.code },
|
||||
props: path.props,
|
||||
})),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<SystemCategoryPage {...Astro.props} />
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
import SystemsPage from '../../systems/index.astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
|
||||
(locale) => ({ params: { locale: locale.code } }),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<SystemsPage />
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
---
|
||||
import TemplatePage, {
|
||||
getStaticPaths as getTemplateStaticPaths,
|
||||
} from '../../templates/[slug]/index.astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const basePaths = await getTemplateStaticPaths();
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).flatMap(
|
||||
(locale) =>
|
||||
basePaths.map((path) => ({
|
||||
params: { ...path.params, locale: locale.code },
|
||||
props: path.props,
|
||||
})),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<TemplatePage {...Astro.props} />
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
---
|
||||
import TemplatesPage from '../../templates/index.astro';
|
||||
import { DEFAULT_LOCALE, LANDING_LOCALES } from '../../../i18n';
|
||||
|
||||
export function getStaticPaths() {
|
||||
return LANDING_LOCALES.filter((locale) => locale.code !== DEFAULT_LOCALE).map(
|
||||
(locale) => ({ params: { locale: locale.code } }),
|
||||
);
|
||||
}
|
||||
---
|
||||
|
||||
<TemplatesPage />
|
||||
|
|
@ -207,8 +207,8 @@ const jsonLd = [
|
|||
<h2>{page.nextTitle}</h2>
|
||||
<ul>
|
||||
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
||||
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
||||
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
||||
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
||||
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
||||
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ const bottomCta =
|
|||
? {
|
||||
title: ui.blog.cta.skillsTitle,
|
||||
body: ui.blog.cta.skillsBody,
|
||||
href: '/skills/',
|
||||
href: '/plugins/skills/',
|
||||
label: ui.blog.cta.skillsLabel,
|
||||
external: false,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1058,7 +1058,7 @@ pnpm -F @html-anything/next dev
|
|||
</p>
|
||||
<p>
|
||||
<a class="ha-btn" href={href('/')}>{copy.visitOpenDesign}</a>
|
||||
<a class="ha-btn" href={href('/skills/')} rel="noopener">{copy.browseSkills}</a>
|
||||
<a class="ha-btn" href={href('/plugins/skills/')} rel="noopener">{copy.browseSkills}</a>
|
||||
<a class="ha-btn" href={HA_URL} rel="noopener">{copy.githubLink}</a>
|
||||
</p>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ const sources = [
|
|||
{ ...page.sources[4], href: DISCORD },
|
||||
{ ...page.sources[5], href: DOCS },
|
||||
{ ...page.sources[6], href: REPO_LICENSE },
|
||||
{ ...page.sources[7], href: href('/skills/') },
|
||||
{ ...page.sources[8], href: href('/systems/') },
|
||||
{ ...page.sources[9], href: href('/templates/') },
|
||||
{ ...page.sources[7], href: href('/plugins/skills/') },
|
||||
{ ...page.sources[8], href: href('/plugins/systems/') },
|
||||
{ ...page.sources[9], href: href('/plugins/templates/') },
|
||||
];
|
||||
|
||||
const jsonLd = [
|
||||
|
|
@ -140,8 +140,8 @@ const jsonLd = [
|
|||
<li><a class="inline-link" href={href('/quickstart/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
||||
<li><a class="inline-link" href={href('/agents/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
||||
<li><a class="inline-link" href={href('/alternatives/claude-design/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
||||
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
||||
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
|
||||
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
||||
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[4].label}</a> — {page.nextItems[4].body}</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,7 @@
|
|||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import SystemCard from '../../../_components/system-card.astro';
|
||||
import {
|
||||
getSystemRecords,
|
||||
getSystemCategoryIndex,
|
||||
} from '../../../_lib/catalog';
|
||||
import { getSystemRecords } from '../../../_lib/catalog';
|
||||
import { getPluginsCopy } from '../../../_lib/plugins-i18n';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
|
|
@ -24,7 +21,6 @@ const ui = getLandingUiCopy(locale);
|
|||
const pcopy = getPluginsCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const systems = await getSystemRecords(locale);
|
||||
const categoryTags = await getSystemCategoryIndex(locale);
|
||||
|
||||
const title = `${pcopy.tileSystems} · ${systems.length} · Open Design`;
|
||||
const description = pcopy.systemsLead;
|
||||
|
|
@ -54,21 +50,6 @@ const jsonLd = {
|
|||
<p class="lead">{pcopy.systemsLead}</p>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">{ui.catalog.systems.category}</span>
|
||||
<ul>
|
||||
{categoryTags.map((tag) => (
|
||||
<li>
|
||||
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
|
||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
|
||||
<ul>
|
||||
{systems.map((s) => <SystemCard system={s} />)}
|
||||
|
|
|
|||
|
|
@ -142,8 +142,8 @@ const jsonLd = [
|
|||
<section class="info-section" id="next">
|
||||
<h2>{page.nextTitle}</h2>
|
||||
<ul>
|
||||
<li><a class="inline-link" href={href('/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
||||
<li><a class="inline-link" href={href('/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
||||
<li><a class="inline-link" href={href('/plugins/skills/')}>{page.nextItems[0].label}</a> — {page.nextItems[0].body}</li>
|
||||
<li><a class="inline-link" href={href('/plugins/systems/')}>{page.nextItems[1].label}</a> — {page.nextItems[1].body}</li>
|
||||
<li><a class="inline-link" href={href('/compare/')}>{page.nextItems[2].label}</a> — {page.nextItems[2].body}</li>
|
||||
<li><a class="inline-link" href={REPO_RELEASES} target="_blank" rel="noreferrer noopener">{page.nextItems[3].label}</a> — {page.nextItems[3].body}</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,472 +0,0 @@
|
|||
---
|
||||
/*
|
||||
* /skills/<slug>/ — a detail page per skill.
|
||||
*
|
||||
* Two flavours render slightly differently:
|
||||
* - `template` skills get a click-to-expand iframe of their
|
||||
* `example.html` demo and stay deliberately brief — the demo is the
|
||||
* content, the README is one click away on GitHub.
|
||||
* - `instruction` skills (no runnable demo) instead render the full
|
||||
* SKILL.md body inline, so the page reads like a brief: what the
|
||||
* skill does, when it triggers, how to use it. Otherwise the page
|
||||
* would be a one-line description and a row of CTAs.
|
||||
*/
|
||||
import { getEntry, render } from 'astro:content';
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import LazyImg from '../../../_components/lazy-img.astro';
|
||||
import { getSkillRecords, type SkillRecord } from '../../../_lib/catalog';
|
||||
import {
|
||||
getLandingUiCopy,
|
||||
localeFromPath,
|
||||
localizedHref,
|
||||
type LandingLocaleCode,
|
||||
} from '../../../i18n';
|
||||
|
||||
/*
|
||||
* Localized share-copy template, keyed by landing locale. The brand
|
||||
* keyword "open-source Claude Design alternative" stays in English
|
||||
* because that's the canonical search query Google associates with
|
||||
* the domain — translating it would split the entity claim. The
|
||||
* surrounding sentence ("I'm using X from @opendesignai") translates
|
||||
* per locale so the message reads as one coherent voice instead of
|
||||
* mixing two scripts in a single share post.
|
||||
*
|
||||
* `{name}` and `{description}` are interpolated at render time.
|
||||
* `{url}` is replaced with the canonical detail-page URL.
|
||||
*/
|
||||
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
|
||||
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
|
||||
en: ({ name, description, url }) => `🎨 Just discovered ${name} on @opendesignai — the open-source Claude Design alternative.
|
||||
✨ Local-first · BYOK · your agent does the design.
|
||||
|
||||
→ ${url}`,
|
||||
zh: ({ name, description, url }) => `🎨 安利一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
|
||||
✨ 本地优先 · 自带模型 · 让你自己的 agent 做设计。
|
||||
|
||||
→ ${url}`,
|
||||
'zh-tw': ({ name, description, url }) => `🎨 推薦一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
|
||||
✨ 本地優先 · 自帶模型 · 讓你自己的 agent 做設計。
|
||||
|
||||
→ ${url}`,
|
||||
ja: ({ name, description, url }) => `🎨 @opendesignai で ${name} を発見 —— オープンソースの Claude Design 代替。
|
||||
✨ ローカル優先 · BYOK · あなたのエージェントが設計する。
|
||||
|
||||
→ ${url}`,
|
||||
ko: ({ name, description, url }) => `🎨 @opendesignai에서 ${name} 발견 —— 오픈 소스 Claude Design 대안.
|
||||
✨ 로컬 우선 · BYOK · 에이전트가 디자인합니다.
|
||||
|
||||
→ ${url}`,
|
||||
de: ({ name, description, url }) => `🎨 Gerade entdeckt: ${name} auf @opendesignai — die Open-Source-Alternative zu Claude Design.
|
||||
✨ Local-first · BYOK · dein Agent designt.
|
||||
|
||||
→ ${url}`,
|
||||
fr: ({ name, description, url }) => `🎨 Découvert : ${name} sur @opendesignai — l'alternative open-source à Claude Design.
|
||||
✨ Local-first · BYOK · votre agent fait le design.
|
||||
|
||||
→ ${url}`,
|
||||
ru: ({ name, description, url }) => `🎨 Нашёл ${name} на @opendesignai — open-source альтернативу Claude Design.
|
||||
✨ Локально · BYOK · агент сам делает дизайн.
|
||||
|
||||
→ ${url}`,
|
||||
es: ({ name, description, url }) => `🎨 Acabo de descubrir ${name} en @opendesignai — la alternativa open-source a Claude Design.
|
||||
✨ Local-first · BYOK · tu agente diseña.
|
||||
|
||||
→ ${url}`,
|
||||
'pt-br': ({ name, description, url }) => `🎨 Acabei de descobrir ${name} no @opendesignai — a alternativa open-source ao Claude Design.
|
||||
✨ Local-first · BYOK · seu agente faz o design.
|
||||
|
||||
→ ${url}`,
|
||||
it: ({ name, description, url }) => `🎨 Ho appena scoperto ${name} su @opendesignai — l'alternativa open-source a Claude Design.
|
||||
✨ Local-first · BYOK · il tuo agente progetta.
|
||||
|
||||
→ ${url}`,
|
||||
vi: ({ name, description, url }) => `🎨 Vừa khám phá ${name} trên @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
|
||||
✨ Ưu tiên local · BYOK · agent của bạn thiết kế.
|
||||
|
||||
→ ${url}`,
|
||||
pl: ({ name, description, url }) => `🎨 Właśnie odkryłem ${name} na @opendesignai — open-source'ową alternatywę dla Claude Design.
|
||||
✨ Local-first · BYOK · twój agent projektuje.
|
||||
|
||||
→ ${url}`,
|
||||
id: ({ name, description, url }) => `🎨 Baru nemu ${name} di @opendesignai — alternatif open-source untuk Claude Design.
|
||||
✨ Local-first · BYOK · agent kamu yang nge-desain.
|
||||
|
||||
→ ${url}`,
|
||||
nl: ({ name, description, url }) => `🎨 Net ontdekt: ${name} op @opendesignai — het open-source alternatief voor Claude Design.
|
||||
✨ Local-first · BYOK · jouw agent ontwerpt.
|
||||
|
||||
→ ${url}`,
|
||||
ar: ({ name, description, url }) => `🎨 اكتشفت للتو ${name} على @opendesignai — البديل مفتوح المصدر لـ Claude Design.
|
||||
✨ محلي أولًا · BYOK · وكيلك يصمّم.
|
||||
|
||||
→ ${url}`,
|
||||
tr: ({ name, description, url }) => `🎨 Yeni keşfettim: ${name} (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
|
||||
✨ Local-first · BYOK · ajanın tasarlıyor.
|
||||
|
||||
→ ${url}`,
|
||||
uk: ({ name, description, url }) => `🎨 Щойно знайшов ${name} на @opendesignai — open-source альтернативу Claude Design.
|
||||
✨ Local-first · BYOK · ваш агент робить дизайн.
|
||||
|
||||
→ ${url}`,
|
||||
};
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const skills = await getSkillRecords();
|
||||
return skills.map((skill) => ({
|
||||
params: { slug: skill.slug },
|
||||
props: { skill, all: skills },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
skill: SkillRecord;
|
||||
all: ReadonlyArray<SkillRecord>;
|
||||
}
|
||||
|
||||
const { skill: routeSkill, all: routeAll } = Astro.props as Props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const all = locale === 'en' ? routeAll : await getSkillRecords(locale);
|
||||
const skill = all.find((item) => item.slug === routeSkill.slug) ?? routeSkill;
|
||||
|
||||
const title = ui.catalog.skills.detailTitle(skill.name);
|
||||
const description = skill.description.length > 0
|
||||
? skill.description
|
||||
: ui.catalog.skills.detailFallbackDescription(skill.name);
|
||||
|
||||
const skillUrl = `https://open-design.ai/skills/${skill.slug}/`;
|
||||
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
|
||||
name: skill.name,
|
||||
description,
|
||||
url: skillUrl,
|
||||
});
|
||||
// Share-dialog UI strings localized inline. Keeping them next to the
|
||||
// page that uses them avoids growing the global UI bundle for what's
|
||||
// effectively four short labels per locale.
|
||||
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
|
||||
en: { title: 'Share this skill', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
|
||||
zh: { title: '分享这个 skill', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
|
||||
'zh-tw': { title: '分享這個 skill', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
|
||||
ja: { title: 'この skill を共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
|
||||
ko: { title: '이 skill 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
|
||||
de: { title: 'Diesen Skill teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
|
||||
fr: { title: 'Partager ce skill', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
|
||||
ru: { title: 'Поделиться скиллом', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
|
||||
es: { title: 'Compartir este skill', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
|
||||
'pt-br': { title: 'Compartilhar skill', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
|
||||
it: { title: 'Condividi lo skill', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
|
||||
vi: { title: 'Chia sẻ skill', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
|
||||
pl: { title: 'Udostępnij ten skill', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
|
||||
id: { title: 'Bagikan skill ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
|
||||
nl: { title: 'Deel deze skill', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
|
||||
ar: { title: 'شارك هذه المهارة', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
|
||||
tr: { title: 'Bu skilli paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
|
||||
uk: { title: 'Поділитись скілом', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
|
||||
};
|
||||
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
|
||||
|
||||
const related = all
|
||||
.filter((s) => s.slug !== skill.slug)
|
||||
.filter((s) => s.mode === skill.mode || s.scenario === skill.scenario)
|
||||
.slice(0, 4);
|
||||
|
||||
/*
|
||||
* Instruction skills don't have a runnable demo to iframe — to avoid
|
||||
* a near-empty detail page, render the SKILL.md prose inline so the
|
||||
* page reads like a brief. Template skills keep the page deliberately
|
||||
* brief because their demo is the content; their full SKILL.md is one
|
||||
* "Find on GitHub" click away.
|
||||
*
|
||||
* Astro 6 exposes the markdown pipeline through a top-level
|
||||
* `render(entry)` helper rather than the legacy `entry.render()`
|
||||
* method. The output (heading anchors, smart-typography, GFM
|
||||
* tables) styles cleanly with the existing `.detail-md` rules.
|
||||
*/
|
||||
const skillEntry =
|
||||
skill.kind === 'instruction' ? await getEntry('skills', `${skill.slug}/SKILL`) : null;
|
||||
const SkillBody = skillEntry ? (await render(skillEntry)).Content : null;
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
|
||||
{ '@type': 'ListItem', position: 2, name: ui.catalog.skills.detailLabel, item: new URL('/skills/', Astro.site).toString() },
|
||||
{ '@type': 'ListItem', position: 3, name: skill.name, item: new URL(`/skills/${skill.slug}/`, Astro.site).toString() },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareSourceCode',
|
||||
name: skill.name,
|
||||
description,
|
||||
codeRepository: skill.source,
|
||||
programmingLanguage: 'Markdown',
|
||||
keywords: skill.triggers.join(', '),
|
||||
license: 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">{skill.name}</span>
|
||||
</nav>
|
||||
|
||||
<article class="detail">
|
||||
<header class="detail-head">
|
||||
<span class="label">
|
||||
{ui.catalog.skills.detailLabel}
|
||||
{typeof skill.featured === 'number' && (
|
||||
<span class="ix">{ui.catalog.skills.featuredNumber(String(skill.featured).padStart(2, '0'))}</span>
|
||||
)}
|
||||
</span>
|
||||
<h1 class="display">{skill.name}<span class="dot">.</span></h1>
|
||||
<p class="lead">{description}</p>
|
||||
<div class="detail-actions">
|
||||
{/*
|
||||
Two primary CTAs. "Use this skill" v1 sends users to the OD
|
||||
desktop release page — install the app first, then run the
|
||||
skill. Routing here rather than to /quickstart/ keeps the
|
||||
flow concrete (download a binary now) instead of asking
|
||||
users to read an install doc. Once the desktop client
|
||||
exposes a registered URL scheme, this anchor flips to a
|
||||
JS-driven `od://skill/<slug>` try + fallback without
|
||||
changing the page surface.
|
||||
*/}
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
href="https://github.com/nexu-io/open-design/releases"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Use this skill →
|
||||
</a>
|
||||
<a
|
||||
class="btn btn-ghost"
|
||||
href={skill.source}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Find on GitHub →
|
||||
</a>
|
||||
{skill.upstream && (
|
||||
<a class="btn btn-ghost" href={skill.upstream} target="_blank" rel="noopener">
|
||||
{ui.catalog.skills.upstream}
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost detail-share-trigger"
|
||||
data-share-open={`skill:${skill.slug}`}
|
||||
>
|
||||
{shareUi.openLabel}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{skill.kind === 'template' && skill.previewUrl && (
|
||||
<figure class="detail-preview">
|
||||
{/*
|
||||
Click-to-expand interactive preview. Only template-kind skills
|
||||
ship a runnable example.html, so this block is gated on kind
|
||||
rather than just `previewUrl` — instruction skills now have a
|
||||
synthesized cover thumbnail too, but no iframe target. The
|
||||
thumb is the summary of a `<details>` element: clicking opens
|
||||
the live iframe, replacing the thumb with the canonical
|
||||
`<slug>/example.html` rendered inside a sandboxed frame.
|
||||
*/}
|
||||
<details class="detail-preview-live">
|
||||
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${skill.name}`}>
|
||||
<LazyImg
|
||||
src={skill.previewUrl}
|
||||
alt={`${skill.name} example output`}
|
||||
loading="priority"
|
||||
/>
|
||||
<span class="detail-preview-thumb-overlay" aria-hidden="true">
|
||||
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="detail-preview-frame-wrap">
|
||||
<iframe
|
||||
src={`/skills/${skill.slug}/example.html`}
|
||||
title={`${skill.name} interactive preview`}
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
class="detail-preview-frame"
|
||||
/>
|
||||
<a
|
||||
class="detail-preview-popout"
|
||||
href={`/skills/${skill.slug}/example.html`}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Open preview in new tab"
|
||||
>
|
||||
Open in new tab ↗
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<figcaption>
|
||||
{ui.catalog.skills.previewCaption(skill.slug)}
|
||||
</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
|
||||
{/*
|
||||
Share modal — opens a `<dialog>` containing the canonical share
|
||||
copy (with the brand keyword "open-source Claude Design
|
||||
alternative" baked in), a one-click "Copy" button, and a row of
|
||||
platform jump buttons. Each platform button just opens the
|
||||
vendor's compose URL — the user pastes the already-copied text.
|
||||
This works around a real cross-platform pain point: LinkedIn /
|
||||
Facebook ignore pre-fill `text` params, X has length limits that
|
||||
truncate Chinese content unpredictably, and Reddit's title param
|
||||
survives but title-only is a weak signal. Copy-then-paste is
|
||||
uniformly reliable.
|
||||
|
||||
The trigger sits inside `.detail-actions` instead of as a
|
||||
separate row below `.detail-meta` so it has visual weight equal
|
||||
to the primary CTAs. Joey called this out specifically.
|
||||
*/}
|
||||
<dialog
|
||||
class="detail-share-dialog"
|
||||
data-share-dialog={`skill:${skill.slug}`}
|
||||
>
|
||||
<form method="dialog" class="detail-share-dialog-form">
|
||||
<header class="detail-share-dialog-head">
|
||||
<h2>{shareUi.title}</h2>
|
||||
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
|
||||
</header>
|
||||
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
|
||||
<textarea
|
||||
class="detail-share-dialog-text"
|
||||
readonly
|
||||
rows="6"
|
||||
data-share-text
|
||||
>{shareCopy}</textarea>
|
||||
<div class="detail-share-dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary detail-share-dialog-copy"
|
||||
data-share-copy
|
||||
>
|
||||
{shareUi.copyText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost detail-share-dialog-copy-link"
|
||||
data-copy-link={skillUrl}
|
||||
>
|
||||
{shareUi.copyLink}
|
||||
</button>
|
||||
</div>
|
||||
{/*
|
||||
Platform jump buttons — official brand logos rendered as
|
||||
inline SVG (no third-party icon font, no client JS). Each
|
||||
opens the vendor's compose surface in a new tab; the user
|
||||
pastes the already-copied text. Email channel was dropped
|
||||
per Joey's revision; the four channels here cover the
|
||||
highest-value SEO + virality surfaces.
|
||||
*/}
|
||||
<div class="detail-share-dialog-platforms">
|
||||
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
|
||||
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
|
||||
<span class="sr-only">X</span>
|
||||
</a>
|
||||
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
<span class="sr-only">LinkedIn</span>
|
||||
</a>
|
||||
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
|
||||
<span class="sr-only">Reddit</span>
|
||||
</a>
|
||||
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
<span class="sr-only">Facebook</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dl class="detail-meta">
|
||||
{skill.mode && (
|
||||
<Fragment>
|
||||
<dt>{ui.catalog.skills.mode}</dt>
|
||||
<dd>{skill.modeLabel ?? skill.mode}</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
{skill.scenario && (
|
||||
<Fragment>
|
||||
<dt>{ui.catalog.skills.scenario}</dt>
|
||||
<dd>{skill.scenarioLabel ?? skill.scenario}</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
{skill.platform && (
|
||||
<Fragment>
|
||||
<dt>{ui.catalog.skills.platform}</dt>
|
||||
<dd>{skill.platformLabel ?? skill.platform}</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
{skill.category && (
|
||||
<Fragment>
|
||||
<dt>{ui.catalog.systems.category}</dt>
|
||||
<dd>{skill.categoryLabel ?? skill.category}</dd>
|
||||
</Fragment>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{skill.triggers.length > 0 && (
|
||||
<section class="detail-block">
|
||||
<h2>{ui.catalog.skills.triggers}</h2>
|
||||
<p class="block-lead">
|
||||
{ui.catalog.skills.triggersLead}
|
||||
</p>
|
||||
<ul class="trigger-list">
|
||||
{skill.triggers.map((t) => <li><code>{t}</code></li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{skill.examplePrompt && (
|
||||
<section class="detail-block">
|
||||
<h2>{ui.catalog.skills.examplePrompt}</h2>
|
||||
<pre class="example-prompt">{skill.examplePrompt}</pre>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{SkillBody && (
|
||||
<section class="detail-block detail-md">
|
||||
<h2>About this skill</h2>
|
||||
<SkillBody />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{related.length > 0 && (
|
||||
<section class="detail-block">
|
||||
<h2>{ui.catalog.skills.related}</h2>
|
||||
<ul class="related-grid">
|
||||
{related.map((r) => (
|
||||
<li>
|
||||
<a href={href(`/skills/${r.slug}/`)}>
|
||||
<span class="related-name">{r.name}</span>
|
||||
<span class="related-desc">{r.description}</span>
|
||||
<span class="related-meta">
|
||||
{r.modeLabel && <span class="meta-tag">{r.modeLabel}</span>}
|
||||
{r.scenarioLabel && <span class="meta-tag muted">{r.scenarioLabel}</span>}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
</Layout>
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
---
|
||||
/*
|
||||
* /skills/ — index of every shippable skill in the repo.
|
||||
*
|
||||
* Pulls live data from `skills/<slug>/SKILL.md` via Astro Content
|
||||
* Collections so adding a skill anywhere in the monorepo
|
||||
* automatically surfaces here on the next build.
|
||||
*/
|
||||
import Layout from '../../_components/sub-page-layout.astro';
|
||||
import LazyImg from '../../_components/lazy-img.astro';
|
||||
import SkillRow from '../../_components/skill-row.astro';
|
||||
import {
|
||||
getSkillRecords,
|
||||
getSkillModeIndex,
|
||||
getSkillScenarioIndex,
|
||||
tally,
|
||||
} from '../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
|
||||
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const skills = await getSkillRecords(locale);
|
||||
|
||||
const modeTags = await getSkillModeIndex(locale);
|
||||
const scenarioTags = await getSkillScenarioIndex(locale);
|
||||
const platformTally = tally(
|
||||
skills.map((s) => s.platformLabel).filter((p): p is string => Boolean(p)),
|
||||
);
|
||||
|
||||
const featured = skills.filter((s) => typeof s.featured === 'number').slice(0, 6);
|
||||
|
||||
const title = ui.catalog.skills.title(skills.length);
|
||||
const description = ui.catalog.skills.description;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: new URL('/skills/', Astro.site).toString(),
|
||||
isPartOf: {
|
||||
'@type': 'WebSite',
|
||||
name: 'Open Design',
|
||||
url: Astro.site?.toString(),
|
||||
},
|
||||
numberOfItems: skills.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
|
||||
<header class="catalog-head">
|
||||
<span class="label">{ui.catalog.skills.label}</span>
|
||||
<h1 class="display">
|
||||
{ui.catalog.skills.heading(skills.length)}
|
||||
</h1>
|
||||
<p class="lead">
|
||||
{ui.catalog.skills.lead}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip" aria-label={ui.catalog.skills.allAria}>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">{ui.catalog.skills.mode}</span>
|
||||
<ul>
|
||||
{modeTags.map((tag) => (
|
||||
<li>
|
||||
<a class="chip chip-link" href={href(`/skills/mode/${tag.slug}/`)}>
|
||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">{ui.catalog.skills.scenario}</span>
|
||||
<ul>
|
||||
{scenarioTags.slice(0, 12).map((tag) => (
|
||||
<li>
|
||||
<a class="chip chip-link" href={href(`/skills/scenario/${tag.slug}/`)}>
|
||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{platformTally.length > 0 && (
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">{ui.catalog.skills.platform}</span>
|
||||
<ul>
|
||||
{platformTally.map(([key, count]) => (
|
||||
<li>
|
||||
<span class="chip">
|
||||
{key}<span class="chip-num">{count}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{featured.length > 0 && (
|
||||
<section class="featured-strip" aria-labelledby="featured-skills">
|
||||
<h2 id="featured-skills" class="strip-title">{ui.catalog.skills.featured}</h2>
|
||||
<ul class="featured-grid">
|
||||
{featured.map((s, i) => (
|
||||
<li class="featured-card">
|
||||
<a href={href(`/skills/${s.slug}/`)}>
|
||||
{s.previewUrl ? (
|
||||
<span class="featured-thumb">
|
||||
<LazyImg src={s.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
|
||||
</span>
|
||||
) : (
|
||||
<span class="featured-thumb featured-thumb-empty" aria-hidden="true" />
|
||||
)}
|
||||
<span class="featured-num">Nº {String(s.featured).padStart(2, '0')}</span>
|
||||
<span class="featured-name">{s.name}</span>
|
||||
<p>{s.description}</p>
|
||||
{s.modeLabel && <span class="meta-tag">{s.modeLabel}</span>}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
|
||||
<ol>
|
||||
{skills.map((s, idx) => <SkillRow skill={s} index={idx} />)}
|
||||
</ol>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
---
|
||||
/*
|
||||
* /skills/mode/<slug>/ — every skill that emits a given artifact mode
|
||||
* (deck, prototype, template, image, video, audio, design-system, utility).
|
||||
*
|
||||
* One static page per distinct `od.mode` value. Mode is the strongest
|
||||
* mental-model facet ("I want a deck-builder") so this is the primary
|
||||
* faceted view; scenario/category live alongside.
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import SkillRow from '../../../_components/skill-row.astro';
|
||||
import {
|
||||
getSkillModeIndex,
|
||||
getSkillsForMode,
|
||||
type TagDescriptor,
|
||||
} from '../../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const tags = await getSkillModeIndex();
|
||||
return tags.map((tag) => ({
|
||||
params: { mode: tag.slug },
|
||||
props: { tag },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tag: TagDescriptor;
|
||||
}
|
||||
|
||||
const { tag } = Astro.props as Props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const { records, label } = await getSkillsForMode(tag.slug, locale);
|
||||
const heading = label ?? tag.label;
|
||||
|
||||
const title = ui.catalog.skills.filterTitle(heading, records.length);
|
||||
const description = ui.catalog.skills.modeDescription(heading, records.length);
|
||||
|
||||
const url = new URL(`/skills/mode/${tag.slug}/`, Astro.site).toString();
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
numberOfItems: records.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
|
||||
<header class="catalog-head">
|
||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
||||
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{ui.catalog.skills.mode}</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span class="crumb-active">{heading}</span>
|
||||
</nav>
|
||||
<span class="label">{ui.catalog.skills.label}</span>
|
||||
<h1 class="display">
|
||||
{ui.catalog.skills.modeHeading(heading, records.length)}
|
||||
</h1>
|
||||
<p class="lead">
|
||||
{ui.catalog.skills.modeLead(label ?? tag.label)}
|
||||
</p>
|
||||
<p class="filter-clear">
|
||||
<a href={href('/skills/')}>{ui.catalog.skills.allSkills(tag.count)}</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
|
||||
<ol>
|
||||
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
|
||||
</ol>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
---
|
||||
/*
|
||||
* /skills/scenario/<slug>/ — every skill targeting a given use-case
|
||||
* scenario (marketing, engineering, design, research, ...).
|
||||
*
|
||||
* Mirrors the mode page but facets on `od.scenario`. One page per
|
||||
* distinct scenario value found across all SKILL.md files.
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import SkillRow from '../../../_components/skill-row.astro';
|
||||
import {
|
||||
getSkillScenarioIndex,
|
||||
getSkillsForScenario,
|
||||
type TagDescriptor,
|
||||
} from '../../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const tags = await getSkillScenarioIndex();
|
||||
return tags.map((tag) => ({
|
||||
params: { scenario: tag.slug },
|
||||
props: { tag },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tag: TagDescriptor;
|
||||
}
|
||||
|
||||
const { tag } = Astro.props as Props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const { records, label } = await getSkillsForScenario(tag.slug, locale);
|
||||
const heading = label ?? tag.label;
|
||||
|
||||
const title = ui.catalog.skills.filterTitle(heading, records.length);
|
||||
const description = ui.catalog.skills.scenarioDescription(heading, records.length);
|
||||
|
||||
const url = new URL(`/skills/scenario/${tag.slug}/`, Astro.site).toString();
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
numberOfItems: records.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="skills" jsonLd={jsonLd}>
|
||||
<header class="catalog-head">
|
||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
||||
<a href={href('/skills/')}>{ui.catalog.skills.detailLabel}</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{ui.catalog.skills.scenario}</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span class="crumb-active">{heading}</span>
|
||||
</nav>
|
||||
<span class="label">{ui.catalog.skills.label}</span>
|
||||
<h1 class="display">
|
||||
{ui.catalog.skills.scenarioHeading(heading, records.length)}
|
||||
</h1>
|
||||
<p class="lead">
|
||||
{ui.catalog.skills.scenarioLead(label ?? tag.label)}
|
||||
</p>
|
||||
<p class="filter-clear">
|
||||
<a href={href('/skills/')}>{ui.catalog.skills.allSkills()}</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="catalog-grid catalog-grid-skills" aria-label={ui.catalog.skills.allAria}>
|
||||
<ol>
|
||||
{records.map((s, idx) => <SkillRow skill={s} index={idx} />)}
|
||||
</ol>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
---
|
||||
import Layout from '../../_components/sub-page-layout.astro';
|
||||
import { getSystemRecords, type SystemRecord } from '../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const systems = await getSystemRecords();
|
||||
return systems.map((system) => ({
|
||||
params: { slug: system.slug },
|
||||
props: { system, all: systems },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
system: SystemRecord;
|
||||
all: ReadonlyArray<SystemRecord>;
|
||||
}
|
||||
|
||||
const { system: routeSystem, all: routeAll } = Astro.props as Props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const all = locale === 'en' ? routeAll : await getSystemRecords(locale);
|
||||
const system = all.find((item) => item.slug === routeSystem.slug) ?? routeSystem;
|
||||
|
||||
const title = ui.catalog.systems.detailTitle(system.name);
|
||||
const description = system.tagline
|
||||
? `${system.name} (${system.categoryLabel}) — ${system.tagline}`
|
||||
: ui.catalog.systems.detailFallbackDescription(system.name, system.categoryLabel);
|
||||
|
||||
const related = all
|
||||
.filter((s) => s.slug !== system.slug && s.category === system.category)
|
||||
.slice(0, 4);
|
||||
|
||||
const jsonLd = [
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
|
||||
{ '@type': 'ListItem', position: 2, name: ui.catalog.systems.detailLabel, item: new URL('/systems/', Astro.site).toString() },
|
||||
{ '@type': 'ListItem', position: 3, name: system.name, item: new URL(`/systems/${system.slug}/`, Astro.site).toString() },
|
||||
],
|
||||
},
|
||||
{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CreativeWork',
|
||||
name: system.name,
|
||||
description,
|
||||
url: new URL(`/systems/${system.slug}/`, Astro.site).toString(),
|
||||
license: 'https://www.apache.org/licenses/LICENSE-2.0',
|
||||
genre: system.categoryLabel,
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">{system.name}</span>
|
||||
</nav>
|
||||
|
||||
<article class="detail">
|
||||
<header class="detail-head">
|
||||
<span class="label">
|
||||
{ui.catalog.systems.detailLabel}
|
||||
<span class="ix">· {system.categoryLabel}</span>
|
||||
</span>
|
||||
<h1 class="display">{system.name}<span class="dot">.</span></h1>
|
||||
{system.tagline && <p class="lead">{system.tagline}</p>}
|
||||
<div class="detail-actions">
|
||||
<a class="btn btn-primary" href={system.source} target="_blank" rel="noopener">
|
||||
{ui.catalog.systems.viewOnGithub}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{system.palette.length > 0 && (
|
||||
<section class="detail-block">
|
||||
<h2>{ui.catalog.systems.paletteSample}</h2>
|
||||
<p class="block-lead">
|
||||
{ui.catalog.systems.paletteLead(system.palette.length)}
|
||||
</p>
|
||||
<div class="palette-row">
|
||||
{system.palette.map((hex) => (
|
||||
<div class="palette-cell">
|
||||
<span class="swatch" style={`background:${hex}`} />
|
||||
<code>{hex}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{system.atmosphere && (
|
||||
<section class="detail-block">
|
||||
<h2>{ui.catalog.systems.visualTheme}</h2>
|
||||
<p class="atmosphere">{system.atmosphere}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{related.length > 0 && (
|
||||
<section class="detail-block">
|
||||
<h2>{ui.catalog.systems.related(system.categoryLabel)}</h2>
|
||||
<ul class="related-grid">
|
||||
{related.map((r) => (
|
||||
<li>
|
||||
<a href={href(`/systems/${r.slug}/`)}>
|
||||
<span class="related-name">{r.name}</span>
|
||||
<span class="related-desc">{r.tagline}</span>
|
||||
<div class="system-swatches" aria-hidden="true">
|
||||
{r.palette.slice(0, 4).map((hex) => (
|
||||
<span class="swatch" style={`background:${hex}`} />
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
</Layout>
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
---
|
||||
/*
|
||||
* /systems/category/<slug>/ — every design system grouped by category
|
||||
* (AI & LLM, Productivity & SaaS, Editorial, Brand, ...).
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import SystemCard from '../../../_components/system-card.astro';
|
||||
import {
|
||||
getSystemCategoryIndex,
|
||||
getSystemsForCategory,
|
||||
type TagDescriptor,
|
||||
} from '../../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../../i18n';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const tags = await getSystemCategoryIndex();
|
||||
return tags.map((tag) => ({
|
||||
params: { category: tag.slug },
|
||||
props: { tag },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
tag: TagDescriptor;
|
||||
}
|
||||
|
||||
const { tag } = Astro.props as Props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const { records, label } = await getSystemsForCategory(tag.slug, locale);
|
||||
const heading = label ?? tag.label;
|
||||
|
||||
const title = ui.catalog.systems.categoryHeading(heading, records.length);
|
||||
const description = ui.catalog.systems.categoryDescription(heading, records.length);
|
||||
|
||||
const url = new URL(`/systems/category/${tag.slug}/`, Astro.site).toString();
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url,
|
||||
numberOfItems: records.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
|
||||
<header class="catalog-head">
|
||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
||||
<a href={href('/systems/')}>{ui.catalog.systems.detailLabel}</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span>{ui.catalog.systems.category}</span>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span class="crumb-active">{heading}</span>
|
||||
</nav>
|
||||
<span class="label">{ui.catalog.systems.label}</span>
|
||||
<h1 class="display">
|
||||
{ui.catalog.systems.categoryHeading(heading, records.length)}
|
||||
</h1>
|
||||
<p class="lead">
|
||||
{ui.catalog.systems.categoryLead(label ?? tag.label)}
|
||||
</p>
|
||||
<p class="filter-clear">
|
||||
<a href={href('/systems/')}>{ui.catalog.systems.allSystems}</a>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
|
||||
<ul>
|
||||
{records.map((s) => <SystemCard system={s} />)}
|
||||
</ul>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
---
|
||||
/*
|
||||
* /systems/ — index of every portable design system in the repo.
|
||||
*/
|
||||
import Layout from '../../_components/sub-page-layout.astro';
|
||||
import SystemCard from '../../_components/system-card.astro';
|
||||
import { getSystemRecords, getSystemCategoryIndex } from '../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
|
||||
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const systems = await getSystemRecords(locale);
|
||||
|
||||
const categoryTags = await getSystemCategoryIndex(locale);
|
||||
|
||||
const title = ui.catalog.systems.title(systems.length);
|
||||
const description = ui.catalog.systems.description;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: new URL('/systems/', Astro.site).toString(),
|
||||
numberOfItems: systems.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="systems" jsonLd={jsonLd}>
|
||||
<header class="catalog-head">
|
||||
<span class="label">{ui.catalog.systems.label}</span>
|
||||
<h1 class="display">
|
||||
{ui.catalog.systems.heading(systems.length)}
|
||||
</h1>
|
||||
<p class="lead">
|
||||
{ui.catalog.systems.lead}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip" aria-label={ui.catalog.systems.allAria}>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">{ui.catalog.systems.category}</span>
|
||||
<ul>
|
||||
{categoryTags.map((tag) => (
|
||||
<li>
|
||||
<a class="chip chip-link" href={href(`/systems/category/${tag.slug}/`)}>
|
||||
{tag.label}<span class="chip-num">{tag.count}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="catalog-grid systems-grid" aria-label={ui.catalog.systems.allAria}>
|
||||
<ul>
|
||||
{systems.map((s) => <SystemCard system={s} />)}
|
||||
</ul>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -1,356 +0,0 @@
|
|||
---
|
||||
/*
|
||||
* /templates/<slug>/ — detail page for renderable design templates and
|
||||
* legacy Live Artifact template bundles.
|
||||
*/
|
||||
import Layout from '../../../_components/sub-page-layout.astro';
|
||||
import LazyImg from '../../../_components/lazy-img.astro';
|
||||
import { getTemplateRecords, type TemplateRecord } from '../../../_lib/catalog';
|
||||
import {
|
||||
getLandingUiCopy,
|
||||
localeFromPath,
|
||||
localizedHref,
|
||||
type LandingLocaleCode,
|
||||
} from '../../../i18n';
|
||||
|
||||
/* See pages/skills/[slug]/index.astro for the rationale on why these
|
||||
* tables live inline rather than in the global UI bundle. Same shape,
|
||||
* just keyed for the templates surface. */
|
||||
type ShareTemplate = (vars: { name: string; description: string; url: string }) => string;
|
||||
const SHARE_COPY: Record<LandingLocaleCode, ShareTemplate> = {
|
||||
en: ({ name, description, url }) => `🎨 Just forked ${name} from @opendesignai — the open-source Claude Design alternative.
|
||||
✨ Templates as files, not vendor docs. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
zh: ({ name, description, url }) => `🎨 fork 了一个:@opendesignai 上的 ${name} —— Claude Design 的开源替代品。
|
||||
✨ 模板就是文件,不是 vendor 数据。Fork → 换数据 → 发。
|
||||
|
||||
→ ${url}`,
|
||||
'zh-tw': ({ name, description, url }) => `🎨 fork 了一個:@opendesignai 上的 ${name} —— Claude Design 的開源替代品。
|
||||
✨ 模板就是檔案,不是 vendor 資料。Fork → 換資料 → 發佈。
|
||||
|
||||
→ ${url}`,
|
||||
ja: ({ name, description, url }) => `🎨 @opendesignai の ${name} を fork —— オープンソースの Claude Design 代替。
|
||||
✨ テンプレートはファイル、ベンダー DB じゃない。Fork → 差し替え → 出荷。
|
||||
|
||||
→ ${url}`,
|
||||
ko: ({ name, description, url }) => `🎨 @opendesignai의 ${name} fork —— 오픈 소스 Claude Design 대안.
|
||||
✨ 템플릿은 파일, 벤더 DB가 아닙니다. Fork → 교체 → 출시.
|
||||
|
||||
→ ${url}`,
|
||||
de: ({ name, description, url }) => `🎨 Gerade ${name} von @opendesignai geforkt — die Open-Source-Alternative zu Claude Design.
|
||||
✨ Vorlagen als Dateien, nicht als Vendor-DB. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
fr: ({ name, description, url }) => `🎨 Je viens de forker ${name} de @opendesignai — l'alternative open-source à Claude Design.
|
||||
✨ Modèles = fichiers, pas une base vendeur. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
ru: ({ name, description, url }) => `🎨 Форкнул ${name} с @opendesignai — open-source альтернативу Claude Design.
|
||||
✨ Шаблоны — это файлы, не vendor-DB. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
es: ({ name, description, url }) => `🎨 Acabo de hacer fork de ${name} en @opendesignai — la alternativa open-source a Claude Design.
|
||||
✨ Plantillas como archivos, no como vendor DB. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
'pt-br': ({ name, description, url }) => `🎨 Acabei de dar fork em ${name} do @opendesignai — a alternativa open-source ao Claude Design.
|
||||
✨ Templates como arquivos, não como vendor DB. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
it: ({ name, description, url }) => `🎨 Ho appena forkato ${name} da @opendesignai — l'alternativa open-source a Claude Design.
|
||||
✨ Template come file, non come DB vendor. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
vi: ({ name, description, url }) => `🎨 Vừa fork ${name} từ @opendesignai — giải pháp mã nguồn mở thay thế Claude Design.
|
||||
✨ Template là file, không phải DB của vendor. Fork → đổi data → ship.
|
||||
|
||||
→ ${url}`,
|
||||
pl: ({ name, description, url }) => `🎨 Właśnie sforkowałem ${name} z @opendesignai — open-source'ową alternatywę dla Claude Design.
|
||||
✨ Szablony jako pliki, nie vendor DB. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
id: ({ name, description, url }) => `🎨 Baru fork ${name} dari @opendesignai — alternatif open-source untuk Claude Design.
|
||||
✨ Template itu file, bukan vendor DB. Fork → tukar data → ship.
|
||||
|
||||
→ ${url}`,
|
||||
nl: ({ name, description, url }) => `🎨 Net ${name} geforkt van @opendesignai — het open-source alternatief voor Claude Design.
|
||||
✨ Templates als bestanden, niet als vendor-DB. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
ar: ({ name, description, url }) => `🎨 fork للتو ${name} من @opendesignai — البديل مفتوح المصدر لـ Claude Design.
|
||||
✨ القوالب ملفات، ليست قاعدة بيانات للمزوّد. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
tr: ({ name, description, url }) => `🎨 ${name} fork'ladım (@opendesignai) — Claude Design'a açık kaynaklı alternatif.
|
||||
✨ Şablonlar dosya, vendor DB değil. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
uk: ({ name, description, url }) => `🎨 Форкнув ${name} з @opendesignai — open-source альтернативу Claude Design.
|
||||
✨ Шаблони — це файли, а не vendor-DB. Fork → swap → ship.
|
||||
|
||||
→ ${url}`,
|
||||
};
|
||||
const SHARE_UI: Record<LandingLocaleCode, { title: string; lead: string; copyText: string; copyLink: string; jumpTo: string; openLabel: string }> = {
|
||||
en: { title: 'Share this template', lead: 'Copy the message below, then jump to the platform you want to share on and paste.', copyText: 'Copy text', copyLink: 'Copy link only', jumpTo: 'Then jump to:', openLabel: 'Share ↗' },
|
||||
zh: { title: '分享这个模板', lead: '复制下面的文案,然后跳到你想分享的平台粘贴即可。', copyText: '复制文案', copyLink: '只复制链接', jumpTo: '跳转到:', openLabel: '分享 ↗' },
|
||||
'zh-tw': { title: '分享這個模板', lead: '複製下面的文案,然後跳到你想分享的平台貼上即可。', copyText: '複製文案', copyLink: '只複製連結', jumpTo: '跳轉到:', openLabel: '分享 ↗' },
|
||||
ja: { title: 'このテンプレートを共有', lead: '下のメッセージをコピーしてから、共有したいプラットフォームに移動して貼り付けてください。', copyText: 'テキストをコピー', copyLink: 'リンクのみコピー', jumpTo: 'プラットフォームへ:', openLabel: '共有 ↗' },
|
||||
ko: { title: '이 템플릿 공유', lead: '아래 메시지를 복사한 다음 공유할 플랫폼으로 이동해 붙여넣으세요.', copyText: '텍스트 복사', copyLink: '링크만 복사', jumpTo: '플랫폼으로:', openLabel: '공유 ↗' },
|
||||
de: { title: 'Diese Vorlage teilen', lead: 'Kopiere die Nachricht unten und füge sie auf der gewünschten Plattform ein.', copyText: 'Text kopieren', copyLink: 'Nur Link kopieren', jumpTo: 'Zur Plattform:', openLabel: 'Teilen ↗' },
|
||||
fr: { title: 'Partager ce modèle', lead: 'Copiez le message ci-dessous, puis ouvrez la plateforme de votre choix et collez.', copyText: 'Copier le texte', copyLink: 'Copier le lien', jumpTo: 'Aller sur :', openLabel: 'Partager ↗' },
|
||||
ru: { title: 'Поделиться шаблоном', lead: 'Скопируйте сообщение ниже, затем перейдите на нужную платформу и вставьте.', copyText: 'Скопировать текст', copyLink: 'Только ссылка', jumpTo: 'Перейти:', openLabel: 'Поделиться ↗' },
|
||||
es: { title: 'Compartir plantilla', lead: 'Copia el mensaje y abre la plataforma donde quieras compartirlo.', copyText: 'Copiar texto', copyLink: 'Solo el enlace', jumpTo: 'Ir a:', openLabel: 'Compartir ↗' },
|
||||
'pt-br': { title: 'Compartilhar template', lead: 'Copie a mensagem e abra a plataforma onde quer compartilhar.', copyText: 'Copiar texto', copyLink: 'Só o link', jumpTo: 'Ir para:', openLabel: 'Compartilhar ↗' },
|
||||
it: { title: 'Condividi il modello', lead: 'Copia il messaggio e apri la piattaforma su cui vuoi condividere.', copyText: 'Copia testo', copyLink: 'Solo il link', jumpTo: 'Vai a:', openLabel: 'Condividi ↗' },
|
||||
vi: { title: 'Chia sẻ template', lead: 'Sao chép nội dung dưới đây, rồi mở nền tảng bạn muốn chia sẻ và dán vào.', copyText: 'Sao chép', copyLink: 'Chỉ sao chép link', jumpTo: 'Mở:', openLabel: 'Chia sẻ ↗' },
|
||||
pl: { title: 'Udostępnij szablon', lead: 'Skopiuj wiadomość poniżej, otwórz wybraną platformę i wklej.', copyText: 'Kopiuj tekst', copyLink: 'Skopiuj link', jumpTo: 'Przejdź do:', openLabel: 'Udostępnij ↗' },
|
||||
id: { title: 'Bagikan template ini', lead: 'Salin pesan di bawah, lalu buka platform yang ingin Anda gunakan dan tempel.', copyText: 'Salin teks', copyLink: 'Salin tautan', jumpTo: 'Buka:', openLabel: 'Bagikan ↗' },
|
||||
nl: { title: 'Deel deze template', lead: 'Kopieer het bericht hieronder en plak het op het platform van jouw keuze.', copyText: 'Tekst kopiëren', copyLink: 'Alleen de link', jumpTo: 'Ga naar:', openLabel: 'Delen ↗' },
|
||||
ar: { title: 'شارك هذا القالب', lead: 'انسخ الرسالة أدناه، ثم انتقل إلى المنصة التي تريد المشاركة عليها والصقها.', copyText: 'انسخ النص', copyLink: 'انسخ الرابط فقط', jumpTo: 'انتقل إلى:', openLabel: 'مشاركة ↗' },
|
||||
tr: { title: 'Bu şablonu paylaş', lead: 'Aşağıdaki mesajı kopyala, dilediğin platformu açıp yapıştır.', copyText: 'Metni kopyala', copyLink: 'Sadece linki kopyala', jumpTo: 'Şuraya git:', openLabel: 'Paylaş ↗' },
|
||||
uk: { title: 'Поділитись шаблоном', lead: 'Скопіюйте повідомлення нижче, потім перейдіть на платформу й вставте.', copyText: 'Копіювати текст', copyLink: 'Тільки посилання', jumpTo: 'Перейти:', openLabel: 'Поділитись ↗' },
|
||||
};
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const records = await getTemplateRecords();
|
||||
return records.map((template) => ({
|
||||
params: { slug: template.slug },
|
||||
props: { template },
|
||||
}));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
template: TemplateRecord;
|
||||
}
|
||||
|
||||
const { template: routeTemplate } = Astro.props as Props;
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const localizedTemplates = locale === 'en' ? [] : await getTemplateRecords(locale);
|
||||
const template =
|
||||
localizedTemplates.find((item) => item.slug === routeTemplate.slug) ?? routeTemplate;
|
||||
|
||||
const title = ui.catalog.templates.detailTitle(template.name);
|
||||
const description = template.summary;
|
||||
|
||||
const templateUrl = `https://open-design.ai/templates/${template.slug}/`;
|
||||
const shareCopy = (SHARE_COPY[locale] ?? SHARE_COPY.en)({
|
||||
name: template.name,
|
||||
description: template.summary,
|
||||
url: templateUrl,
|
||||
});
|
||||
const shareUi = SHARE_UI[locale] ?? SHARE_UI.en;
|
||||
const originLabel =
|
||||
template.origin === 'live-artifact'
|
||||
? ui.catalog.templates.liveArtifact
|
||||
: ui.catalog.templates.skillTemplate;
|
||||
const files =
|
||||
template.origin === 'live-artifact'
|
||||
? [
|
||||
['template.html', ui.catalog.templates.renderer],
|
||||
['data.json', ui.catalog.templates.seedData],
|
||||
['README.md', ui.catalog.templates.readme],
|
||||
]
|
||||
: [
|
||||
['SKILL.md', ui.catalog.skills.detailLabel],
|
||||
['example.html', ui.catalog.templates.previewCaption],
|
||||
['assets/', ui.catalog.templates.detailLabel],
|
||||
['references/', ui.catalog.craft.detailLabel],
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Open Design', item: Astro.site?.toString() },
|
||||
{ '@type': 'ListItem', position: 2, name: ui.catalog.templates.detailLabel, item: new URL('/templates/', Astro.site).toString() },
|
||||
{ '@type': 'ListItem', position: 3, name: template.name, item: new URL(template.detailHref, Astro.site).toString() },
|
||||
],
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
|
||||
<nav class="breadcrumb" aria-label={ui.catalog.breadcrumbLabel}>
|
||||
<a href={href('/')}>Open Design</a>
|
||||
<span>/</span>
|
||||
<a href={href('/templates/')}>{ui.catalog.templates.detailLabel}</a>
|
||||
<span>/</span>
|
||||
<span aria-current="page">{template.name}</span>
|
||||
</nav>
|
||||
|
||||
<article class="detail">
|
||||
<header class="detail-head">
|
||||
<span class="label">
|
||||
{ui.catalog.templates.detailLabel}
|
||||
<span class="ix">· {originLabel}</span>
|
||||
</span>
|
||||
<h1 class="display">{template.name}<span class="dot">.</span></h1>
|
||||
<p class="lead">{template.summary}</p>
|
||||
{(template.mode || template.platform || template.scenario) && (
|
||||
<dl class="detail-meta">
|
||||
{template.mode && (
|
||||
<>
|
||||
<dt>{ui.catalog.skills.mode}</dt>
|
||||
<dd>{template.modeLabel ?? template.mode}</dd>
|
||||
</>
|
||||
)}
|
||||
{template.platform && (
|
||||
<>
|
||||
<dt>{ui.catalog.skills.platform}</dt>
|
||||
<dd>{template.platformLabel ?? template.platform}</dd>
|
||||
</>
|
||||
)}
|
||||
{template.scenario && (
|
||||
<>
|
||||
<dt>{ui.catalog.skills.scenario}</dt>
|
||||
<dd>{template.scenarioLabel ?? template.scenario}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
<div class="detail-actions">
|
||||
{/* Two CTAs matching skills/[slug]: "Use this template" sends
|
||||
users to the OD desktop release page (install first, then
|
||||
use the template); "Find on GitHub" deep-links to the
|
||||
source folder. See skills/[slug].astro for the broader
|
||||
rationale on the release-page pivot. */}
|
||||
<a
|
||||
class="btn btn-primary"
|
||||
href="https://github.com/nexu-io/open-design/releases"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Use this template →
|
||||
</a>
|
||||
<a class="btn btn-ghost" href={template.source} target="_blank" rel="noopener">
|
||||
Find on GitHub →
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost detail-share-trigger"
|
||||
data-share-open={`template:${template.slug}`}
|
||||
>
|
||||
{shareUi.openLabel}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{template.previewUrl && (
|
||||
<figure class="detail-preview">
|
||||
{/* Click-to-expand: thumb is the summary; clicking opens the
|
||||
live iframe rendering the canonical artifact. Skill-template
|
||||
origin → /skills/<slug>/example.html; live-artifact origin
|
||||
→ /templates/<slug>/preview.html. */}
|
||||
<details class="detail-preview-live">
|
||||
<summary class="detail-preview-thumb-trigger" aria-label={`Open interactive preview for ${template.name}`}>
|
||||
<LazyImg
|
||||
src={template.previewUrl}
|
||||
alt={`${template.name} preview`}
|
||||
loading="priority"
|
||||
/>
|
||||
<span class="detail-preview-thumb-overlay" aria-hidden="true">
|
||||
<span class="detail-preview-thumb-cta">Click for live preview ↗</span>
|
||||
</span>
|
||||
</summary>
|
||||
<div class="detail-preview-frame-wrap">
|
||||
<iframe
|
||||
src={
|
||||
template.origin === 'live-artifact'
|
||||
? `/templates/${template.slug}/preview.html`
|
||||
: `/skills/${template.slug}/example.html`
|
||||
}
|
||||
title={`${template.name} interactive preview`}
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
class="detail-preview-frame"
|
||||
/>
|
||||
<a
|
||||
class="detail-preview-popout"
|
||||
href={
|
||||
template.origin === 'live-artifact'
|
||||
? `/templates/${template.slug}/preview.html`
|
||||
: `/skills/${template.slug}/example.html`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
aria-label="Open preview in new tab"
|
||||
>
|
||||
Open in new tab ↗
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<figcaption>{ui.catalog.templates.previewCaption}</figcaption>
|
||||
</figure>
|
||||
)}
|
||||
|
||||
{/* Share modal — same shape as skills/[slug]; see that file for the
|
||||
copy-then-paste rationale and SEO keyword choice. */}
|
||||
<dialog
|
||||
class="detail-share-dialog"
|
||||
data-share-dialog={`template:${template.slug}`}
|
||||
>
|
||||
<form method="dialog" class="detail-share-dialog-form">
|
||||
<header class="detail-share-dialog-head">
|
||||
<h2>{shareUi.title}</h2>
|
||||
<button type="submit" class="detail-share-dialog-close" aria-label="Close" value="cancel">×</button>
|
||||
</header>
|
||||
<p class="detail-share-dialog-lead">{shareUi.lead}</p>
|
||||
<textarea
|
||||
class="detail-share-dialog-text"
|
||||
readonly
|
||||
rows="6"
|
||||
data-share-text
|
||||
>{shareCopy}</textarea>
|
||||
<div class="detail-share-dialog-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary detail-share-dialog-copy"
|
||||
data-share-copy
|
||||
>
|
||||
{shareUi.copyText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost detail-share-dialog-copy-link"
|
||||
data-copy-link={templateUrl}
|
||||
>
|
||||
{shareUi.copyLink}
|
||||
</button>
|
||||
</div>
|
||||
<div class="detail-share-dialog-platforms">
|
||||
<span class="detail-share-dialog-platforms-label">{shareUi.jumpTo}</span>
|
||||
<a class="detail-share-platform-btn" href="https://x.com/compose/post" target="_blank" rel="noopener" aria-label="X">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25h6.815l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117L17.083 19.77z"/></svg>
|
||||
<span class="sr-only">X</span>
|
||||
</a>
|
||||
<a class="detail-share-platform-btn" href="https://www.linkedin.com/feed/?shareActive=true" target="_blank" rel="noopener" aria-label="LinkedIn">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
|
||||
<span class="sr-only">LinkedIn</span>
|
||||
</a>
|
||||
<a class="detail-share-platform-btn" href="https://www.reddit.com/submit" target="_blank" rel="noopener" aria-label="Reddit">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 01-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 01.042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 014.028 12.3c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 01.14-.197.35.35 0 01.238-.042l2.906.617a1.214 1.214 0 011.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 00-.231.094.33.33 0 000 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 00.029-.463.33.33 0 00-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 00-.232-.095z"/></svg>
|
||||
<span class="sr-only">Reddit</span>
|
||||
</a>
|
||||
<a class="detail-share-platform-btn" href="https://www.facebook.com/" target="_blank" rel="noopener" aria-label="Facebook">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true" fill="currentColor"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
|
||||
<span class="sr-only">Facebook</span>
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<section class="detail-block">
|
||||
<h2>{ui.catalog.templates.whatsInside}</h2>
|
||||
<p class="block-lead">
|
||||
{ui.catalog.templates.whatsInsideLead}
|
||||
</p>
|
||||
<ul class="trigger-list">
|
||||
{files.map(([name, copy]) => (
|
||||
<li><code>{name}</code> — {copy}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</Layout>
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
---
|
||||
import Layout from '../../_components/sub-page-layout.astro';
|
||||
import LazyImg from '../../_components/lazy-img.astro';
|
||||
import { getTemplateRecords } from '../../_lib/catalog';
|
||||
import { getLandingUiCopy, localeFromPath, localizedHref } from '../../i18n';
|
||||
|
||||
const locale = localeFromPath(Astro.url.pathname);
|
||||
const ui = getLandingUiCopy(locale);
|
||||
const href = (path: string) => localizedHref(path, locale);
|
||||
const templates = await getTemplateRecords(locale);
|
||||
|
||||
const title = ui.catalog.templates.title(templates.length);
|
||||
const description = ui.catalog.templates.description;
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: title,
|
||||
description,
|
||||
url: new URL('/templates/', Astro.site).toString(),
|
||||
numberOfItems: templates.length,
|
||||
};
|
||||
---
|
||||
|
||||
<Layout title={title} description={description} active="templates" jsonLd={jsonLd}>
|
||||
<header class="catalog-head">
|
||||
<span class="label">{ui.catalog.templates.label}</span>
|
||||
<h1 class="display">
|
||||
{ui.catalog.templates.heading(templates.length)}
|
||||
</h1>
|
||||
<p class="lead">
|
||||
{ui.catalog.templates.lead}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section class="template-grid" aria-label={ui.catalog.templates.allAria}>
|
||||
<ul>
|
||||
{templates.map((t, i) => (
|
||||
<li class="template-card">
|
||||
<a href={href(t.detailHref)}>
|
||||
{t.previewUrl ? (
|
||||
<span class="template-thumb">
|
||||
<LazyImg src={t.previewUrl} alt="" loading={i < 4 ? 'eager' : 'precise'} />
|
||||
</span>
|
||||
) : (
|
||||
<span class="template-thumb template-thumb-empty" aria-hidden="true" />
|
||||
)}
|
||||
<span class={`meta-tag ${t.origin === 'live-artifact' ? 'coral' : ''}`}>
|
||||
{t.origin === 'live-artifact' ? ui.catalog.templates.liveArtifact : (t.modeLabel ?? ui.catalog.templates.skillTemplate)}
|
||||
</span>
|
||||
<span class="template-name">{t.name}</span>
|
||||
<p class="template-summary">{t.summary}</p>
|
||||
{(t.platform || t.scenario) && (
|
||||
<span class="template-meta-line">
|
||||
{[t.platformLabel ?? t.platform, t.scenarioLabel ?? t.scenario].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</Layout>
|
||||
|
|
@ -257,11 +257,11 @@ export default defineConfig({
|
|||
item.priority = 0.9;
|
||||
item.changefreq = changefreq.weekly;
|
||||
} else if (
|
||||
path === '/skills/' ||
|
||||
path === '/systems/' ||
|
||||
path === '/templates/' ||
|
||||
path === '/craft/' ||
|
||||
path === '/plugins/'
|
||||
path === '/plugins/' ||
|
||||
path === '/plugins/skills/' ||
|
||||
path === '/plugins/systems/' ||
|
||||
path === '/plugins/templates/'
|
||||
) {
|
||||
item.priority = 0.7;
|
||||
item.changefreq = changefreq.weekly;
|
||||
|
|
|
|||
|
|
@ -34,3 +34,85 @@
|
|||
/fa/plugins/* /plugins/:splat 301
|
||||
/hu/plugins/* /plugins/:splat 301
|
||||
/th/plugins/* /plugins/:splat 301
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Catalog migration: legacy /skills /systems /templates -> /plugins/*
|
||||
# The old Astro generators were removed; these 301s preserve inbound
|
||||
# links and SEO equity. Cloudflare matches first rule wins, so order is:
|
||||
# faceted/specific -> detail prefixes -> bare index -> locale variants.
|
||||
# trailingSlash:'always', so every source and target ends in '/'.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Faceted pages have no new equivalent -> degrade to the section landing.
|
||||
/skills/mode/* /plugins/skills/ 301
|
||||
/skills/scenario/* /plugins/skills/ 301
|
||||
/systems/category/* /plugins/systems/ 301
|
||||
|
||||
# Systems detail: design-system-<folder> is the uniform new slug.
|
||||
# These 8 folders have no new detail page -> degrade (must precede splat).
|
||||
/systems/cisco/ /plugins/systems/ 301
|
||||
/systems/hud/ /plugins/systems/ 301
|
||||
/systems/loom/ /plugins/systems/ 301
|
||||
/systems/perplexity/ /plugins/systems/ 301
|
||||
/systems/slack/ /plugins/systems/ 301
|
||||
/systems/trading-terminal/ /plugins/systems/ 301
|
||||
/systems/webex/ /plugins/systems/ 301
|
||||
/systems/wechat/ /plugins/systems/ 301
|
||||
/systems/* /plugins/design-system-:splat 301
|
||||
|
||||
# Templates detail: example-<folder> is the uniform new slug.
|
||||
/templates/live-otd-operations-brief/ /plugins/templates/ 301
|
||||
/templates/* /plugins/example-:splat 301
|
||||
|
||||
# Skills detail: only these 27 have a new artifact-template equivalent.
|
||||
# 'replicate' collides with design-system-replicate -> force the section.
|
||||
/skills/replicate/ /plugins/skills/ 301
|
||||
/skills/article-magazine/ /plugins/example-article-magazine/ 301
|
||||
/skills/card-twitter/ /plugins/example-card-twitter/ 301
|
||||
/skills/card-xiaohongshu/ /plugins/example-card-xiaohongshu/ 301
|
||||
/skills/data-report/ /plugins/example-data-report/ 301
|
||||
/skills/deck-guizang-editorial/ /plugins/example-deck-guizang-editorial/ 301
|
||||
/skills/deck-open-slide-canvas/ /plugins/example-deck-open-slide-canvas/ 301
|
||||
/skills/deck-swiss-international/ /plugins/example-deck-swiss-international/ 301
|
||||
/skills/design-brief/ /plugins/example-design-brief/ 301
|
||||
/skills/doc-kami-parchment/ /plugins/example-doc-kami-parchment/ 301
|
||||
/skills/frame-data-chart-nyt/ /plugins/example-frame-data-chart-nyt/ 301
|
||||
/skills/frame-flowchart-sticky/ /plugins/example-frame-flowchart-sticky/ 301
|
||||
/skills/frame-glitch-title/ /plugins/example-frame-glitch-title/ 301
|
||||
/skills/frame-light-leak-cinema/ /plugins/example-frame-light-leak-cinema/ 301
|
||||
/skills/frame-liquid-bg-hero/ /plugins/example-frame-liquid-bg-hero/ 301
|
||||
/skills/frame-logo-outro/ /plugins/example-frame-logo-outro/ 301
|
||||
/skills/frame-macos-notification/ /plugins/example-frame-macos-notification/ 301
|
||||
/skills/hatch-pet/ /plugins/example-hatch-pet/ 301
|
||||
/skills/mockup-device-3d/ /plugins/example-mockup-device-3d/ 301
|
||||
/skills/poster-hero/ /plugins/example-poster-hero/ 301
|
||||
/skills/ppt-keynote/ /plugins/example-ppt-keynote/ 301
|
||||
/skills/pptx-html-fidelity-audit/ /plugins/example-pptx-html-fidelity-audit/ 301
|
||||
/skills/resume-modern/ /plugins/example-resume-modern/ 301
|
||||
/skills/social-reddit-card/ /plugins/example-social-reddit-card/ 301
|
||||
/skills/social-spotify-card/ /plugins/example-social-spotify-card/ 301
|
||||
/skills/social-x-post-card/ /plugins/example-social-x-post-card/ 301
|
||||
/skills/vfx-text-cursor/ /plugins/example-vfx-text-cursor/ 301
|
||||
/skills/video-hyperframes/ /plugins/example-video-hyperframes/ 301
|
||||
# Remaining ~110 instruction-only skills have no detail page -> section.
|
||||
/skills/* /plugins/skills/ 301
|
||||
|
||||
# Bare catalog index pages (least specific -> last).
|
||||
/skills/ /plugins/skills/ 301
|
||||
/systems/ /plugins/systems/ 301
|
||||
/templates/ /plugins/templates/ 301
|
||||
|
||||
# Locale-prefixed variants (active LANDING_LOCALES minus en: zh zh-tw ja ko).
|
||||
# Non-en pages are sitemap-excluded; degrade to the section (no detail precision).
|
||||
/zh/skills/* /zh/plugins/skills/ 301
|
||||
/zh/systems/* /zh/plugins/systems/ 301
|
||||
/zh/templates/* /zh/plugins/templates/ 301
|
||||
/zh-tw/skills/* /zh-tw/plugins/skills/ 301
|
||||
/zh-tw/systems/* /zh-tw/plugins/systems/ 301
|
||||
/zh-tw/templates/* /zh-tw/plugins/templates/ 301
|
||||
/ja/skills/* /ja/plugins/skills/ 301
|
||||
/ja/systems/* /ja/plugins/systems/ 301
|
||||
/ja/templates/* /ja/plugins/templates/ 301
|
||||
/ko/skills/* /ko/plugins/skills/ 301
|
||||
/ko/systems/* /ko/plugins/systems/ 301
|
||||
/ko/templates/* /ko/plugins/templates/ 301
|
||||
|
|
|
|||
|
|
@ -47,6 +47,11 @@ import {
|
|||
type InlineMentionEntity,
|
||||
} from '../utils/inlineMentions';
|
||||
import { isImeComposing } from '../utils/imeComposing';
|
||||
import {
|
||||
reconcileInsertions,
|
||||
stripPluginInsertedTokens,
|
||||
type TrackedInsertion,
|
||||
} from '../utils/pluginInsertionTracking';
|
||||
import { ANNOTATION_EVENT, type AnnotationEventDetail } from "./PreviewDrawOverlay";
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
|
|
@ -224,7 +229,23 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
const [draft, setDraft] = useState(() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "");
|
||||
const [draft, setDraft] = useState(
|
||||
() => initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
|
||||
);
|
||||
// Synchronous mirror of the latest committed draft value.
|
||||
// `updateDraft` reads this as `prev` instead of relying on the
|
||||
// closure `draft` (which only updates after re-render) or
|
||||
// `setDraft((prev) => …)` (whose updater is double-invoked
|
||||
// under React StrictMode and would mutate
|
||||
// `pluginInsertedTokensRef` twice). The ref is updated
|
||||
// synchronously by `updateDraft` before `setDraft`, so the
|
||||
// next call sees a fresh `prev` even when React batches
|
||||
// multiple updates within one tick. Initialized from the same
|
||||
// source as the React state to keep the two in lockstep on
|
||||
// first render. See `updateDraft` below and #2929 round 5.
|
||||
const draftRef = useRef<string>(
|
||||
initialDraft ?? loadComposerDraft(draftStorageKey) ?? "",
|
||||
);
|
||||
|
||||
// chat_panel page_view fires from ProjectView (which outlives
|
||||
// conversation switches) so the event measures real chat-panel
|
||||
|
|
@ -271,6 +292,77 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
// or from the tools-menu "Details" affordance.
|
||||
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
|
||||
const pluginsSectionRef = useRef<PluginsSectionHandle | null>(null);
|
||||
// Instance-aware tracking for `@<token>` mentions this surface
|
||||
// inserted into the draft via the @-mention popover plugin-pick
|
||||
// path (`insertPluginMention`). Each entry pins the precise
|
||||
// start offset of `@`, so two `@Airbnb` mentions in the same
|
||||
// draft (one composer-inserted, one user-authored) are
|
||||
// distinguishable — the chip-clear strip removes only tracked
|
||||
// instances (#2929 round 3). See utils/pluginInsertionTracking.ts
|
||||
// for the diff/reconcile/strip primitives.
|
||||
//
|
||||
// Lifecycle invariants:
|
||||
// - add: `insertPluginMention` pushes { token, start } using the
|
||||
// `insertStart` returned by `replaceMentionWithText`
|
||||
// - reconcile: `handleChange` runs LCP/LCS diff on each
|
||||
// keystroke and shifts/drops entries whose offsets crossed
|
||||
// the edit, plus revalidates surviving entries against the
|
||||
// mention boundary so `@Airbnbify`-style corruption prunes
|
||||
// - clear: `reset()` empties the array on send; `onCleared`
|
||||
// strips by range and empties the array
|
||||
//
|
||||
// Tools-menu / details-modal applies route through
|
||||
// `pluginsSectionRef.current.applyById` without writing to the
|
||||
// draft, so the array stays empty for those surfaces and the
|
||||
// post-clear strip is a no-op. Every draft mutation in this
|
||||
// component goes through the `updateDraft` chokepoint, which
|
||||
// runs `reconcileInsertions` against the prev → next diff. That
|
||||
// includes typing, slash-command pick, file/MCP/connector
|
||||
// insertion, skill chip remove, annotation append, imperative
|
||||
// handle, post-send reset, and the on-cleared strip itself —
|
||||
// so a tracked offset can never go stale relative to the draft
|
||||
// and re-introduce the original #2881 orphan-mention symptom
|
||||
// (#2929 round 4).
|
||||
//
|
||||
// Each entry carries the `pluginId` of the apply that produced
|
||||
// it. When the active plugin changes (e.g. tools-menu `applyById`
|
||||
// replaces plugin A with plugin B without writing to the draft),
|
||||
// entries for the previous active plugin are dropped via
|
||||
// `setActivePlugin`. Without that, clearing B's chip would still
|
||||
// strip A's `@A` from the draft — silent user-text deletion in a
|
||||
// supported replace-plugin flow (#2929 round 6).
|
||||
const pluginInsertedTokensRef = useRef<TrackedInsertion[]>([]);
|
||||
// The plugin id whose chip is currently mounted in PluginsSection's
|
||||
// chip strip, or `null` after the strip clears or before any apply
|
||||
// succeeds. Updated via `setActivePlugin`, which also drops any
|
||||
// tracked entries whose `pluginId` does not match the new active
|
||||
// — a no-op for `insertPluginMention` (the new entry it just
|
||||
// pushed matches), critical for tools-menu / details-modal
|
||||
// applies that arrive without an accompanying draft insertion.
|
||||
const activePluginIdRef = useRef<string | null>(null);
|
||||
// Monotonic counter that hands out unique `insertionId` strings to
|
||||
// entries pushed by `insertPluginMention`. The id survives
|
||||
// `reconcileInsertions` (utils/pluginInsertionTracking.ts forwards
|
||||
// the field) so the in-flight handler's failure path can locate
|
||||
// its own tracked entry even after intervening reconciles or
|
||||
// `onCleared` mutations of the array (#2929 round 10 codex
|
||||
// review). Plain ref counter is enough — the id only needs to be
|
||||
// unique within a single composer instance and is never persisted.
|
||||
const insertionIdSeqRef = useRef(0);
|
||||
|
||||
// Single chokepoint for setting the active plugin. Routes every
|
||||
// `applyById` call so the tracker stays in lockstep with the
|
||||
// chip strip's currently-mounted plugin.
|
||||
function setActivePlugin(pluginId: string | null): void {
|
||||
if (activePluginIdRef.current === pluginId) return;
|
||||
if (pluginInsertedTokensRef.current.length > 0) {
|
||||
pluginInsertedTokensRef.current =
|
||||
pluginInsertedTokensRef.current.filter(
|
||||
(entry) => entry.pluginId === pluginId,
|
||||
);
|
||||
}
|
||||
activePluginIdRef.current = pluginId;
|
||||
}
|
||||
// Consolidated "tools" popover — a single dropdown anchored to the
|
||||
// leading sliders icon that hosts MCP / Import / Pet quick actions and
|
||||
// a shortcut to open the full Settings dialog. Replaces the previous
|
||||
|
|
@ -299,7 +391,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
useEffect(() => {
|
||||
if (seededRef.current) return;
|
||||
if (initialDraft && initialDraft !== draft) {
|
||||
setDraft(initialDraft);
|
||||
updateDraft(initialDraft);
|
||||
seededRef.current = true;
|
||||
} else if (initialDraft === undefined) {
|
||||
seededRef.current = true;
|
||||
|
|
@ -614,7 +706,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
// command's canonical insertion text.
|
||||
const replaced = before.replace(/\/[^\s/]*$/, cmd.insert);
|
||||
const next = replaced + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setSlash(null);
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
|
|
@ -658,7 +750,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const trimmed = draft.trim();
|
||||
if (!/^\/mcp\s*$/i.test(trimmed)) return false;
|
||||
onOpenMcpSettings();
|
||||
setDraft('');
|
||||
updateDraft('');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -724,7 +816,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
return false;
|
||||
}
|
||||
}
|
||||
setDraft('');
|
||||
updateDraft('');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -732,7 +824,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
ref,
|
||||
() => ({
|
||||
setDraft: (text: string) => {
|
||||
setDraft(text);
|
||||
updateDraft(text);
|
||||
seededRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
|
|
@ -743,7 +835,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
});
|
||||
},
|
||||
restoreDraft: ({ text, attachments = [], commentAttachments = [] }) => {
|
||||
setDraft(text);
|
||||
updateDraft(text);
|
||||
setStaged(attachments);
|
||||
setStagedVisualComments(commentAttachments);
|
||||
setStagedSkills([]);
|
||||
|
|
@ -768,8 +860,39 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
[]
|
||||
);
|
||||
|
||||
// Single chokepoint for every draft mutation. Reconciles the
|
||||
// tracked plugin-mention offsets against the prev → next diff so
|
||||
// any setDraft path — typing, slash command, file/MCP/connector
|
||||
// insertion, skill chip removal, annotation append, imperative
|
||||
// handle, post-send reset, on-cleared strip — keeps
|
||||
// `pluginInsertedTokensRef` in lockstep with the draft.
|
||||
//
|
||||
// Implementation note (#2929 round 5): the reconcile and the
|
||||
// ref mutation happen *outside* the `setDraft` updater, using
|
||||
// the synchronous `draftRef` mirror as `prev`. Putting them
|
||||
// inside `setDraft((prev) => …)` would not be safe under
|
||||
// React StrictMode, which double-invokes setState updaters in
|
||||
// development to detect impurity — the second invocation
|
||||
// would re-shift or re-drop already-reconciled entries,
|
||||
// bringing back the #2881 orphan-mention symptom for every
|
||||
// user keystroke in the dev build.
|
||||
function updateDraft(next: string | ((prev: string) => string)): void {
|
||||
const prev = draftRef.current;
|
||||
const value = typeof next === 'function' ? next(prev) : next;
|
||||
if (prev === value) return;
|
||||
if (pluginInsertedTokensRef.current.length > 0) {
|
||||
pluginInsertedTokensRef.current = reconcileInsertions(
|
||||
pluginInsertedTokensRef.current,
|
||||
prev,
|
||||
value,
|
||||
);
|
||||
}
|
||||
draftRef.current = value;
|
||||
setDraft(value);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setDraft("");
|
||||
updateDraft("");
|
||||
setStaged([]);
|
||||
setStagedVisualComments([]);
|
||||
setStagedSkills([]);
|
||||
|
|
@ -778,6 +901,14 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
setUploadError(null);
|
||||
setMention(null);
|
||||
setSlash(null);
|
||||
// Drop tracked plugin-mention insertions when the draft is wiped
|
||||
// — otherwise a later chip clear would prune user-authored text
|
||||
// that happened to share a label with a previously-applied
|
||||
// plugin (#2929 round 2/3). Also clear the active-plugin id
|
||||
// so the next applyById is treated as a fresh activation
|
||||
// rather than a "same plugin re-apply" (#2929 round 6).
|
||||
pluginInsertedTokensRef.current = [];
|
||||
activePluginIdRef.current = null;
|
||||
}
|
||||
|
||||
function currentCommentAttachments(extra: ChatCommentAttachment[] = []): ChatCommentAttachment[] {
|
||||
|
|
@ -829,7 +960,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
// Also strip the matching `@<id>` token from the draft so the chip
|
||||
// and the textarea stay in sync. We allow trailing whitespace to be
|
||||
// collapsed too.
|
||||
setDraft((d) =>
|
||||
updateDraft((d) =>
|
||||
d
|
||||
.replace(new RegExp(`(^|\\s)@${escapeRegExp(id)}(\\s|$)`, 'g'), '$1$2')
|
||||
.replace(/\s{2,}/g, ' '),
|
||||
|
|
@ -1001,7 +1132,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
}),
|
||||
]);
|
||||
}
|
||||
if (detail.note) setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
|
||||
if (detail.note) updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
|
||||
setStreamingAnnotationSendPending(true);
|
||||
textareaRef.current?.focus();
|
||||
ack({ ok: true });
|
||||
|
|
@ -1022,7 +1153,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
}
|
||||
|
||||
if (detail.note) {
|
||||
setDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
|
||||
updateDraft((d) => (d ? `${d}\n${detail.note}` : detail.note));
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
ack({ ok: true });
|
||||
|
|
@ -1118,7 +1249,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
const value = e.target.value;
|
||||
const cursor = e.target.selectionStart;
|
||||
setDraft(value);
|
||||
// Goes through the `updateDraft` chokepoint so the
|
||||
// plugin-mention offset reconcile runs on every keystroke,
|
||||
// matching every other setDraft path for free.
|
||||
updateDraft(value);
|
||||
// Keep the staged-skill chips in sync with the draft. If the user
|
||||
// hand-deletes an `@<id>` token from the textarea, the chip must
|
||||
// disappear too — otherwise submit() would still forward that id in
|
||||
|
|
@ -1165,7 +1299,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const after = draft.slice(cursor);
|
||||
const replaced = before.replace(/@([^\s@]*)$/, `@${filePath} `);
|
||||
const next = replaced + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setMention(null);
|
||||
if (!staged.some((s) => s.path === filePath)) {
|
||||
setStaged((s) => [
|
||||
|
|
@ -1185,28 +1319,175 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
}
|
||||
|
||||
async function insertPluginMention(record: InstalledPluginRecord) {
|
||||
const inserted = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
|
||||
if (!inserted) return;
|
||||
await pluginsSectionRef.current?.applyById(record.id, record);
|
||||
// Snapshot tracker AND draft state before any mutation so we
|
||||
// can roll back if `applyById` fails (#2929 round 7). Without
|
||||
// this, an `/apply` 5xx leaves the draft holding a freshly
|
||||
// inserted `@<token>` whose chip never mounted — a user
|
||||
// clearing the previously-active plugin's chip would then
|
||||
// strip the user-visible `@<token>` they just picked, even
|
||||
// though that text is the only signal they have that
|
||||
// anything happened.
|
||||
const prevDraftValue = draftRef.current;
|
||||
const prevEntries = pluginInsertedTokensRef.current;
|
||||
const prevActiveId = activePluginIdRef.current;
|
||||
|
||||
const result = replaceMentionWithText(`${inlineMentionToken(record.title)} `);
|
||||
if (!result) return;
|
||||
// Capture the post-insert draft *snapshot* — the value the
|
||||
// composer is in immediately after our optimistic write.
|
||||
// Used as a sentinel during the rollback below: if the
|
||||
// textarea is still in this state when `applyById` fails
|
||||
// (no user keystrokes during the await), we can fully
|
||||
// restore `prevDraftValue`. If the user typed during the
|
||||
// await, the draft has moved past the snapshot and we MUST
|
||||
// NOT clobber those edits with the stale `prevDraftValue`
|
||||
// (#2929 round 8 — the textarea stays interactive while
|
||||
// `/apply` is in flight, so this is a real prompt-data-loss
|
||||
// path).
|
||||
const postInsertDraft = draftRef.current;
|
||||
// Track the precise start offset of the inserted `@` so the
|
||||
// post-clear strip can excise exactly this instance, leaving
|
||||
// any user-authored `@<sameLabel>` elsewhere in the draft
|
||||
// untouched (#2929 round 3). Entry carries `pluginId` so a
|
||||
// later replace-plugin flow can drop it cleanly (#2929 round 6),
|
||||
// and an `insertionId` so this handler's failure path can
|
||||
// locate the entry it pushed even after `reconcileInsertions`
|
||||
// shifted offsets or `onCleared` mutated the array
|
||||
// (#2929 round 10).
|
||||
//
|
||||
// Push the new entry but DO NOT yet drop entries from the
|
||||
// previously-active plugin — that filter is committed only
|
||||
// after `applyById` resolves successfully (#2929 round 9
|
||||
// codex review). During the await, the chip strip still
|
||||
// shows the previously-mounted plugin and the textarea is
|
||||
// interactive: a user click on that chip's × must strip its
|
||||
// tracked entries (not the optimistic `@<target>` we just
|
||||
// pushed). `onCleared` filters by
|
||||
// `pluginsSectionRef.current?.getActiveRecord()?.id` so a
|
||||
// pending-window clear scopes to the actually-mounted
|
||||
// plugin's tracked tokens.
|
||||
const ourInsertionId = `i${++insertionIdSeqRef.current}`;
|
||||
pluginInsertedTokensRef.current = [
|
||||
...pluginInsertedTokensRef.current,
|
||||
{
|
||||
token: record.title,
|
||||
start: result.insertStart,
|
||||
pluginId: record.id,
|
||||
insertionId: ourInsertionId,
|
||||
},
|
||||
];
|
||||
|
||||
const applyResult = await pluginsSectionRef.current?.applyById(
|
||||
record.id,
|
||||
record,
|
||||
);
|
||||
if (!applyResult) {
|
||||
// Two failure modes to disambiguate (#2929 round 10):
|
||||
//
|
||||
// (a) "no intervening clear" — the user neither cleared
|
||||
// the previously-mounted chip nor anything else
|
||||
// mutated the tracker beyond our push + reconciles
|
||||
// from user keystrokes. `prevEntries` and
|
||||
// `prevActiveId` are still the truth. We restore the
|
||||
// tracker wholesale and restore the draft only if
|
||||
// the user did not type during the await
|
||||
// (round 7/8 path).
|
||||
//
|
||||
// (b) "intervening clear" — `onCleared` ran during the
|
||||
// await for the previously-mounted chip, stripped
|
||||
// its tokens from the draft, and nulled
|
||||
// `activePluginIdRef`. Restoring `prevEntries`
|
||||
// wholesale here would resurrect already-stripped
|
||||
// entries with stale offsets, AND leave our
|
||||
// optimistic `@<target>` orphaned in the draft (the
|
||||
// original #2881 symptom recurring inside the
|
||||
// failure window). Instead we surgically remove ONLY
|
||||
// our own optimistic entry by `insertionId`, strip
|
||||
// its `@<target>` from the draft, and leave
|
||||
// everything `onCleared` did intact.
|
||||
//
|
||||
// Detection: `onCleared` always nulls
|
||||
// `activePluginIdRef.current`; our deferred
|
||||
// `setActivePlugin` never ran (we are in the failure
|
||||
// branch). So `activePluginIdRef.current === null` while
|
||||
// `prevActiveId !== null` is the smoking gun for an
|
||||
// intervening clear. (If `prevActiveId` was already null,
|
||||
// there was no chip to clear — no race possible.)
|
||||
const intervenedClear =
|
||||
activePluginIdRef.current === null && prevActiveId !== null;
|
||||
if (intervenedClear) {
|
||||
const cur = pluginInsertedTokensRef.current;
|
||||
const idx = cur.findIndex(
|
||||
(e) => e.insertionId === ourInsertionId,
|
||||
);
|
||||
if (idx >= 0) {
|
||||
const ourEntry = cur[idx]!;
|
||||
// Splice our entry out first so `updateDraft`'s
|
||||
// internal `reconcileInsertions` operates on a tracker
|
||||
// that already excludes it (the strip range overlaps
|
||||
// the entry, which would drop it anyway, but splicing
|
||||
// first keeps the invariant explicit and avoids
|
||||
// depending on the reconcile drop edge case).
|
||||
pluginInsertedTokensRef.current = [
|
||||
...cur.slice(0, idx),
|
||||
...cur.slice(idx + 1),
|
||||
];
|
||||
updateDraft((d) => stripPluginInsertedTokens(d, [ourEntry]));
|
||||
}
|
||||
// Don't touch `activePluginIdRef` — `onCleared` set it
|
||||
// to null and that is the truth (no chip is mounted).
|
||||
return;
|
||||
}
|
||||
// (a) round 7/8 path: no intervening clear.
|
||||
pluginInsertedTokensRef.current = prevEntries;
|
||||
activePluginIdRef.current = prevActiveId;
|
||||
// Restore the draft only if no user keystrokes arrived
|
||||
// during the await — overwriting newer edits with the
|
||||
// stale pre-pick snapshot would be a worse bug than the
|
||||
// leftover `@<token>` styled mention this branch leaves
|
||||
// behind. The orphan stays as a styled mention but no
|
||||
// future chip clear will touch it (tracker is empty for
|
||||
// it now), and the user can edit it manually
|
||||
// (#2929 round 8).
|
||||
if (draftRef.current === postInsertDraft) {
|
||||
setDraft(prevDraftValue);
|
||||
draftRef.current = prevDraftValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Apply succeeded. Now commit the active-plugin switch —
|
||||
// this drops any entries from the previously-active plugin
|
||||
// (a no-op for the entry we just pushed since it matches
|
||||
// `record.id`) and updates `activePluginIdRef`. Deferring
|
||||
// until after the await means an `onCleared` triggered
|
||||
// during the in-flight window saw the still-mounted plugin
|
||||
// as the active one and stripped only that plugin's tokens
|
||||
// (#2929 round 9).
|
||||
setActivePlugin(record.id);
|
||||
}
|
||||
|
||||
function replaceMentionWithText(text: string): boolean {
|
||||
if (!mention) return false;
|
||||
function replaceMentionWithText(
|
||||
text: string,
|
||||
): { insertStart: number } | null {
|
||||
if (!mention) return null;
|
||||
const ta = textareaRef.current;
|
||||
const cursor = mention.cursor;
|
||||
const before = draft.slice(0, cursor);
|
||||
const after = draft.slice(cursor);
|
||||
const replaced = before.replace(/(^|\s)@([^\s@]*)$/, `$1${text}`);
|
||||
const next = replaced + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setMention(null);
|
||||
// The inserted text was appended onto `replaced`, so its first
|
||||
// char (the `@`) sits at `replaced.length - text.length`.
|
||||
const insertStart = replaced.length - text.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
const pos = replaced.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
return true;
|
||||
return { insertStart };
|
||||
}
|
||||
|
||||
function insertMcpMention(server: McpServerConfig) {
|
||||
|
|
@ -1234,7 +1515,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
function removeStaged(p: string) {
|
||||
setStaged((s) => s.filter((a) => a.path !== p));
|
||||
setStagedVisualComments((current) => current.filter((attachment) => attachment.screenshotPath !== p));
|
||||
setDraft((current) => stripInlineMentionToken(current, p));
|
||||
updateDraft((current) => stripInlineMentionToken(current, p));
|
||||
}
|
||||
|
||||
function removeCommentAttachment(id: string) {
|
||||
|
|
@ -1473,12 +1754,73 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
showRail={false}
|
||||
onApplied={(brief) => {
|
||||
// Use functional setState so stale closures from the @-mention
|
||||
// flow (which awaits applyById after setDraft) still see the
|
||||
// latest draft value before deciding whether to seed.
|
||||
// flow (which awaits applyById after updateDraft) still see
|
||||
// the latest draft value before deciding whether to seed.
|
||||
if (typeof brief === 'string' && brief.length > 0) {
|
||||
setDraft((cur) => (cur.trim().length === 0 ? brief : cur));
|
||||
updateDraft((cur) => (cur.trim().length === 0 ? brief : cur));
|
||||
}
|
||||
}}
|
||||
onCleared={() => {
|
||||
// Removing the chip strip must drop the `@…` tokens
|
||||
// this surface authored, otherwise the textarea is
|
||||
// left holding orphaned mentions whose chips just
|
||||
// unmounted (#2881). We strip *only* the tracked
|
||||
// insertions (by precise start offset) so
|
||||
// user-authored text that happens to share a label
|
||||
// with a chip is preserved (#2929 round 3).
|
||||
//
|
||||
// The chip strip can clear while an `applyById` for
|
||||
// a *different* plugin is mid-await — the @-popover
|
||||
// optimistically writes `@<target>` and pushes a
|
||||
// tracked entry synchronously, then awaits the
|
||||
// apply (#2929 round 9 codex review). During that
|
||||
// window the ref carries entries for both the
|
||||
// still-mounted plugin (the chip the user is
|
||||
// removing) and the in-flight target. Trusting the
|
||||
// ref wholesale here would strip the optimistic
|
||||
// `@<target>` and leave the unmounting plugin's
|
||||
// `@<token>` orphaned — a recurrence of #2881 in a
|
||||
// pending-apply window.
|
||||
//
|
||||
// PluginsSection only flips `activeRecord` after
|
||||
// `applyPlugin` resolves successfully (see
|
||||
// `PluginsSection.tsx`), so `getActiveRecord()` at
|
||||
// the moment `onCleared` fires reports the plugin
|
||||
// whose chip is currently being unmounted — exactly
|
||||
// the one whose tracked entries we should strip.
|
||||
// Filter to that id; entries for any in-flight
|
||||
// replace target are left in place (the in-flight
|
||||
// handler's success path will commit
|
||||
// `setActivePlugin(target)` and drop them; its
|
||||
// failure path will roll the tracker back).
|
||||
const unmountingId =
|
||||
pluginsSectionRef.current?.getActiveRecord()?.id ?? null;
|
||||
const entries = pluginInsertedTokensRef.current;
|
||||
if (entries.length > 0) {
|
||||
const toStrip = unmountingId
|
||||
? entries.filter((e) => e.pluginId === unmountingId)
|
||||
: entries;
|
||||
if (toStrip.length > 0) {
|
||||
// `updateDraft` runs `reconcileInsertions`
|
||||
// against the prev → next diff inside the
|
||||
// chokepoint, so any in-flight target's entries
|
||||
// get their offsets shifted to track the
|
||||
// post-strip draft. We must re-read the ref
|
||||
// *after* `updateDraft` returns instead of
|
||||
// filtering the pre-strip `entries` snapshot,
|
||||
// otherwise we would clobber the reconciled
|
||||
// offsets and a later clear of the in-flight
|
||||
// chip would no-op via `isInsertionStillValid`.
|
||||
updateDraft((d) => stripPluginInsertedTokens(d, toStrip));
|
||||
}
|
||||
pluginInsertedTokensRef.current = unmountingId
|
||||
? pluginInsertedTokensRef.current.filter(
|
||||
(e) => e.pluginId !== unmountingId,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
activePluginIdRef.current = null;
|
||||
}}
|
||||
onChipDetails={(item: ContextItem) => {
|
||||
if (item.kind !== 'plugin') return;
|
||||
const record = installedPlugins.find((p) => p.id === item.id);
|
||||
|
|
@ -1700,11 +2042,32 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
plugins={pluginsForComposer}
|
||||
activePluginId={pinnedPluginId}
|
||||
onApply={async (record) => {
|
||||
// Tools-menu apply: no draft write, so the
|
||||
// tracked-insertion array gets no new
|
||||
// entry. The active-plugin switch (which
|
||||
// drops previously-tracked entries from a
|
||||
// prior @-popover pick of a different
|
||||
// plugin, #2929 round 6) is deferred until
|
||||
// `applyById` resolves successfully so
|
||||
// that an `onCleared` triggered during the
|
||||
// in-flight window still sees the
|
||||
// still-mounted plugin's entries and
|
||||
// strips them correctly via the
|
||||
// `getActiveRecord()` filter in
|
||||
// `onCleared` (#2929 round 9).
|
||||
//
|
||||
// No synchronous mutation in this branch
|
||||
// means no rollback snapshot is needed:
|
||||
// the failure path is just an early return
|
||||
// (#2929 round 7's snapshot was needed
|
||||
// because `setActivePlugin` was eager).
|
||||
const result = await pluginsSectionRef.current?.applyById(
|
||||
record.id,
|
||||
record,
|
||||
);
|
||||
if (result) setToolsOpen(false);
|
||||
if (!result) return;
|
||||
setActivePlugin(record.id);
|
||||
setToolsOpen(false);
|
||||
}}
|
||||
onShowDetails={(record) => {
|
||||
setDetailsRecord(record);
|
||||
|
|
@ -1726,7 +2089,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const before = currentDraft.slice(0, cursor);
|
||||
const after = currentDraft.slice(cursor);
|
||||
const next = before + insert + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setToolsOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
|
|
@ -1750,7 +2113,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
const before = draft.slice(0, cursor);
|
||||
const after = draft.slice(cursor);
|
||||
const next = before + insert + after;
|
||||
setDraft(next);
|
||||
updateDraft(next);
|
||||
setToolsOpen(false);
|
||||
requestAnimationFrame(() => {
|
||||
const el = textareaRef.current;
|
||||
|
|
@ -1905,7 +2268,24 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
record={detailsRecord}
|
||||
onClose={() => setDetailsRecord(null)}
|
||||
onUse={async (record) => {
|
||||
await pluginsSectionRef.current?.applyById(record.id, record);
|
||||
// Details-modal apply: same shape as tools-menu apply
|
||||
// (no draft write). The active-plugin switch is
|
||||
// deferred until `applyById` resolves successfully so
|
||||
// that an `onCleared` triggered during the in-flight
|
||||
// window still sees the still-mounted plugin's
|
||||
// entries and strips them correctly (#2929 round 9).
|
||||
//
|
||||
// Modal closes regardless of apply outcome so the
|
||||
// user is not stuck on the details view if `/apply`
|
||||
// 5xx'd. Failure is a no-op: no synchronous mutation
|
||||
// happened, so nothing to roll back (#2929 round 7's
|
||||
// snapshot was needed because `setActivePlugin` was
|
||||
// eager — round 9 made it lazy).
|
||||
const result = await pluginsSectionRef.current?.applyById(
|
||||
record.id,
|
||||
record,
|
||||
);
|
||||
if (result) setActivePlugin(record.id);
|
||||
setDetailsRecord(null);
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -142,11 +142,35 @@ function pickEarlierMention(
|
|||
return known.token.length >= unknown.token.length ? known : unknown;
|
||||
}
|
||||
|
||||
function isMentionBoundary(text: string, start: number): boolean {
|
||||
/**
|
||||
* Left boundary rule for inline mentions: `@<token>` is a candidate
|
||||
* mention only when the character before `@` is the start of the
|
||||
* string or whitespace / opening bracket / quote. Exported so the
|
||||
* draft-side plugin-insertion tracker stays in lockstep with this
|
||||
* parser — see `apps/web/src/utils/pluginInsertionTracking.ts`.
|
||||
*/
|
||||
export function isMentionBoundary(text: string, start: number): boolean {
|
||||
if (start === 0) return true;
|
||||
return /[\s([{"']/.test(text[start - 1] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Right boundary rule for inline mentions: the parser's unknown
|
||||
* mention regex is `/@[^\s@]+/`, so a `@<token>` candidate is the
|
||||
* full mention only when the character after the token is the end
|
||||
* of the string, whitespace, or another `@` (which would start a
|
||||
* new mention). Anything else extends the parser's tokenization
|
||||
* past the candidate — e.g. `@Airbnb/foo` is parsed as a single
|
||||
* mention even when `@Airbnb` is a known plugin. Exported for the
|
||||
* same reason as `isMentionBoundary`: the draft-side tracker must
|
||||
* not declare an entry "still valid" when the parser would no
|
||||
* longer see the tracked token as a standalone mention.
|
||||
*/
|
||||
export function isMentionRightBoundary(text: string, end: number): boolean {
|
||||
if (end >= text.length) return true;
|
||||
return /[\s@]/.test(text[end] ?? '');
|
||||
}
|
||||
|
||||
function coalesceTextParts(parts: InlineMentionPart[]): InlineMentionPart[] {
|
||||
const result: InlineMentionPart[] = [];
|
||||
for (const part of parts) {
|
||||
|
|
|
|||
224
apps/web/src/utils/pluginInsertionTracking.ts
Normal file
224
apps/web/src/utils/pluginInsertionTracking.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// Instance-aware tracking for `@<token>` mentions that ChatComposer
|
||||
// inserts into the draft via the @-mention popover plugin-pick path
|
||||
// (#2881, #2929). Each tracked insertion records the precise start
|
||||
// offset of `@`, so two `@Airbnb` instances in the same draft (one
|
||||
// composer-inserted, one user-typed) are individually distinguishable
|
||||
// — the chip-clear strip only removes the tracked one (#2929 round 3).
|
||||
//
|
||||
// Boundary rules are imported from `./inlineMentions` so the
|
||||
// "tracker thinks this is still a valid mention" predicate stays in
|
||||
// lockstep with the actual mention parser. Without that, drafts
|
||||
// like `@Airbnb/foo` (where the parser tokenizes the full
|
||||
// `@Airbnb/foo` as one mention) would still satisfy a permissive
|
||||
// tracker boundary, and the post-clear strip would tear out only
|
||||
// `@Airbnb`, leaving `/foo` as orphaned user-authored text
|
||||
// (#2929 round 5).
|
||||
import {
|
||||
isMentionBoundary,
|
||||
isMentionRightBoundary,
|
||||
} from './inlineMentions';
|
||||
|
||||
export type TrackedInsertion = {
|
||||
/** Bare token without the leading `@`, matching `inlineMentionToken` payload. */
|
||||
token: string;
|
||||
/** Position of `@` in the draft. The full mention occupies [start, start + token.length + 1). */
|
||||
start: number;
|
||||
/**
|
||||
* `id` of the `InstalledPluginRecord` whose `applyById` produced this
|
||||
* insertion. Used by ChatComposer to scope the post-clear strip to the
|
||||
* currently active plugin: when the user replaces plugin A with plugin
|
||||
* B (e.g. via the tools menu's `applyById` without writing to the
|
||||
* draft), the entries for A must be dropped from the tracker, otherwise
|
||||
* clearing B's chip would silently strip A's `@A` from the draft —
|
||||
* user-text deletion in a supported replace-plugin flow (#2929 round 6).
|
||||
*/
|
||||
pluginId: string;
|
||||
/**
|
||||
* Optional unique handle assigned by the producer (ChatComposer's
|
||||
* `insertPluginMention`) when the entry is pushed. Survives
|
||||
* `reconcileInsertions` so the producer's failure-path rollback can
|
||||
* locate "the entry I just pushed" even after intervening reconciles
|
||||
* shifted offsets or after `onCleared` mutated the array. Without
|
||||
* this, a `(token, pluginId)`-only match is ambiguous if the user
|
||||
* replays the same plugin pick during the await window
|
||||
* (#2929 round 10 codex review).
|
||||
*/
|
||||
insertionId?: string;
|
||||
};
|
||||
|
||||
export type EditRange = {
|
||||
/** First index where prev and next differ. */
|
||||
start: number;
|
||||
/** Index in prev one past the last differing char. */
|
||||
oldEnd: number;
|
||||
/** Index in next one past the last differing char. */
|
||||
newEnd: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Longest-common-suffix + longest-common-prefix diff of two strings.
|
||||
* Returns the minimal `[editStart, oldEnd, newEnd]` range that contains
|
||||
* every byte that differs between `prev` and `next`.
|
||||
*
|
||||
* Suffix is computed first; the prefix is then capped so the two
|
||||
* matches do not overlap. This ordering matters when the inserted
|
||||
* text shares a leading character with `prev` — e.g. prepending
|
||||
* `@github ` before `@Airbnb ` to get `@github @Airbnb `. A
|
||||
* prefix-first walk would greedily claim the leading `@` (LCP=1)
|
||||
* and then split the diff window at index 1, which crosses through
|
||||
* a tracked `@Airbnb` entry that is structurally untouched. Suffix
|
||||
* first claims the entire `@Airbnb ` from the right, leaving LCP=0
|
||||
* and a clean prepend window of `[0, 0, 8]` — the entry shifts
|
||||
* cleanly by `delta`.
|
||||
*
|
||||
* Single-point edits (typing/deleting/pasting at one location) are
|
||||
* 100% accurate. Multi-point simultaneous edits (rare) collapse into
|
||||
* one wider range, which conservatively invalidates any tracked
|
||||
* insertion overlapping that range.
|
||||
*/
|
||||
export function computeEditRange(prev: string, next: string): EditRange {
|
||||
if (prev === next) return { start: 0, oldEnd: 0, newEnd: 0 };
|
||||
const minLen = Math.min(prev.length, next.length);
|
||||
// Longest common suffix first — capped at minLen so it does not
|
||||
// walk past the start of either string.
|
||||
let suffix = 0;
|
||||
while (
|
||||
suffix < minLen &&
|
||||
prev.charCodeAt(prev.length - 1 - suffix) ===
|
||||
next.charCodeAt(next.length - 1 - suffix)
|
||||
) {
|
||||
suffix++;
|
||||
}
|
||||
// Longest common prefix, capped so it does not overlap the suffix.
|
||||
const maxStart = minLen - suffix;
|
||||
let start = 0;
|
||||
while (start < maxStart && prev.charCodeAt(start) === next.charCodeAt(start)) {
|
||||
start++;
|
||||
}
|
||||
return {
|
||||
start,
|
||||
oldEnd: prev.length - suffix,
|
||||
newEnd: next.length - suffix,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff the draft still contains `@<token>` at the given start
|
||||
* offset AND the surrounding characters make the parser see it as
|
||||
* exactly that mention (not a longer one). Boundaries delegate to
|
||||
* the same `isMentionBoundary` / `isMentionRightBoundary` helpers
|
||||
* the parser uses, so the tracker cannot diverge from the parser
|
||||
* and inadvertently strip a prefix of a longer parser-recognized
|
||||
* mention (#2929 round 5).
|
||||
*
|
||||
* Concretely: a tracked `@Airbnb` at offset 0 in `@Airbnb/foo` is
|
||||
* INVALID under this rule because the parser treats the full
|
||||
* `@Airbnb/foo` as one mention (its `@[^\s@]+` greedy regex extends
|
||||
* through `/foo`). Stripping just `@Airbnb` would leave `/foo`
|
||||
* dangling — that is user-authored text mutation, not an orphan
|
||||
* removal. Invalidating the entry on clear is the conservative
|
||||
* choice: the post-clear strip becomes a no-op, the orphan
|
||||
* styled mention remains visible, and the user can edit it
|
||||
* manually if they want.
|
||||
*/
|
||||
export function isInsertionStillValid(
|
||||
draft: string,
|
||||
start: number,
|
||||
token: string,
|
||||
): boolean {
|
||||
if (start < 0 || token.length === 0) return false;
|
||||
const tokenLen = token.length + 1; // include leading `@`
|
||||
if (start + tokenLen > draft.length) return false;
|
||||
if (draft.slice(start, start + tokenLen) !== `@${token}`) return false;
|
||||
if (!isMentionBoundary(draft, start)) return false;
|
||||
if (!isMentionRightBoundary(draft, start + tokenLen)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-map tracked insertion offsets across a draft edit. Entries
|
||||
* entirely before the edit keep their offset; entries entirely
|
||||
* after shift by `delta`; entries that overlap the edit are
|
||||
* dropped. Survivors are revalidated against the new draft so any
|
||||
* boundary corruption (e.g. user typed letters touching the right
|
||||
* edge of `@Airbnb` to form `@Airbnbify`) prunes the entry.
|
||||
*/
|
||||
export function reconcileInsertions(
|
||||
entries: ReadonlyArray<TrackedInsertion>,
|
||||
prev: string,
|
||||
next: string,
|
||||
): TrackedInsertion[] {
|
||||
if (entries.length === 0) return [];
|
||||
if (prev === next) return entries.slice();
|
||||
const { start: editStart, oldEnd, newEnd } = computeEditRange(prev, next);
|
||||
const delta = newEnd - oldEnd;
|
||||
const result: TrackedInsertion[] = [];
|
||||
for (const e of entries) {
|
||||
const tokenLen = e.token.length + 1;
|
||||
const entryEnd = e.start + tokenLen;
|
||||
let nextStart: number;
|
||||
if (entryEnd <= editStart) {
|
||||
nextStart = e.start; // entry entirely before edit
|
||||
} else if (e.start >= oldEnd) {
|
||||
nextStart = e.start + delta; // entry entirely after edit → shift
|
||||
} else {
|
||||
continue; // edit overlaps entry → drop
|
||||
}
|
||||
if (isInsertionStillValid(next, nextStart, e.token)) {
|
||||
const reconciled: TrackedInsertion = {
|
||||
token: e.token,
|
||||
start: nextStart,
|
||||
pluginId: e.pluginId,
|
||||
};
|
||||
if (e.insertionId !== undefined) reconciled.insertionId = e.insertionId;
|
||||
result.push(reconciled);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tracked `@<token>` insertions from the draft by slicing each
|
||||
* entry's range. Sorts descending by start so earlier offsets stay
|
||||
* valid as later ones are excised. Invalidated entries (boundary
|
||||
* corruption since the last reconcile) are skipped — the safe failure
|
||||
* mode is under-delete, never over-delete.
|
||||
*
|
||||
* Whitespace handling (#2929 round 8): when both the character before
|
||||
* `@` and the character after the token are whitespace, the slice
|
||||
* removes one of them in addition to the token to avoid leaving
|
||||
* doubled whitespace at the seam (e.g. `text @Airbnb more` → `text
|
||||
* more`, not `text more`). Whitespace ELSEWHERE in the draft is
|
||||
* never touched — a previous version of this function ran a global
|
||||
* `[ \t]{2,}` collapse over the entire result, which silently
|
||||
* rewrote any user-authored multi-space spans (e.g. `keep gap` →
|
||||
* `keep gap`). Round 8 reviewer flagged that as prompt corruption
|
||||
* in the changed flow.
|
||||
*/
|
||||
export function stripPluginInsertedTokens(
|
||||
draft: string,
|
||||
entries: ReadonlyArray<TrackedInsertion>,
|
||||
): string {
|
||||
if (!draft || entries.length === 0) return draft;
|
||||
const valid = entries
|
||||
.filter((e) => isInsertionStillValid(draft, e.start, e.token))
|
||||
.sort((a, b) => b.start - a.start);
|
||||
let next = draft;
|
||||
for (const e of valid) {
|
||||
const tokenLen = e.token.length + 1;
|
||||
const leftIdx = e.start - 1;
|
||||
const rightIdx = e.start + tokenLen;
|
||||
const leftIsWs =
|
||||
leftIdx >= 0 && /[ \t]/.test(next[leftIdx] ?? '');
|
||||
const rightIsWs =
|
||||
rightIdx < next.length && /[ \t]/.test(next[rightIdx] ?? '');
|
||||
// Seam-local collapse only: if both sides are whitespace,
|
||||
// extend the slice by one character so the seam ends up with
|
||||
// a single space instead of two. Anything outside this range
|
||||
// — including user-authored multi-space spans elsewhere — is
|
||||
// left untouched.
|
||||
const sliceEnd = leftIsWs && rightIsWs ? rightIdx + 1 : rightIdx;
|
||||
next = next.slice(0, e.start) + next.slice(sliceEnd);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
310
apps/web/tests/utils/pluginInsertionTracking.test.ts
Normal file
310
apps/web/tests/utils/pluginInsertionTracking.test.ts
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
computeEditRange,
|
||||
isInsertionStillValid,
|
||||
reconcileInsertions,
|
||||
stripPluginInsertedTokens,
|
||||
type TrackedInsertion,
|
||||
} from '../../src/utils/pluginInsertionTracking';
|
||||
|
||||
// Pure-function coverage for the diff/reconcile/strip primitives that
|
||||
// back ChatComposer's instance-aware plugin mention tracking
|
||||
// (#2929 round 3). The integration spec
|
||||
// (`ChatComposer.plugin-clear-prunes-draft.test.tsx`) exercises the
|
||||
// end-to-end React path; this file pins the edge cases the integration
|
||||
// flow is unlikely to hit, so a regression in the math surfaces here
|
||||
// before it can corrupt user drafts.
|
||||
|
||||
describe('computeEditRange', () => {
|
||||
it('returns an empty range when the strings are equal', () => {
|
||||
expect(computeEditRange('abc', 'abc')).toEqual({ start: 0, oldEnd: 0, newEnd: 0 });
|
||||
});
|
||||
|
||||
it('detects a pure prefix append', () => {
|
||||
// `prev` is the suffix of `next`; the diff sits at the very start.
|
||||
expect(computeEditRange('world', 'hello world')).toEqual({
|
||||
start: 0,
|
||||
oldEnd: 0,
|
||||
newEnd: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a pure suffix append', () => {
|
||||
expect(computeEditRange('hello', 'hello world')).toEqual({
|
||||
start: 5,
|
||||
oldEnd: 5,
|
||||
newEnd: 11,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a middle replacement', () => {
|
||||
expect(computeEditRange('abc XYZ def', 'abc 12345 def')).toEqual({
|
||||
start: 4,
|
||||
oldEnd: 7,
|
||||
newEnd: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it('detects a full deletion to empty', () => {
|
||||
expect(computeEditRange('hello', '')).toEqual({ start: 0, oldEnd: 5, newEnd: 0 });
|
||||
});
|
||||
|
||||
it('does not let prefix and suffix overlap when one string is a substring of the other', () => {
|
||||
// Both strings share the leading `aa`. If suffix matching greedily
|
||||
// walked past `start`, the range could go negative.
|
||||
const r = computeEditRange('aa', 'aaa');
|
||||
expect(r.start).toBeLessThanOrEqual(r.oldEnd);
|
||||
expect(r.start).toBeLessThanOrEqual(r.newEnd);
|
||||
});
|
||||
|
||||
it('treats prepended text that shares a leading char with prev as a clean prepend (#2929 round 4)', () => {
|
||||
// Inserting `@github ` before `@Airbnb ` gives `@github @Airbnb `.
|
||||
// A naive LCP-first algorithm matches the leading `@`, then walks
|
||||
// LCS backwards through `Airbnb `, and reports the edit as
|
||||
// `editStart=1, oldEnd=1, newEnd=9`. That window cuts through a
|
||||
// tracked entry at offset 0 even though `@Airbnb` was not
|
||||
// structurally touched. LCS-first is required so the entire
|
||||
// `@Airbnb ` suffix is claimed by the right side and the diff
|
||||
// collapses to a clean prepend of `[0, 0, 8]`.
|
||||
const r = computeEditRange('@Airbnb ', '@github @Airbnb ');
|
||||
expect(r).toEqual({ start: 0, oldEnd: 0, newEnd: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInsertionStillValid', () => {
|
||||
it('accepts a token at the start of the draft', () => {
|
||||
expect(isInsertionStillValid('@Airbnb plan', 0, 'Airbnb')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a token after a whitespace boundary', () => {
|
||||
expect(isInsertionStillValid('see @Airbnb', 4, 'Airbnb')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when the surrounding letter forms a longer mention', () => {
|
||||
// `@Airbnbx` would render as a single mention so the tracked range
|
||||
// is no longer the intended target.
|
||||
expect(isInsertionStillValid('@Airbnbx', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the left boundary is a non-mention character', () => {
|
||||
// `x@Airbnb` is not a valid mention per inlineMentions boundary
|
||||
// rules — the `x` immediately to the left is a word char.
|
||||
expect(isInsertionStillValid('x@Airbnb', 1, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when the offset no longer points at the token', () => {
|
||||
expect(isInsertionStillValid('compare @Airbnb', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative or out-of-range offsets', () => {
|
||||
expect(isInsertionStillValid('@Airbnb', -1, 'Airbnb')).toBe(false);
|
||||
expect(isInsertionStillValid('@Airbnb', 100, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
// Parser-alignment cases (#2929 round 5): the inline-mention parser
|
||||
// tokenizes `@<token>` greedily through `[^\s@]`, then prefers the
|
||||
// longer match at the same start offset. A tracked entry must
|
||||
// therefore be invalidated whenever the right-boundary character
|
||||
// would extend the parser's mention past the tracked range — that
|
||||
// is, anything other than EOS, whitespace, or another `@`. Without
|
||||
// these rejections the post-clear strip would carve `@Airbnb` out
|
||||
// of `@Airbnb/foo`, leaving `/foo` dangling as user-authored text
|
||||
// mutation.
|
||||
it('rejects when followed by `/` (parser would tokenize a longer mention)', () => {
|
||||
expect(isInsertionStillValid('@Airbnb/foo', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when followed by `.`', () => {
|
||||
expect(isInsertionStillValid('@Airbnb.test', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when followed by `,`', () => {
|
||||
expect(isInsertionStillValid('@Airbnb,', 0, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when followed by `)` (parser would extend through the paren)', () => {
|
||||
expect(isInsertionStillValid('see (@Airbnb), then ship', 5, 'Airbnb')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts when followed by another `@` (next mention starts there)', () => {
|
||||
expect(isInsertionStillValid('@Airbnb@other', 0, 'Airbnb')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts at end-of-string', () => {
|
||||
expect(isInsertionStillValid('@Airbnb', 0, 'Airbnb')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts when followed by whitespace', () => {
|
||||
expect(isInsertionStillValid('@Airbnb plan', 0, 'Airbnb')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileInsertions', () => {
|
||||
const entry: TrackedInsertion = { token: 'Airbnb', start: 0, pluginId: 'airbnb' };
|
||||
|
||||
it('returns a fresh copy when nothing changed', () => {
|
||||
const out = reconcileInsertions([entry], '@Airbnb ', '@Airbnb ');
|
||||
expect(out).toEqual([entry]);
|
||||
expect(out).not.toBe([entry]); // new array
|
||||
});
|
||||
|
||||
it('keeps an entry whose tail sits before the edit', () => {
|
||||
// `@Airbnb` at [0,7], edit happens at index 8 (typing after the trailing space)
|
||||
const next = reconcileInsertions(
|
||||
[entry],
|
||||
'@Airbnb ',
|
||||
'@Airbnb compare',
|
||||
);
|
||||
expect(next).toEqual([entry]);
|
||||
});
|
||||
|
||||
it('shifts an entry whose head sits after the edit', () => {
|
||||
// Insert `prefix ` (7 chars) at the beginning. Entry start moves 0 → 7.
|
||||
const next = reconcileInsertions(
|
||||
[entry],
|
||||
'@Airbnb ',
|
||||
'prefix @Airbnb ',
|
||||
);
|
||||
expect(next).toEqual([{ token: 'Airbnb', start: 7, pluginId: 'airbnb' }]);
|
||||
});
|
||||
|
||||
it('drops an entry the edit overlaps', () => {
|
||||
// User selects through the entry and replaces it with other text.
|
||||
const next = reconcileInsertions(
|
||||
[entry],
|
||||
'@Airbnb plan',
|
||||
'@Air-other plan',
|
||||
);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops an entry whose right boundary is corrupted (letters touching)', () => {
|
||||
// Typing `ify` immediately after `@Airbnb` makes it `@Airbnbify`
|
||||
// which is no longer a valid mention.
|
||||
const next = reconcileInsertions(
|
||||
[entry],
|
||||
'@Airbnb',
|
||||
'@Airbnbify',
|
||||
);
|
||||
expect(next).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles multiple entries with mixed shift / keep / drop outcomes', () => {
|
||||
// prev: `@A xxx @B yyy`
|
||||
// entry1 at 0 entry2 at 11
|
||||
// edit: replace `xxx` (cols 4-6) with `12345` (delta = +2)
|
||||
const e1: TrackedInsertion = { token: 'A', start: 0, pluginId: 'a' };
|
||||
const e2: TrackedInsertion = { token: 'B', start: 11, pluginId: 'b' };
|
||||
const prev = '@A xxx @B yyy';
|
||||
const next = '@A 12345 @B yyy';
|
||||
const out = reconcileInsertions([e1, e2], prev, next);
|
||||
expect(out).toEqual([
|
||||
{ token: 'A', start: 0, pluginId: 'a' },
|
||||
{ token: 'B', start: 13, pluginId: 'b' }, // 11 + 2
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when the entries are empty', () => {
|
||||
expect(reconcileInsertions([], 'a', 'b')).toEqual([]);
|
||||
});
|
||||
|
||||
// Purity guard (#2929 round 5): reconcile must not mutate its
|
||||
// inputs and must produce the same output regardless of how many
|
||||
// times it is called with the same arguments. React StrictMode
|
||||
// double-invokes setState updaters in development; the previous
|
||||
// implementation called reconcile *inside* the updater and
|
||||
// accumulated shifts (entry at 0 → 8 → 16) on the second
|
||||
// invocation, dropping the entry as out-of-range. The fix moves
|
||||
// reconcile out of the updater, but pinning purity here too so a
|
||||
// future regression there is caught at the algorithm layer.
|
||||
it('is pure: invoking twice with the same args returns equivalent output and does not mutate input', () => {
|
||||
const entries: TrackedInsertion[] = [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }];
|
||||
const frozen = Object.freeze([...entries]) as ReadonlyArray<TrackedInsertion>;
|
||||
const first = reconcileInsertions(frozen, '@Airbnb ', '@github @Airbnb ');
|
||||
const second = reconcileInsertions(frozen, '@Airbnb ', '@github @Airbnb ');
|
||||
expect(first).toEqual([{ token: 'Airbnb', start: 8, pluginId: 'airbnb' }]);
|
||||
expect(second).toEqual([{ token: 'Airbnb', start: 8, pluginId: 'airbnb' }]);
|
||||
// Frozen input was not mutated (any attempt would have thrown
|
||||
// in strict mode).
|
||||
expect(frozen).toEqual([{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripPluginInsertedTokens', () => {
|
||||
it('returns the draft unchanged when there are no entries', () => {
|
||||
expect(stripPluginInsertedTokens('@Airbnb ', [])).toBe('@Airbnb ');
|
||||
});
|
||||
|
||||
it('removes a single tracked token at the start of the draft', () => {
|
||||
expect(
|
||||
stripPluginInsertedTokens('@Airbnb ', [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]),
|
||||
).toBe(' '); // trailing space from inserted text remains; integration trims as needed
|
||||
});
|
||||
|
||||
it('removes a tracked token while preserving an untracked duplicate (#2929 round 3)', () => {
|
||||
// The whole point: composer-inserted `@Airbnb` at offset 0 gets
|
||||
// removed; the user-authored `@Airbnb` at offset 16 is untracked
|
||||
// and therefore preserved.
|
||||
const draft = '@Airbnb compare @Airbnb with our spec';
|
||||
const out = stripPluginInsertedTokens(draft, [{ token: 'Airbnb', start: 0, pluginId: 'airbnb' }]);
|
||||
expect(out).toBe(' compare @Airbnb with our spec');
|
||||
});
|
||||
|
||||
it('slices multiple tracked tokens in one pass without offset drift', () => {
|
||||
// Two tracked entries, descending sort means the right one is
|
||||
// sliced first so the left one's offset stays valid.
|
||||
const draft = '@A and @B';
|
||||
const out = stripPluginInsertedTokens(draft, [
|
||||
{ token: 'A', start: 0, pluginId: 'a' },
|
||||
{ token: 'B', start: 7, pluginId: 'b' },
|
||||
]);
|
||||
expect(out).toBe(' and ');
|
||||
});
|
||||
|
||||
it('drops invalidated entries instead of corrupting unrelated text', () => {
|
||||
// The tracked offset no longer points at `@Airbnb` (user retyped).
|
||||
// strip should be a no-op rather than deleting whatever sits at the
|
||||
// stale offset.
|
||||
const draft = 'hello world';
|
||||
const out = stripPluginInsertedTokens(draft, [
|
||||
{ token: 'Airbnb', start: 0, pluginId: 'airbnb' },
|
||||
]);
|
||||
expect(out).toBe('hello world');
|
||||
});
|
||||
|
||||
it('collapses double whitespace left behind by the strip', () => {
|
||||
const draft = 'see @Airbnb here';
|
||||
const out = stripPluginInsertedTokens(draft, [
|
||||
{ token: 'Airbnb', start: 4, pluginId: 'airbnb' },
|
||||
]);
|
||||
// After slicing `@Airbnb`: `see here` (two spaces) → collapse to `see here`
|
||||
expect(out).toBe('see here');
|
||||
});
|
||||
|
||||
it('does not normalize user-authored multi-space spans elsewhere in the draft (#2929 round 8)', () => {
|
||||
// Reviewer-flagged: the previous global `[ \t]{2,}` collapse
|
||||
// would rewrite any user-authored double-space span to a
|
||||
// single space, even ones unrelated to the strip seam. The
|
||||
// seam-local collapse here only touches the whitespace
|
||||
// adjacent to the removed range.
|
||||
const draft = 'keep gap @Airbnb here';
|
||||
const out = stripPluginInsertedTokens(draft, [
|
||||
{ token: 'Airbnb', start: 10, pluginId: 'airbnb' },
|
||||
]);
|
||||
// `keep gap` (two spaces) is preserved; the `@Airbnb` seam
|
||||
// collapses to a single space.
|
||||
expect(out).toBe('keep gap here');
|
||||
});
|
||||
|
||||
it('preserves multi-space spans on both sides of an unrelated mention (#2929 round 8)', () => {
|
||||
// Two user-authored double-space spans flank an `@Airbnb`
|
||||
// that is not tracked. Strip should be a no-op (no entries
|
||||
// for it) — verifies that nothing in the function reaches
|
||||
// out and normalizes whitespace when it has no entries to
|
||||
// operate on.
|
||||
const draft = 'one two @Untracked three four';
|
||||
const out = stripPluginInsertedTokens(draft, []);
|
||||
expect(out).toBe('one two @Untracked three four');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue