28 Commits
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 2420fcee9f fix: 공개 문서가 실제로 그리는 주제 링크도 탐색 필터로 돌린다
주제 링크를 고쳤는데 화면은 그대로 `/topics/:slug` 로 갔다. 공개 문서의 머리말을 그리는
것은 `PublicDocumentHeader` 의 breadcrumb 이 아니라 렌더 모델의 `topic.publicPath` 이고,
같은 파일 안에서 두 자리가 같은 경로를 만들고 있었다. 한 자리만 고쳤으니 배포하고 눌러
보기 전까지는 고친 것처럼 보였다.

테스트도 이 링크를 묻지 않고 있었다. 머리말 검사가 `Case/JPA/Backend Skeleton` 이라는
글자만 확인해서, 그 글자가 어디로 가는지는 아무도 보지 않았다. 되돌려 보면 공개 문서
여섯 개가 모두 빨개진다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:55:19 +09:00
DongHyeonkaandClaude Opus 5 2632850322 fix: 최근 기록에 Open Question 을 싣고, 주제 링크를 탐색 필터로 돌린다
게시한 질문이 홈 최근 기록에 나오지 않았다. 계약의 `LatestEntry.entryType` 이 네 값만
허용해 백엔드가 담을 수 없었다 — design-package ef49d3a 에서 `QUESTION` 을 더했고 여기서
계약을 다시 생성한다. 포트의 `LatestRecordEntry.entryType` 도 같이 넓히고, 홈의 표기는
목록의 다른 이름들과 같은 규칙(대문자에 공백)을 따라 `OPEN QUESTION` 으로 적는다.

문서 머리말의 주제 링크는 `/topics/:slug` 로 가고 있었는데, 그 화면은 `jpa`/`authentication`
/`redis` 세 개를 하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 됐다. 게시한 모든 문서의
주제 링크가 거기로 갔다.

주제 페이지를 채우는 대신 링크를 탐색 필터로 돌린다. 지금 그 페이지만 줄 수 있는 것 —
설명, 범위, 선별한 대표 기록 — 이 전부 비어 있고, Studio 의 주제 만들기는 `{name, slug}` 만
보내므로 설명을 쓸 칸조차 없다. 독자가 거기서 기대하는 것은 같은 주제의 기록 목록이고,
그것은 `/explore?topic=` 이 그대로 준다. 주제가 여러 개가 되고 설명을 쓸 수 있게 되면 그때
페이지를 만드는 것이 순서다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 23:44:20 +09:00
DongHyeonkaandClaude Opus 5 ad8f322b43 fix: 질문 머리말이 관계에 담긴 프로젝트를 읽는다
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트 칸이 늘
비어 있었다.

그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
`name`/`slug` 이므로 옮겨 준다 — slug 는 경로의 마지막 마디다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:56:21 +09:00
DongHyeonkaandClaude Opus 5 ab4d822956 fix: 게시된 Open Question 의 공개 상세가 열리게 한다
게시한 질문의 공개 상세가 「요청을 처리하지 못했습니다」만 띄웠다. 게이트웨이가 `points`
를 `{group, items}` 배열로 읽고 `.filter` 를 불렀는데, 계약의 `QuestionPointGroup` 은
`facts`/`assumptions`/`unknowns`/`constraints` 를 키로 갖는 객체다. 객체에는 `.filter` 가
없으니 매핑이 통째로 터졌다.

목록은 이 칸들을 빈 배열로 두고 만들기 때문에 탐색에서는 멀쩡히 보였다. 그래서 "게시했는데
public 에 안 뜬다" 로만 드러났고 어느 층이 깨졌는지는 보이지 않았다. `as` 캐스트가 그
어긋남을 타입 검사에서 가렸다 — 이제 계약의 타입을 그대로 써서 모양이 바뀌면 컴파일이
먼저 막는다.

관계도 같은 종류로 어긋나 있었다. 계약이 주는 이름은 `resultCase`/`producedDecision`/
`derivedReferences` 인데 매퍼는 `derivedCases`/`projectDecisions`/`relatedQuestions` 를
찾고 있었고, 하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다. `primaryProject` 는
관계가 아니라 이 질문이 속한 프로젝트이므로 관계 목록에서 뺀다 — 머리말이 이미 보여 준다.

이 사고가 지나간 이유는 HTTP 게이트웨이의 질문 상세 매핑을 지나는 테스트가 없었기
때문이다. 화면 테스트는 정적 픽스처 어댑터를 쓰므로 계약 모양을 한 번도 통과시키지
않는다. 계약 모양 그대로의 응답을 진짜 게이트웨이에 넣고 네 칸이 채워져 나오는지 묻는
테스트를 넣는다 — 되돌려 보면 운영에서 난 것과 같은 `points.filter is not a function`
으로 실패한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 22:50:14 +09:00
DongHyeonkaandClaude Opus 5 344a163d84 fix: 탐색 주제 필터가 slug 를 보내고, 편집기를 넓혀 두 칸이 함께 스크롤한다
주제 필터를 걸면 「조건에 맞는 공개 기록이 없습니다」만 남았다. 선택지가
`<option>{이름}</option>` 이라 값이 없어 이름이 그대로 나갔고 — `topic=OAuth/OIDC 인증
경계` — API 는 slug 로 거르므로 0건을 돌려줬다. 프로젝트 선택지는 처음부터
`value={slug}` 였고, 그래서 프로젝트만 멀쩡했다. 주제도 같은 모양으로 맞춘다.

테스트가 이 결함을 통과시킨 이유는 픽스처의 주제 이름이 `JPA`, `Authentication` 처럼
slug 와 구분되지 않는 값이어서다. 이름과 slug 가 다른 값을 쓰는 운영에서만 드러났다.
선택지의 값이 slug 인지 직접 묻는 단언을 넣는다.

`RecordFilters.topic` 은 어댑터마다 뜻이 달랐다. 정적 어댑터는 이름으로, HTTP 어댑터는
그 값을 그대로 API 에 넘겨 slug 로 걸렀다. 프로젝트가 이미 slug/제목 둘 다 받는 것과
같이 주제도 둘 다 받게 해서 두 어댑터가 같은 값을 이해하게 한다. 주제 페이지도 이름
대신 경로의 slug 로 묻는다.

「전체」를 고른 칸은 조건이 아니다. 빈 값까지 실어 보내고 있었고, URL 이 지저분해질 뿐
아니라 이 값을 그대로 API 에 넘기는 화면에서는 `topic=` 이 "slug 가 빈 문자열인 주제"로
해석되어 0건이 된다.

편집기는 미리보기를 붙박이로 두고 자체 스크롤을 줬다. 편집기를 내려도 미리보기는
제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가 둘을 같이 보는 것인데
움직임이 갈라지면 그 이점이 없다. 둘 다 페이지 스크롤을 그대로 타게 한다.

폭도 넓힌다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께 쓰는데, 본문 두 벌이
들어가야 하는 이 화면에서는 한 칸이 566px 였다. 편집기가 놓인 경우에만 1600px 로
넓히고 헤더도 같이 넓혀 좌우 끝을 맞춘다. 다른 화면은 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 17:30:11 +09:00
DongHyeonkaandClaude Opus 5 60c8c82097 fix: 편집과 미리보기를 한 화면에서 보고, 저장·게시를 아래에 고정한다
편집기는 탭이었다. 고친 것이 어떻게 보이는지 확인하려면 편집하던 자리를 화면에서
치워야 했고, 돌아오면 스크롤 위치도 잃었다. 두 패널을 나란히 두면 그 왕복이 통째로
없어진다.

작업 상태와 저장·게시는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께
쓸 가로 폭을 가져갔고, 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다.
화면 아래에 고정하면 폭을 돌려주면서 스크롤 위치와 무관하게 손이 닿는다.

탭을 없앴으므로 각 패널이 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을
가리켜 landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를
대신한다. `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이
묶음이 무엇인지가 바뀐 것은 아니다.

미리보기가 늘 떠 있게 되면서 Picker 테스트의 단언도 함께 고쳤다. 「Picker 가
좁아졌는가」를 화면 전체에 묻고 있었는데, 이제는 미리보기 쪽의 같은 Asset 까지
세게 된다. 그 김에 `.studio-asset-panel` 의 `aria-labelledby` 가 role 없는 div 에서
아무 이름도 만들지 못하던 것도 `role="group"` 으로 실효화했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
2026-08-26 15:49:18 +09:00
DongHyeonkaandClaude Opus 5 b3119952d5 fix: 제목 아래에 문서의 요약을 그린다
Reference 머리말에 「이 기준을 쓰는 이유」와 같은 글이 나오고 있었다. Case 도 마찬가지로
바로 아래 「문제」와 같은 글을 두 번 말했다.

문서가 스스로 밝히는 한 줄 요약이 공개 응답에 없어서, 화면이 유형별 요약
(`problemSummary` / `scopeSummary`)을 대신 쓰고 있었다. Question 에는 이미 `summary` 가
있었으므로 셋이 같은 자리를 갖게 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 21:01:52 +09:00
DongHyeonkaandClaude Opus 5 7211dd1a92 fix: 공개 Reference 가 계약이 주는 이름을 읽게 한다
Reference 를 공개했는데 Studio 에서는 다 보이고 공개 화면만 비어 있었다.

게이트웨이가 읽던 이름이 계약에 없는 것들이었다 — `purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. 계약이 주는 이름은
`scopeSummary`, `appliesTo`, `excludedScope` 다. 전부 undefined 로 떨어졌고, `as string`
단언 때문에 타입 검사는 아무 말도 하지 않았다.

규칙은 `content` 마크다운을 잘라 만들고 있었다. Reference 의 본문은 마크다운 한 덩어리가
아니라 제목이 붙은 규칙의 목록이고, Studio 의 편집기가 그렇게 받아 `body_markdown` 은
비워 둔다 — 자를 것이 없으니 언제나 빈 목록이었다. 계약이 구조로 주는 것을 그대로 쓴다.

값이 아니라 이름을 지키는 테스트를 둔다. 계약에서 그 칸이 사라지면 `satisfies` 가 먼저
깨진다 — 이번 결함은 값을 검사해서는 잡히지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 19:08:37 +09:00
DongHyeonkaandClaude Opus 5 3036b8d788 feat: Ctrl+S 로 저장한다
브라우저의 Ctrl+S 는 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장
대화상자가 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다.

문서·프로젝트·릴리즈 세 편집기에서 기본 동작을 막고 그 화면의 저장으로 돌린다.

저장할 것이 없을 때도 기본 동작은 막는다. 막지 않으면 "저장할 것이 없다"는 화면 상태와
브라우저의 저장 대화상자가 동시에 나와, 무엇이 일어났는지 알 수 없다.

Ctrl+Shift+S 와 Alt 조합은 가로채지 않는다 — 다른 뜻으로 쓰는 곳이 있다.

버튼에 단축키를 적어 둔다. 단축키는 알려 주지 않으면 없는 것과 같다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-25 17:05:14 +09:00
DongHyeonkaandClaude Opus 5 fd73bc88a1 fix: Decision 미리보기의 결정일 요구를 풀고, 화면 테스트가 실제 동작을 다시 말하게 한다
Decision 은 결정일이 없으면 미리보기가 열리지 않았다. 검증은 그것을 경고로만 다루므로
날짜 없이 게시할 수 있는데 렌더 모델이 필수로 요구했다 — 작성자는 "경고라면서 왜 안
되냐"를 만난다. 계약을 nullable 로 열고 화면이 "결정일 미정"이라고 말하게 한다.

한 칸의 실패가 화면을 통째로 날리지 않게 한다. `Promise.all([gateway.foo()])` 은 foo 가
거절하는 것만 잡는다 — 호출이 동기적으로 던지면 배열을 만드는 중에 터져 rejection
handler 를 지나지 못하고, 그러면 홈 focus 한 칸 때문에 대시보드 전체가 빈 화면이 된다.
프로젝트 편집도 같은 모양이라 함께 고친다.

`IntersectionObserver` 가 없는 환경을 견딘다. 목차는 픽스처 Case 하나에서만 쓰여 그런
환경을 만난 적이 없었는데, 모든 Case 가 목차를 받게 되면서 jsdom 에서 문서가 통째로
깨졌다. 없으면 "지금 읽는 절" 표시만 못 할 뿐이다.

픽스처의 최근 기록에서 릴리스를 뺀다. 서버의 `latestEntries` 는 공개 투영에서 고르므로
릴리스가 없고, 홈이 릴리스를 따로 읽어 합친다 — 픽스처가 넣으면 같은 릴리스가 두 번
나온다.

화면 테스트는 `test:unit` 이 아니라 `test:tech-log` 가 돌린다. 그것을 돌리지 않아 위
두 결함과, 라우트 두 개·`--body-copy`·Case 배치 통합·활동 링크 제거처럼 의도한 변경에
고정돼 있던 단언들이 23건 빨간 채로 여러 커밋을 지나갔다. 단언을 실제 동작으로 옮긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 18:03:11 +09:00
DongHyeonkaandClaude Opus 5 e5770dfbc8 fix: Decision 미리보기 오류가 할 일을 말하게 한다
"PROJECT public path is required" 는 사실이지만 작성자가 무엇을 해야 하는지 말하지
않는다. Decision 의 공개 주소는 프로젝트 주소 아래에 있으므로, 프로젝트를 먼저
게시해야 한다는 것과 그 화면이 어디인지 적는다.

프로젝트를 고르지 않았을 때의 문구도 같이 바꾼다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 17:35:46 +09:00
DongHyeonkaandClaude Opus 5 a7fe069a6d feat: 프로젝트 활동을 게시가 남기는 로그로 바꾼다
활동을 Studio 에서 손으로 적어야 했다. 그러면 "언제 무엇을 올렸는가" 가 실제로 올린
사실과 따로 관리되고, 적기를 잊으면 타임라인에 구멍이 남는다. 게시가 곧 사건이므로
게시가 기록한다.

공개 화면에서는 링크를 없앤다. 각 줄에 그 기록으로 가는 링크가 있으면 같은 글에 닿는
길이 둘이 되고, 읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다.
활동은 날짜와 무엇을 올렸는지만 말하고, 글을 읽는 자리는 기록 화면 하나로 둔다.

Studio 의 활동 칸도 읽기 전용 로그가 된다 — 만들기·고치기·지우기 폼을 걷어낸다.

맥락에서 경계와 시스템 개요 칸을 뺀다. 저장은 되지만 공개 화면 어디에도 나오지 않는다
— 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 어딘가 실릴 것처럼 보이는
칸이었고 실제로는 아무도 읽지 않았다. 열의 데이터는 남겨 두고 서버 값을 그대로
왕복시킨다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 15:33:43 +09:00
DongHyeonkaandClaude Opus 5 ab67eb912c fix: 릴리즈 편집 하단 버튼이 두 벌 나오던 것을 하나로 되돌린다
저장·공개·공개에서 내리기가 각각 두 개씩 나왔다.

편집 폼을 목록에서 떼어 낼 때 잘라 온 조각에 이미 그 푸터가 들어 있었는데, 새 화면을
조립하면서 같은 푸터를 한 번 더 붙였다. 둘 다 같은 핸들러를 부르므로 어느 쪽을 눌러도
동작은 했고, 그래서 화면을 열어 개수를 세기 전까지는 드러나지 않았다.

자식이 하나뿐이라 감쌀 이유가 없어진 프래그먼트도 함께 걷어낸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 02:32:12 +09:00
DongHyeonkaandClaude Opus 5 e9b866184a fix: 릴리즈 목록에서 편집 흔적을 걷어내고, 타입 검사가 실제로 돌게 한다
앞 커밋에서 편집 폼만 떼어 내고 그것을 움직이던 상태와 핸들러를 목록에 남겨 두었다.
`GuardedStudioLink` import 는 빠졌고 `navigate` 는 아예 정의된 적이 없다. 운영에서
릴리즈 목록이 ReferenceError 로 비었다.

`npx tsc --noEmit` 이 통과했기 때문에 이것을 못 봤다. 루트 tsconfig 는 `"files": []` 에
project references 만 나열하므로 그 명령은 한 파일도 검사하지 않고 성공한다 — 실제 검사는
`npm run check:types` 가 여섯 개 프로젝트를 돌며 한다. 그 명령으로 돌리자 이 저장소에
남아 있던 다른 오류도 함께 드러났다:

- `CatalogEntry` 는 `contracts/studio/contract.ts` 가 export 하지 않는다 (project-editor,
  home-focus-editor). 문서 편집기처럼 generated 에서 지역 타입으로 뽑는다.
- 라우트 파라미터는 `unknown` 으로 들어온다. `params.id` 를 그대로 문자열 자리에 넘기고
  있었다.
- `message("route.auth.returnTo", …)` 는 파라미터를 받는 키로 등록되지 않았다.
- `ReleaseIndexItem` 에는 `summary` 가 없다 — 목록 행은 마크다운을 싣지 않는다. 대신
  공개 경로를 적는다.

목록의 남은 로직도 정리한다. 새 릴리즈를 만들면 그 행을 `aria-current` 로 짚어 준다 —
목록이 길면 어느 것이 새것인지 알기 어렵다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:47:41 +09:00
DongHyeonkaandClaude Opus 5 84d72c4f60 feat: 릴리즈 편집을 자기 주소로 옮긴다
편집 폼이 릴리즈 목록 아래에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고 목록
전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 목록이 길어질수록 편집이
멀어지는 구조다.

`/studio/releases/:id` 를 연다 — 문서와 프로젝트가 각자 편집 주소를 갖는 것과 같은
이유다. 목록의 "편집" 은 토글이 아니라 링크가 되고, 새 릴리즈를 만들면 곧바로 그 화면으로
간다(만들자마자 편집할 것이 분명하다).

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — 아티팩트 기준선 133→134, 증거 개수
112→113, 게이트 형태 다이제스트 재계산(f9e7e521… 을 이전 gates.json 에서 먼저 재현해
계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 01:41:04 +09:00
DongHyeonkaandClaude Opus 5 6b9dc3f0a2 fix: 릴리즈 하단 버튼을 한 줄에 세우고, 로그인을 화면으로 만든다
릴리즈 편집 맨 아래에서 저장과 공개가 서로 다른 높이에 서 있었다.
`.studio-secondary-button` 는 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 를 갖는데
하단 푸터에서 primary 만 그 여백을 풀어 주고 있었다 — 그래서 저장은 위에, 공개와
공개에서 내리기는 24px 아래에 앉았다.

로그인은 화면이 없었다. `ui-page` 위에 제목 한 줄과 버튼 하나가 전부라, 보호된 주소를
열었을 때 페이지처럼 보이지 않았다. 카드 하나로 세우고 eyebrow·제목·설명·행동을 갖춘
뒤, 로그인하면 어디로 돌아오는지까지 적는다 — 그것이 이 화면이 답해야 할 질문이다.

오류 화면과 다른 모양이어야 한다. 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-24 00:56:16 +09:00
DongHyeonkaandClaude Opus 5 f9d20e8946 fix: setState 업데이터 안에서 event.currentTarget 을 읽지 않는다
프로젝트 편집 화면에서 활동 유형이나 제목을 한 글자 치면 화면이 통째로 죽었다 —
"Cannot read properties of null (reading 'value')".

setState 업데이터는 핸들러가 끝난 뒤 다음 렌더에 실행된다. 그때 React 는 이미
`event.currentTarget` 을 null 로 되돌려 놓았으므로, 업데이터 안에서 그것을 읽으면
반드시 터진다. 타입 검사도 lint 도 이것을 잡지 못했고, 첫 입력에서야 드러났다.

값은 핸들러가 도는 동안 지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.

같은 실수를 다시 못 하도록 lint 규칙을 세운다: `setXxx(...)` 에 곧바로 넘기는 화살표
함수 안에서는 `currentTarget` 을 읽을 수 없다. 처음 쓴 선택자는 동기 핸들러까지 잡아
(map 콜백, 즉시 호출되는 update 등 아홉 자리) 너무 넓었으므로, 실제로 위험한 setter
업데이터만 겨냥하도록 좁혔다. 결함을 되돌려 규칙이 잡는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:50:47 +09:00
DongHyeonkaandClaude Opus 5 014f21b9e1 feat: 프로젝트 주제와 활동 연결을 열고, Case 목차를 되살리고, Studio 화면을 기존 디테일에 맞춘다
주요 주제 — 프로젝트 화면은 처음부터 `project.topics` 를 읽고 있었는데 공개 계약에
그 자리가 없었다. `project_topic` 은 테이블도 있고 조인할 수도 있었지만 응답에 실을
곳이 없었고, 고를 화면도 없었다. 계약에 자리를 만들고, 편집 화면에 주제 선택을 붙이고,
공개 조회가 ACTIVE 인 주제만 고른 순서대로 내보내게 한다.

활동 위치 열기 — 그 링크는 활동의 관련 기록을 따라간다. 백엔드는 처음부터 그 경로를
유도하고 있었지만, 활동을 만들 때 기록을 고를 방법이 없어 모든 줄이 관련 기록 없이
저장됐다. 활동 추가 폼에 유형·일시·제목·요약·연결할 기록·노출을 모두 둔다.

Case 목차 — 오른쪽 목차가 어떤 문서에서도 나오지 않았다. Case 렌더러가 둘이었고
어느 쪽을 쓸지 `publicPath === "/cases/collection-fetch-join-pagination"` 이라는 슬러그
비교가 정했다. 설계 픽스처 문서 하나만 breadcrumb·목차가 있는 완성된 배치를 받고, 실제로
작성한 Case 는 전부 축약본으로 떨어졌다. 배치를 하나로 합치고, 목차는 본문에 제목이
있을 때만 그린다.

Studio UI — 새로 만든 화면들이 기존 디테일을 따라가지 않고 있었다. `<label htmlFor>` +
별도 컨트롤 대신 편집기가 쓰는 `<label><span>` 묶음을, 임시로 만든 폼 클래스 대신
`studio-editor-section`·`studio-field-grid` 를 쓴다. 릴리즈 목록 행에는 유형 라벨이
빠져 격자 첫 열이 비어 있었고(제목이 당겨져 다른 목록과 줄이 맞지 않았다) 버튼 둘이
묶이지 않은 채였다. 프로젝트 행에는 단계·공개 범위를 한국어로 적고 "내용 편집" 을
다음 행동으로 둔다.

로그인 화면 — "인증 연동 지점을 확인하기 위한 보호 라우트" 는 이 뼈대를 만들던 사람에게
하는 말이었다. 이 화면을 보는 사람은 글을 쓰러 온 작성자다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 21:22:58 +09:00
DongHyeonkaandClaude Opus 5 16e5b9f4ec feat: 프로젝트를 편집하고 활동을 남길 수 있게 한다
홈의 집중 카드가 제목만 보여 주고 "현재 목표"·"다음 작업" 칸이 비어 있었다.
프로젝트 페이지의 "활동" 도 늘 비어 있었다.

프로젝트를 만들 수는 있었지만 고칠 화면이 없었다. 그래서 이름과 slug 말고는 아무
값도 가질 수 없었고, 그 값을 읽는 공개 화면들은 빈칸을 그렸다 — 백엔드의
`updateProject` 는 처음부터 구현돼 있었고 채울 화면만 없었다.

`/studio/projects/:id` 를 연다. 프로젝트 필드 전부와 활동 목록을 한 화면에 둔다 —
프로젝트 밖의 활동은 존재하지 않고, 무엇이 공개 타임라인에 실리는지를 프로젝트 필드와
같은 자리에서 보는 편이 낫다.

이미 공개된 프로젝트는 저장한 뒤 투영도 함께 갱신한다. 투영은 게시할 때 세워지므로,
저장만 하면 공개 화면에는 예전 값이 남는다.

단계와 활동 유형의 선택지는 DB CHECK 제약과 같은 목록이다. 화면이 더 많은 값을
보여 주면 저장이 제약에서 터지고, 작성자는 왜 안 되는지 알 수 없다.

라우트가 하나 늘어 CI 게이트가 함께 움직였다 — FE-GATE-009 는 설치된 라우트마다 수동
접근성 증거를 하나씩 요구하고 그 집합이 정확히 일치하지 않으면 거절한다. 아티팩트
기준선 132→133, 증거 개수 111→112, 게이트 형태 다이제스트 재계산(이전 상수 187dbd96…
을 이전 gates.json 에서 먼저 재현해 계산 방법을 확인했다), 서빙 패턴 하나 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:45:00 +09:00
DongHyeonkaandClaude Opus 5 0eb3c86839 fix: 새 목록 항목의 id 가 계약을 건너갈 수 있게 한다
관계를 고르고 저장하면 400 이 돌아왔고, 편집기에는 "saveStudioDocument broke its
contract." 한 줄만 남았다. 저장은 통째로 실패했다.

새 관계·규칙·선택지·문장의 id 를 `createLocalId` 로 만들고 있었다. 그 함수는 접두사를
붙여 `relation-<uuid>` 를 돌려준다 — React key 나 Idempotency-Key 로는 맞지만 계약이
그 자리에 요구하는 것은 uuid 다. 서버는 파싱조차 못 하고 InvalidFormatException 으로
거절했다.

목록에 고를 대상이 하나도 없던 동안에는(catalog RELATION/EVIDENCE 가 스텁이었다) 아무도
이 경로를 지나지 않아 드러나지 않았다. 두 결함이 서로를 가리고 있었다.

Case 는 목록 항목이 없어 무사했다. Reference 의 규칙, Question 의 선택지, Decision 의
consequences 는 모두 같은 이유로 저장되지 않았을 것이다.

`createNewItemId` 로 나눈다. 서버는 자기가 소유하지 않은 id 를 어차피 새로 부여하므로
(`StudioRelationStore.replace`) 이 값은 "이 줄은 새것"이라는 표시일 뿐이다 — 지켜야 할
것은 형식뿐이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 19:03:27 +09:00
DongHyeonkaandClaude Opus 5 c87e0a2338 fix: home focus 경로를 계약과 맞춘다
계약이 `/api/v1/studio/home-focus` 에서 `/api/v1/studio/home/focus` 로 옮겼다 —
케밥 세그먼트가 백엔드의 AIP-122 규칙을 위반했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 18:20:32 +09:00
DongHyeonkaandClaude Opus 5 b3aa304975 feat: 프로젝트를 공개할 수 있게 하고, 홈이 무엇을 앞에 둘지 고를 수 있게 한다
공개 화면 다섯 곳이 조용히 비어 있었다. 원인은 하나씩 달랐지만 모두 "값을 채울
방법이 없었다"는 같은 모양이었다.

홈의 "지금 집중하는 것" — `home_focus_config` 는 마이그레이션이 빈 행 하나만
넣어 두었고, 계약에 선언된 `getHomeFocus`/`updateHomeFocus` 는 구현이 없었다.
세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않으므로, 운영에서는 한 번도
나타난 적이 없다. Studio 대시보드에 고르는 화면을 둔다.

홈의 "최근 기록" — 화면이 공개된 프로젝트를 하나씩 돌며 타임라인을 조립했다.
그래서 게시한 문서라도 그 프로젝트가 공개되어 있지 않으면 목록에서 통째로
빠졌고, 실제로 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고
있으므로 그것을 그대로 읽는다. 프로젝트마다 요청을 보내던 N+1 도 사라진다.

프로젝트 공개 — 프로젝트는 `RecordKind` 에 없어 문서 게시 파이프라인을 타지
못하는데, 공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈 focus)은 전부
`public_resource_projection` 의 PROJECT 행을 가시성 관문으로 쓴다. 그 행을
세우는 경로가 없었으므로 프로젝트는 영원히 비공개였다. 계약에 이미 있던
`publishProject`/`unpublishProject` 를 구현하고 주제·프로젝트 화면에 버튼을 둔다.

문서 사이 관계 연결 — `JdbcCatalogQueryAdapter` 의 RELATION/EVIDENCE 가
`List.of()` 스텁이라 어떤 기록도 연결 대상 목록을 채울 수 없었다. RELATION 은
작성 중에 고르는 것이므로 작업본까지 포함하고, EVIDENCE 는 읽는 사람이 따라갈
수 있어야 하므로 공개된 것만 포함한다.

본문 너비 — 문서 한 편이 세 폭으로 갈라져 있었다. 머리말 920px, 유형·프로젝트
줄은 shell 전체 1180px, 본문은 672px 를 가운데 정렬. 셋을 같은 폭·같은 왼쪽
끝에 세우고 읽는 단을 56rem 으로 넓힌다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEHXspz4rv5pB5wiiSsVDu
2026-08-23 17:38:49 +09:00
DongHyeonka c03b0c77b8 fix: 오류 수정 2026-08-22 14:40:49 +09:00
DongHyeonka 1801414592 fix: show the reason the server gave for a failed action
Every failure in the management screens printed a guess. Deleting a working
copy said "it may be published, or something may reference it, or someone may
have edited it first" — three maybes, while the server had answered with
exactly one: "이 기록을 참조하는 곳이 있어 삭제할 수 없습니다". A version
conflict read as "in use" because the same sentence covered both, and an author
watching some deletions succeed and others fail had no way to tell them apart.

The gateway now carries the server's client-safe message and the screens show
it. The canned sentences remain only as a fallback for a failure that never
reached the server.

The topic status label said "사용 중" for every active topic, including one
created seconds earlier that nothing references. Next to a refusal about
records that use a topic, the two read as the same statement. It says "활성"
now, which is what the status means.

Publishing also stops demanding a finished document — the mock validator moves
with the real one, so what an author sees against fixtures matches production.
2026-08-21 19:29:04 +09:00
DongHyeonka 7345500ef3 feat: show the way to publish, and what is blocking it, in the editor
Publishing was reachable only by walking the whole chain blind. The editor
offered one link — "저장본 검증" — and the word "게시" appeared nowhere until
three screens later, so an author with a finished draft could not tell how to
publish it. The working-copy list already named the next step, but the name was
plain text with nowhere to go.

The editor now shows the whole path: 검증 → 미리보기 → 게시, each a link. The
list's next step is a link to that step. Neither weakens the gates — an
unvalidated document is still refused at preview, an unpreviewed one at
publish. What changes is that the order stops being a secret.

What is blocking publication now appears where it gets fixed. The validation
report lived on its own screen, so an author read the list, navigated back, and
had to remember which field each item meant. The editor shows the same issues
above the fields, in red, and says plainly when they describe an older saved
version rather than the current one.

The clock is read during render, not captured as an effect dependency. It is a
new function on every render of the provider, so depending on it refetched the
document endlessly — the editor never settled, and a tab click did not even
register. A value you are asking about now does not belong in a dependency
array.
2026-08-21 18:43:10 +09:00
DongHyeonka fb478f951b fix: say which version a stale validation judged, before showing its errors
The validation screen presented a report from an earlier version with the same
weight as a current one — same status badge, same full error list — and marked
the difference with two small words. An author read a version 2 report on a
version 5 document and understood those errors as the document's present state.
Every error in that list had already been fixed.

The report now leads with what it is: which version it judged, that the
document has changed since, and where to re-run it. The badge stops claiming a
verdict and the issue list recedes, because a judgement about an older version
is not a verdict about this one.
2026-08-21 18:10:51 +09:00
DongHyeonka 7289ce97bb fix: stop the smoke sweep reporting an expected 404 as a failure
A document with no preview yet answers 404 when the screen asks for its current
one, and the screen turns that into "make a preview". The sweep counted it as a
failure, so every run ended with the same red line under a healthy deployment.

A check that cries wolf on every run stops being read, and a real failure would
have sat unnoticed beside it. The session probe's 401 before sign-in is the
same kind of expected answer and is excluded on the same terms; every other
4xx and 5xx still fails the sweep.
2026-08-21 18:00:14 +09:00
DongHyeonka 348420618d fix: stop claiming a 1x1 size for an image whose dimensions are unknown
An uploaded figure never appeared, and no request for it was ever made. The
resolver substituted 1x1 when an asset carried no dimensions, and a 1x1 box
with `loading="lazy"` never enters the viewport — so the browser had no reason
to fetch it. The image was not failing to load; it was never asked for.

Unknown dimensions now say so. The figure omits the attributes and loads
eagerly, letting the browser size the image from the file, and reserves layout
space only when the size is actually known. Guessing a number to fill an
attribute is what turned a missing measurement into a missing picture.
2026-08-21 17:57:10 +09:00
102 changed files with 4194 additions and 951 deletions
+1
View File
@@ -29,3 +29,4 @@ artifacts/tests/visual/
# Git worktrees created inside the repository. A worktree is a checkout, not
# source: committing one would nest a second working copy inside this one.
.worktrees/
.playwright-mcp/
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_PROJECT_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_PROJECT_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
@@ -0,0 +1,18 @@
# TECH_LOG_STUDIO_RELEASE_EDIT accessibility review
Status: pending-manual-review
Route ID: TECH_LOG_STUDIO_RELEASE_EDIT
Release ID:
Reviewer:
Reviewed at:
Signature:
Attestation: pending
M1 Keyboard: pending
M2 Visible focus: pending
M3 Route focus: pending
M4 Modal focus: pending
M5 Error association: pending
M6 Color signal: pending
M7 Reduced motion: pending
Screen reader: pending
Notes: Human review pending; automated evidence does not replace signed keyboard, focus, and screen-reader review.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+14
View File
@@ -1202,12 +1202,24 @@
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_PROJECT_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASES.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_RELEASE_EDIT.md",
"schemaId": "markdown",
"production": "source-controlled"
},
{
"id": "artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"path": "artifacts/tests/a11y-manual/TECH_LOG_STUDIO_NOT_FOUND.md",
@@ -1962,7 +1974,9 @@
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PUBLICATION-PREVIEW-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-ASSETS-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-TAXONOMY-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-PROJECT-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASES-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-RELEASE-EDIT-md",
"artifact-artifacts-tests-a11y-manual-TECH-LOG-STUDIO-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-NOT-FOUND-md",
"artifact-artifacts-tests-a11y-manual-report-json"
+2 -2
View File
@@ -5,8 +5,8 @@
"registrationAllowed": false,
"loginTheme": "keycloak",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400,
"roles": {
"realm": [
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
+14
View File
@@ -594,6 +594,20 @@ const commonSecurityRules = {
"CallExpression[callee.object.name='document'][callee.property.name='createElement'][arguments.0.value='script']",
message: "Runtime script construction is prohibited by FE-OC-019.",
},
{
/*
setState 업데이터는 핸들러가 끝난 뒤, 다음 렌더에 실행된다. 그때 React 는 이미
`event.currentTarget` 을 null 로 되돌려 놓았으므로 업데이터 안에서 그것을 읽으면
"Cannot read properties of null" 로 화면이 통째로 죽는다.
타입 검사도 lint 도 잡지 못했고, 첫 입력에서야 드러났다 — 값은 핸들러가 도는 동안
지역 변수로 꺼내 두고 업데이터에는 그 값을 넘긴다.
*/
selector:
"CallExpression[callee.name=/^set[A-Z]/] > ArrowFunctionExpression MemberExpression[property.name='currentTarget']",
message:
"Read event.currentTarget before the setState updater runs — it is null by the time the updater is called.",
},
],
};
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+9 -9
View File
@@ -38,28 +38,28 @@
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:38329cfe645e1d6cbfc7a9bb3e20e23b579a286ad0d1254982c70de5c6055ae8",
"setDigest": "sha256:cdcfb628a502d71596f1162726eb395aad0f5f92cf05fd77d304f8e51c81b2fc",
"packages": [
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"runtimeProtocolVersion": 1,
"sourceRevision": "65a04fc"
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/public-contract",
"version": "2.0.0",
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
"version": "2.1.0",
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"runtimeProtocolVersion": 1,
"sourceRevision": "65a04fc"
"sourceRevision": "ef49d3a"
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
"version": "3.1.0",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"runtimeProtocolVersion": 1,
"sourceRevision": "65a04fc"
"sourceRevision": "ef49d3a"
}
]
}
+13 -3
View File
@@ -468,7 +468,12 @@ const CANONICAL_GATE_SHAPE_SHA256 =
// TECH_LOG_STUDIO_RELEASES evidence artifact, by the same method — 8c73d447…
// was first reproduced from the previous gates.json, so the computation that
// produced this value is known to be the one the constant was pinned under.
"187dbd9676b7b3444409b5af4143d49927b1eacc67f57a099f4a3765bb64d865";
// 프로젝트 편집 화면: FE-GATE-009 가 TECH_LOG_STUDIO_PROJECT_EDIT 증거 아티팩트를
// 얻어 다시 계산했다. 같은 방법이다 — 187dbd96… 을 이전 gates.json 에서 먼저 재현해,
// 이 값을 만든 계산이 상수가 고정될 때 쓰인 그 계산임을 확인했다.
// 릴리즈 편집 화면: 같은 방법으로 다시 계산했다. f9e7e521… 을 이전 gates.json 에서 먼저
// 재현했다.
"fb138e7c51fdf969f755cd8ff32cf627f1750b966d212c33ee996c8c578db0e3";
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
const normalized = gates.map(
@@ -523,8 +528,13 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[]
// Alignment follow-up, item 2 added the TechLog junit report.
// The taxonomy route added its own manual a11y evidence file — every installed
// route carries one, and the gate checks that the two sets match exactly.
if (contract.artifacts.length !== 132) {
failures.push(`artifact authority baseline must contain exactly 132 artifacts; received ${contract.artifacts.length}`);
// The project edit route did the same: it is what finally lets a project carry
// a purpose, a current objective, and a next step, so the public screens that
// read those fields stop rendering blanks.
// The release edit route followed: the editor used to open below the release
// list, so editing meant scrolling past every release to reach it.
if (contract.artifacts.length !== 134) {
failures.push(`artifact authority baseline must contain exactly 134 artifacts; received ${contract.artifacts.length}`);
}
if (contract.stages.length !== 5) {
failures.push(`stage authority baseline must contain exactly 5 stages; received ${contract.stages.length}`);
+7 -3
View File
@@ -34,9 +34,13 @@ async function visit(
request(): { method(): string };
}) => {
const u = new URL(r.url()).pathname;
if (r.status() >= 400 && u.startsWith("/api") && !u.includes("/studio/session")) {
problems.push(`${r.status()} ${r.request().method()} ${u}`);
}
if (r.status() < 400 || !u.startsWith("/api")) return;
// 두 가지는 화면이 다루는 정상 상태다: 로그인 전 세션 탐침의 401, 그리고 아직 미리보기를
// 만들지 않은 문서의 404. 이것들을 실패로 세면 매번 같은 줄이 뜨고, 진짜 실패가 그 사이에
// 묻힌다 — 늑대가 왔다고 매번 외치는 점검은 아무도 읽지 않는다.
if (u.includes("/studio/session")) return;
if (r.status() === 404 && r.request().method() === "GET" && u.endsWith("/preview")) return;
problems.push(`${r.status()} ${r.request().method()} ${u}`);
};
page.on("console", onConsole);
page.on("response", onResponse);
@@ -1,5 +1,10 @@
import type {
CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectActivityRequest,
ProjectActivityResponse,
UpdateProjectActivityRequest,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
@@ -10,6 +15,7 @@ import type {
TopicEdit,
} from "../../contracts/management/contract.ts";
import type { ManagementGateway } from "../../application/ports/management-gateway.ts";
import { ManagementGatewayError } from "../../application/ports/management-gateway-error.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
export type { ManagementGateway };
@@ -23,17 +29,10 @@ const ROUTE_ID = "TECH_LOG_STUDIO";
* Studio 쪽 상태 처리를 그대로 쓴다. 여기서 Result 로 감싸면 이 표면만 다른 규약이 된다.
*/
export class ManagementGatewayError extends Error {
readonly operationId: string;
readonly code: string;
constructor(operationId: string, code: string) {
super(`${operationId}: ${code}`);
this.name = "ManagementGatewayError";
this.operationId = operationId;
this.code = code;
}
}
export {
ManagementGatewayError,
managementFailureMessage,
} from "../../application/ports/management-gateway-error.ts";
export function createHttpManagementGateway(
deps: Readonly<{ operations: StudioOperationExecutor }>,
@@ -46,7 +45,11 @@ export function createHttpManagementGateway(
// the code under `error` — reading `problem.code` found nothing and every
// failure surfaced as the literal "PROBLEM", matching no i18n key.
const body = outcome.problem as
| Readonly<{ code?: unknown; error?: Readonly<{ code?: unknown }> }>
| Readonly<{
code?: unknown;
detail?: unknown;
error?: Readonly<{ code?: unknown; message?: unknown }>;
}>
| null;
const code =
typeof body?.code === "string"
@@ -54,9 +57,15 @@ export function createHttpManagementGateway(
: typeof body?.error?.code === "string"
? body.error.code
: "PROBLEM";
throw new ManagementGatewayError(operationId, code);
const detail =
typeof body?.error?.message === "string"
? body.error.message
: typeof body?.detail === "string"
? body.detail
: "";
throw new ManagementGatewayError(operationId, code, detail);
}
throw new ManagementGatewayError(operationId, outcome.kind);
throw new ManagementGatewayError(operationId, outcome.kind, "");
}
return Object.freeze({
@@ -81,6 +90,28 @@ export function createHttpManagementGateway(
deleteRelease: async (id: string, expectedVersion: number) => {
await run<void>("deleteRelease", { id, expectedVersion });
},
listProjectActivities: (id: string) =>
run<ProjectActivityResponse[]>("listStudioProjectActivities", { id }),
createProjectActivity: (id: string, body: ProjectActivityRequest) =>
run<ProjectActivityResponse>("createProjectActivity", { id, body }),
updateProjectActivity: (
id: string,
activityId: string,
body: UpdateProjectActivityRequest,
) => run<ProjectActivityResponse>("updateProjectActivity", { id, activityId, body }),
deleteProjectActivity: async (id: string, activityId: string, expectedVersion: number) => {
await run<void>("deleteProjectActivity", { id, activityId, expectedVersion });
},
publishProject: (
id: string,
expectedVersion: number,
visibility: "PUBLIC" | "UNLISTED" = "PUBLIC",
) => run<PublishResponse>("publishProject", { id, expectedVersion, visibility }),
unpublishProject: (id: string, expectedVersion: number) =>
run<ProjectEditResponse>("unpublishProject", { id, expectedVersion }),
getHomeFocus: () => run<HomeFocusResponse>("getHomeFocus", {}),
updateHomeFocus: (body: HomeFocusRequest) =>
run<HomeFocusResponse>("updateHomeFocus", body),
publishRelease: (id: string, expectedVersion: number) =>
run<PublishResponse>("publishRelease", { id, expectedVersion }),
archiveRelease: (id: string, expectedVersion: number) =>
@@ -1,5 +1,6 @@
import type {
HomeFocusItem,
LatestRecordEntry,
ProjectActivity,
ProjectDecision,
Project,
@@ -19,12 +20,12 @@ import {
decisionItemToDecision,
flattenRelations,
knowledgeListItemToRecord,
markdownLines,
markdownSections,
questionListItemToRecord,
releaseDetailToRelease,
searchItemToEntity,
} from "./public-content-mapping.ts";
import type { components } from "../../contracts/public/generated.ts";
import type { StudioOperationExecutor } from "./http-studio-gateway.ts";
const ROUTE_ID = "TECH_LOG_PUBLIC";
@@ -141,13 +142,19 @@ export function createHttpPublicContentGateway(
const canonicalPath = String(detail.canonicalPath ?? "");
const groups = (detail.relations as Readonly<Record<string, never>>) ?? {};
const projectOf = (entry: Readonly<{ title?: string; path?: string }> | undefined) =>
entry?.path
? { name: entry.title ?? "", slug: entry.path.split("/").filter(Boolean).pop() ?? "", path: entry.path }
: undefined;
if (kind === "CASE") {
const body = (detail.case as Readonly<Record<string, unknown>>) ?? {};
return Object.freeze({
...baseOf("CASE", slug, {
title: body.title as string,
summary: body.problemSummary as string,
// 제목 바로 아래에 오는 것은 문서의 요약이다. 유형별 요약(문제/범위)을 쓰면 바로 아래
// 블록과 같은 글을 두 번 말한다.
summary: (body.summary as string) ?? "",
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
@@ -162,12 +169,27 @@ export function createHttpPublicContentGateway(
kind: "CASE",
problem: (body.problemSummary as string) ?? "",
conclusion: (body.conclusionSummary as string) ?? "",
environment: ((body.environmentSummary as readonly string[]) ?? []).join(", "),
// The Case document renders a verification line. The contract has no
// field for it — verification lives in the body — so it stays empty
// rather than being guessed from a heading.
verification: "",
// `environmentSummary` 는 검증 환경과 재현 조건을 그 순서로 담는다 — 서버가 비어 있지
// 않은 것만 순서대로 넣는다. 예전에는 둘을 쉼표로 이어 붙여 한 칸에 넣고 재현 조건 칸은
// "계약에 없다"며 비워 두었는데, 계약에는 있었고 채우는 쪽이 없었을 뿐이다.
environment: ((body.environmentSummary as readonly string[]) ?? [])[0] ?? "",
verification: ((body.environmentSummary as readonly string[]) ?? [])[1] ?? "",
lastVerifiedLabel: dateLabel(body.lastVerifiedAt as string),
content: (body.content as string) ?? "",
bodyAssets: Object.freeze(
((body.bodyAssets as readonly Readonly<Record<string, unknown>>[]) ?? []).map((asset) =>
Object.freeze({
assetKey: asset.assetKey as string,
assetId: asset.assetId as string,
url: asset.url as string,
contentType: asset.contentType as string,
altText: (asset.altText as string) ?? "",
width: (asset.width as number) ?? null,
height: (asset.height as number) ?? null,
decorative: Boolean(asset.decorative),
}),
),
),
sections: markdownSections(body.content as string),
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
@@ -177,7 +199,7 @@ export function createHttpPublicContentGateway(
return Object.freeze({
...baseOf("REFERENCE", slug, {
title: body.title as string,
summary: body.purposeSummary as string,
summary: (body.summary as string) ?? "",
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
@@ -189,48 +211,90 @@ export function createHttpPublicContentGateway(
}),
}),
kind: "REFERENCE",
purpose: (body.purposeSummary as string) ?? "",
/*
여기서 읽는 이름은 계약이 실제로 주는 이름이어야 한다. 한때 `purposeSummary`,
`applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown` 을 읽었는데 계약에는 그런
칸이 없다 — 전부 undefined 로 떨어져 공개 Reference 화면이 통째로 비었다. Studio 에서는
같은 글이 다 보이므로 "공개 쪽만 안 나온다" 로 드러났다.
규칙과 예시는 `content` 마크다운을 잘라 만드는 것이 아니라 계약이 구조로 준다. Studio 의
편집기가 제목과 본문을 따로 받기 때문이다.
*/
purpose: (body.scopeSummary as string) ?? "",
rules: Object.freeze(
markdownSections(body.content as string).map((section) => ({
title: section.title,
body: section.paragraphs.join("\n"),
})),
((body.rules as readonly Readonly<Record<string, unknown>>[] | undefined) ?? []).map(
(rule) => ({
title: String(rule.title ?? ""),
body: String(rule.body ?? ""),
}),
),
),
applyWhen: Object.freeze(markdownLines(body.applyWhenMarkdown as string)),
exceptions: Object.freeze(markdownLines(body.exceptionsMarkdown as string)),
examples: Object.freeze(markdownLines(body.examplesMarkdown as string)),
applyWhen: Object.freeze(((body.appliesTo as readonly string[] | undefined) ?? []).map(String)),
exceptions: Object.freeze(
((body.excludedScope as readonly string[] | undefined) ?? []).map(String),
),
examples: Object.freeze(((body.examples as readonly string[] | undefined) ?? []).map(String)),
verifiedAt: dateLabel(body.lastVerifiedAt as string),
}) as unknown as Extract<PublicRecord, { kind: K }>;
}
const body = (detail.question as Readonly<Record<string, unknown>>) ?? {};
const points = (body.points as readonly Readonly<Record<string, unknown>>[] | undefined) ?? [];
const pointsOf = (group: string) =>
Object.freeze(
points
.filter((point) => point.group === group)
.flatMap((point) => (point.items as readonly string[] | undefined) ?? []),
);
/*
`points` 는 그룹 이름을 키로 갖는 객체다 — 계약의 `QuestionPointGroup`. 여기서는
`{group, items}` 배열로 읽으면서 `.filter` 를 불렀고, 객체에는 그런 것이 없으니 상세
화면이 통째로 「요청을 처리하지 못했습니다」가 됐다. 목록은 이 칸을 비워 두고 만들기
때문에 탐색에서는 멀쩡히 보였고, 그래서 "게시했는데 안 뜬다" 로만 드러났다.
`as` 캐스트가 그 어긋남을 타입 검사에서 가렸다. 계약의 타입을 그대로 쓰면 다음에 모양이
바뀔 때 컴파일이 먼저 막는다.
*/
type QuestionPoints = components["schemas"]["QuestionPointGroup"];
const points = body.points as QuestionPoints | undefined;
const pointsOf = (group: keyof QuestionPoints) =>
Object.freeze([...(points?.[group] ?? [])].map(String));
return Object.freeze({
...baseOf("QUESTION", slug, {
title: body.question as string,
summary: body.summary as string,
path: canonicalPath,
primaryTopic: body.primaryTopic as never,
primaryProject: body.primaryProject as never,
/*
질문 상세는 프로젝트를 `question` 이 아니라 `relations.primaryProject` 에 담는다 —
Case/Reference 와 다른 자리다. `question` 에서 찾고 있었으므로 머리말의 프로젝트
칸이 늘 비어 있었다.
그 자리의 값은 `RelatedEntry` 라 `title`/`path` 를 쓴다. 머리말이 기다리는 것은
`name`/`slug` 이므로 여기서 옮겨 준다 — slug 는 경로의 마지막 마디다.
*/
primaryProject: projectOf(groups.primaryProject) as never,
publishedAt: body.updatedAt as string,
relations: flattenRelations(groups, {
derivedCases: "이 질문에서 나온 기록",
projectDecisions: "이 질문이 이끈 결정",
relatedQuestions: "관련 질문",
}),
/*
계약이 주는 이름은 `resultCase` / `producedDecision` / `derivedReferences` 다.
여기서는 `derivedCases` / `projectDecisions` / `relatedQuestions` 를 찾고 있었고,
하나도 맞지 않아 이유 자리에 영문 키가 그대로 나왔다.
`primaryProject` 는 관계가 아니라 이 질문이 속한 프로젝트다 — 머리말이 이미
보여 주므로 관계 목록에 넣지 않는다.
*/
relations: flattenRelations(
{
resultCase: groups.resultCase,
producedDecision: groups.producedDecision,
derivedReferences: groups.derivedReferences,
},
{
resultCase: "이 질문에서 나온 기록",
producedDecision: "이 질문이 이끈 결정",
derivedReferences: "이 질문에서 정리된 기준",
},
),
}),
kind: "QUESTION",
questionStatus: (body.status as QuestionRecord["questionStatus"]) ?? "OPEN",
facts: pointsOf("KNOWN_FACT"),
assumptions: pointsOf("ASSUMPTION"),
unknowns: pointsOf("UNRESOLVED"),
constraints: pointsOf("CONSTRAINT"),
facts: pointsOf("facts"),
assumptions: pointsOf("assumptions"),
unknowns: pointsOf("unknowns"),
constraints: pointsOf("constraints"),
options: Object.freeze([]),
nextValidation: (body.nextVerification as string) ?? "",
}) as unknown as Extract<PublicRecord, { kind: K }>;
@@ -305,6 +369,33 @@ export function createHttpPublicContentGateway(
);
}
/**
* 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 —
* 서버가 이미 걸러 보낸다.
*/
async function getLatestEntries(): Promise<LatestRecordEntry[]> {
const home = await read<Readonly<{ latestEntries?: readonly Readonly<Record<string, unknown>>[] }>>(
"getPublicHome",
{},
);
if (home === NOT_FOUND) return [];
return (home.latestEntries ?? []).map((entry) => {
const topic = entry.primaryTopic as Readonly<Record<string, unknown>> | null | undefined;
const project = entry.primaryProject as Readonly<Record<string, unknown>> | null | undefined;
const path = String(entry.path ?? "");
return Object.freeze({
id: `${String(entry.entryType ?? "")}:${path}`,
entryType: String(entry.entryType ?? "CASE") as LatestRecordEntry["entryType"],
title: String(entry.title ?? ""),
summary: String(entry.summary ?? ""),
path,
publishedAt: String(entry.publishedAt ?? ""),
topic: topic ? String(topic.name ?? "") : "",
project: project ? String(project.name ?? "") : "",
});
});
}
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
"getPublicHome",
@@ -448,6 +539,7 @@ export function createHttpPublicContentGateway(
getProjectDecisions,
getProjectActivity,
getHomeFocusItems,
getLatestEntries,
searchPublicContent,
});
}
@@ -194,6 +194,9 @@ export function knowledgeListItemToRecord(item: Readonly<Record<string, unknown>
environment: "",
verification: "",
lastVerifiedLabel: dateLabel(item.lastVerifiedAt as string),
// 목록 항목은 본문을 담지 않는다 — 본문은 상세 조회에서만 온다.
content: "",
bodyAssets: Object.freeze([]),
sections: Object.freeze([]),
}) as CaseRecord;
}
@@ -170,16 +170,16 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
const has = (id: string | null, type: CatalogEntry["type"]) => Boolean(id && dependencies.catalog.some((entry) => entry.id === id && entry.type === type));
if (blank(document.title)) error("TITLE_REQUIRED", "/title", "제목을 입력하세요.");
if (blank(document.slug)) error("SLUG_REQUIRED", "/slug", "slug를 입력하세요."); else if (dependencies.documents.some((item) => item.id !== document.id && item.slug === document.slug)) error("SLUG_DUPLICATE", "/slug", "중복 slug입니다.");
if (blank(document.summary)) error("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
if (!has(document.topicId, "TOPIC")) error("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
if (blank(document.summary)) warning("SUMMARY_REQUIRED", "/summary", "요약을 입력하세요.");
if (!has(document.topicId, "TOPIC")) warning("TOPIC_REQUIRED", "/topicId", "Topic을 선택하세요.");
if (!document.projectId) {
if (document.kind === "PROJECT_DECISION") error("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
if (document.kind === "PROJECT_DECISION") warning("DECISION_PROJECT_REQUIRED", "/projectId", "Decision에는 Project가 필요합니다.");
else warning("PROJECT_MISSING", "/projectId", "Project 연결을 권장합니다.");
} else if (!has(document.projectId, "PROJECT")) error("PROJECT_NOT_FOUND", "/projectId", "Project를 찾을 수 없습니다.");
document.relations.forEach((relation, index) => { if (!has(relation.targetId, "RELATION")) error("RELATION_TARGET_NOT_FOUND", `/relations/${index}/targetId`, "관계 대상을 찾을 수 없습니다."); });
if (document.kind === "CASE") {
if (blank(document.problem)) error("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) error("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
if (blank(document.bodyMarkdown)) error("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
if (blank(document.problem)) warning("CASE_PROBLEM_REQUIRED", "/problem", "문제를 입력하세요."); if (blank(document.conclusion)) warning("CASE_CONCLUSION_REQUIRED", "/conclusion", "결론을 입력하세요.");
if (blank(document.bodyMarkdown)) warning("CASE_BODY_REQUIRED", "/bodyMarkdown", "본문을 입력하세요.");
else try {
const evidenceCatalog = [...dependencies.catalog, ...evidenceCatalogEntriesFromAssets(dependencies.assets)];
// The key gate is the preview projection's gate, verbatim: a resolvable
@@ -202,20 +202,20 @@ export function validateWorkingCopy(document: WorkingCopy, dependencies: Validat
}
}
} catch { error("CONTENT_FORMAT_INVALID", "/bodyMarkdown", "지원하는 문법을 사용하세요."); }
if (!document.lastVerifiedOn) error("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
if (!document.lastVerifiedOn) warning("LAST_VERIFIED_ON_REQUIRED", "/lastVerifiedOn", "검증일을 입력하세요."); else if (dependencies.now.getTime() - Date.parse(`${document.lastVerifiedOn}T00:00:00Z`) > 30 * 86_400_000) warning("VERIFICATION_OLDER_THAN_30_DAYS", "/lastVerifiedOn", "30일이 지났습니다.");
} else if (document.kind === "REFERENCE") {
if (blank(document.purpose)) error("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) error("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) error("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) error("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
if (blank(document.purpose)) warning("REFERENCE_PURPOSE_REQUIRED", "/purpose", "목적을 입력하세요."); if (!document.rules.length) warning("REFERENCE_RULE_REQUIRED", "/rules", "규칙이 필요합니다."); if (!document.applyWhen.length) warning("REFERENCE_APPLY_WHEN_REQUIRED", "/applyWhen", "적용 조건이 필요합니다."); if (!document.verifiedOn) warning("VERIFIED_ON_REQUIRED", "/verifiedOn", "검증일이 필요합니다."); if (!document.examples.length) warning("REFERENCE_EXAMPLE_MISSING", "/examples", "예시를 권장합니다.");
} else if (document.kind === "QUESTION") {
if (!document.questionStatus) error("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) error("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) error("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
if (!document.questionStatus) warning("QUESTION_STATUS_REQUIRED", "/questionStatus", "상태가 필요합니다."); if (blank(document.nextValidation)) warning("NEXT_VALIDATION_REQUIRED", "/nextValidation", "다음 검증이 필요합니다."); if (!document.facts.length) warning("QUESTION_FACT_REQUIRED", "/facts", "사실이 필요합니다.");
if (document.questionStatus === "OPEN") { if (!document.unknowns.length) error("QUESTION_UNKNOWN_REQUIRED", "/unknowns", "미확인 사항이 필요합니다."); if (document.resolution) error("OPEN_QUESTION_RESOLUTION_FORBIDDEN", "/resolution", "열린 질문에는 결론을 둘 수 없습니다."); }
if (document.questionStatus === "RESOLVED") { if (!document.resolution) error("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) error("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) error("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
if (document.questionStatus === "RESOLVED") { if (!document.resolution) warning("QUESTION_RESOLUTION_REQUIRED", "/resolution", "해결 내용이 필요합니다."); else { if (blank(document.resolution.summary)) warning("RESOLUTION_SUMMARY_REQUIRED", "/resolution/summary", "요약이 필요합니다."); if (!has(document.resolution.evidenceTargetId, "EVIDENCE")) error("RESOLUTION_EVIDENCE_REQUIRED", "/resolution/evidenceTargetId", "근거가 필요합니다."); if (blank(document.resolution.linkLabel)) warning("RESOLUTION_LINK_LABEL_REQUIRED", "/resolution/linkLabel", "링크 문구가 필요합니다."); } }
if (document.options.length < 2) warning("QUESTION_OPTIONS_FEWER_THAN_TWO", "/options", "선택지 두 개를 권장합니다.");
} else {
if (!document.decisionStatus) error("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
if (!document.decidedOn) error("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
if (blank(document.statement)) error("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
if (blank(document.rationale)) error("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
if (!document.consequences.length) error("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
if (!document.decisionStatus) warning("DECISION_STATUS_REQUIRED", "/decisionStatus", "결정 상태가 필요합니다.");
if (!document.decidedOn) warning("DECIDED_ON_REQUIRED", "/decidedOn", "결정일이 필요합니다.");
if (blank(document.statement)) warning("DECISION_STATEMENT_REQUIRED", "/statement", "결정문이 필요합니다.");
if (blank(document.rationale)) warning("DECISION_RATIONALE_REQUIRED", "/rationale", "판단 이유가 필요합니다.");
if (!document.consequences.length) warning("DECISION_CONSEQUENCE_REQUIRED", "/consequences", "영향이 하나 이상 필요합니다.");
if (!document.relations.length) error("DECISION_EVIDENCE_REQUIRED", "/relations", "근거 기록이 하나 이상 필요합니다.");
}
const validatedAt = dependencies.now.toISOString();
@@ -35,6 +35,18 @@ type PublicRecordBase = {
relations: ReadonlyArray<PublicRelation>;
};
/** 본문이 `:::evidence key="..."` 로 가리키는 Asset. 계약의 `BodyAsset` 과 같은 모양이다. */
export type PublicBodyAsset = {
assetKey: string;
assetId: string;
url: string;
contentType: string;
altText: string;
width: number | null;
height: number | null;
decorative: boolean;
};
export type CaseRecord = PublicRecordBase & {
kind: "CASE";
problem: string;
@@ -42,6 +54,9 @@ export type CaseRecord = PublicRecordBase & {
environment: string;
verification: string;
lastVerifiedLabel: string;
/** 본문 Markdown 원문. 정적 기록은 문서 화면이 자체 본문을 쓰므로 비어 있다. */
content: string;
bodyAssets: ReadonlyArray<PublicBodyAsset>;
sections: ReadonlyArray<RecordSection>;
};
@@ -173,6 +188,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
environment: "PostgreSQL 16 · Hibernate 6 · Spring Data JPA",
verification: "FeedItem 100개, Zipf 편중 Highlight/Mention",
lastVerifiedLabel: "2026.08.11",
content: "",
bodyAssets: [],
sections: [
{
id: "fix-the-problem",
@@ -256,6 +273,8 @@ export const publicRecords: ReadonlyArray<PublicRecord> = [
environment: "Spring Boot · Redis · Testcontainers",
verification: "동일한 Port 계약으로 In-memory와 Redis Adapter 계약 테스트 실행",
lastVerifiedLabel: "2026.08.07",
content: "",
bodyAssets: [],
sections: [
{
id: "ownership",
@@ -11,6 +11,7 @@ import {
type HomeFocusItem,
} from "./public-content.ts";
import type {
LatestRecordEntry,
PublicContentQueries,
PublicTopic,
} from "../../application/ports/public-content-queries.ts";
@@ -53,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
.filter(
(record) =>
!hasTopicFilter ||
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
)
.filter(
@@ -119,6 +121,38 @@ export function listTopics(): PublicTopic[] {
.map((entry) => Object.freeze(entry));
}
/**
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
* 프로젝트 활동과 릴리스가 원천이다.
*/
export function getLatestEntries(): LatestRecordEntry[] {
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
const activities = projects.flatMap((project) =>
project.activity.map((activity) => {
const record = recordByPath.get(activity.recordPath ?? activity.path);
const published = activity.type === "PUBLICATION" && record;
return {
id: activity.id,
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
title: published ? record.title : activity.title,
summary: activity.summary,
path: activity.path,
publishedAt: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
};
}),
);
/*
릴리스는 넣지 않는다. 이 목록은 서버의 `latestEntries` 와 같은 의미여야 하고, 그쪽은 공개
투영에서 고르므로 릴리스가 없다 — 릴리스는 Publication 파이프라인을 거치지 않는다. 홈 화면이
릴리스를 따로 읽어 합치므로, 여기서도 넣으면 같은 릴리스가 두 번 나온다.
*/
return [...activities].sort((left, right) =>
right.publishedAt.localeCompare(left.publishedAt),
);
}
export function getHomeFocusItems(): HomeFocusItem[] {
const project = getProject("backend-skeleton");
const question = getRecord("QUESTION", "validate-edge-token-again");
@@ -264,6 +298,9 @@ export const publicContentQueries = Object.freeze({
async listTopics() {
return listTopics();
},
async getLatestEntries() {
return getLatestEntries();
},
async getHomeFocusItems() {
return getHomeFocusItems();
},
@@ -0,0 +1,32 @@
/**
* 관리 표면의 실패.
*
* <p>어댑터가 아니라 포트 계층에 두는 이유는 화면이 이것을 읽어야 하기 때문이다 — `presentation`
* 은 `adapters` 를 보지 않는다 (`feature-presentation-does-not-know-outbound-adapters`).
* Studio 쪽 `studio-gateway-error.ts` 가 같은 이유로 같은 자리에 있다.
*/
export class ManagementGatewayError extends Error {
readonly operationId: string;
readonly code: string;
/**
* 서버가 준, 사람이 읽을 수 있는 이유.
*
* <p>이것이 없어서 화면은 실패할 때마다 자기가 지어낸 문구를 보여 줬다 — "게시 중이거나,
* 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다" 같은 추측 셋. 서버는 정확히
* 무엇인지 알고 그것을 보내 주는데도 그랬고, 그래서 버전 충돌도 "사용 중" 으로 읽혔다.
*/
readonly detail: string;
constructor(operationId: string, code: string, detail: string) {
super(`${operationId}: ${code}`);
this.name = "ManagementGatewayError";
this.operationId = operationId;
this.code = code;
this.detail = detail;
}
}
/** 실패에서 서버가 준 문구를 꺼낸다. 없으면 부른 쪽이 준 기본값을 쓴다. */
export function managementFailureMessage(error: unknown, fallback: string): string {
return error instanceof ManagementGatewayError && error.detail ? error.detail : fallback;
}
@@ -1,5 +1,10 @@
import type {
CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectActivityRequest,
ProjectActivityResponse,
UpdateProjectActivityRequest,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
@@ -27,6 +32,33 @@ export type ManagementGateway = Readonly<{
createProject(title: string): Promise<CreateDraftResponse>;
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
deleteProject(id: string, expectedVersion: number): Promise<void>;
/**
* 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를
* 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의
* "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도
* 나타나지 않는다.
*/
publishProject(
id: string,
expectedVersion: number,
visibility?: "PUBLIC" | "UNLISTED",
): Promise<PublishResponse>;
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
/**
* 프로젝트 활동. 공개 프로젝트 화면의 "활동" 은 이 목록을 투영 없이 직접 읽으므로, 여기서
* 만든 줄이 곧 그 화면이다.
*/
listProjectActivities(id: string): Promise<ProjectActivityResponse[]>;
createProjectActivity(id: string, body: ProjectActivityRequest): Promise<ProjectActivityResponse>;
updateProjectActivity(
id: string,
activityId: string,
body: UpdateProjectActivityRequest,
): Promise<ProjectActivityResponse>;
deleteProjectActivity(id: string, activityId: string, expectedVersion: number): Promise<void>;
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
getHomeFocus(): Promise<HomeFocusResponse>;
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
getRelease(id: string): Promise<ReleaseEditResponse>;
createRelease(title: string): Promise<CreateDraftResponse>;
@@ -36,6 +36,23 @@ type PublicRecordBase = {
relations: ReadonlyArray<PublicRelation>;
};
/**
* 본문이 `:::evidence key="..."` 로 가리키는 Asset.
*
* <p>본문에는 key 만 있고 `/media/{assetId}` 는 UUID 로만 서빙하므로 — 주소가 추측 불가능한 것이
* 의도된 성질이다 — 공개 화면이 key 를 주소로 바꾸려면 이 대응이 함께 와야 한다.
*/
export type PublicBodyAsset = {
assetKey: string;
assetId: string;
url: string;
contentType: string;
altText: string;
width: number | null;
height: number | null;
decorative: boolean;
};
export type CaseRecord = PublicRecordBase & {
kind: "CASE";
problem: string;
@@ -43,6 +60,14 @@ export type CaseRecord = PublicRecordBase & {
environment: string;
verification: string;
lastVerifiedLabel: string;
/**
* 본문 Markdown 원문.
*
* <p>`sections` 는 이것을 제목·문단·불릿으로만 줄인 것이라 표·코드·callout·evidence 가 사라진다.
* 문서 화면은 원문을 직접 파싱한다.
*/
content: string;
bodyAssets: ReadonlyArray<PublicBodyAsset>;
sections: ReadonlyArray<RecordSection>;
};
@@ -139,6 +164,24 @@ export type PublicTopic = {
recordCount: number;
};
/**
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
*
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
*/
export type LatestRecordEntry = {
id: string;
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
title: string;
summary: string;
path: string;
publishedAt: string;
topic: string;
project: string;
};
export type FocusKey = "current" | "question" | "decision";
export type HomeFocusItem = {
@@ -202,6 +245,7 @@ export type PublicContentQueries = Readonly<{
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
*/
listTopics(): Promise<PublicTopic[]>;
getLatestEntries(): Promise<LatestRecordEntry[]>;
getHomeFocusItems(): Promise<HomeFocusItem[]>;
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
}>;
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
"sourceRevision": "65a04fc",
"digest": "sha256:72650735061fde627f5037571eb986cb758f44a546f065c88408399f8eec4a55",
"sourceRevision": "ef49d3a",
"operationIds": [
"createCaseDraft",
"getCaseForEdit",
@@ -83,6 +83,7 @@
"updateHomeFocus",
"listStudioProjectActivities",
"createProjectActivity",
"updateProjectActivity"
"updateProjectActivity",
"deleteProjectActivity"
]
}
@@ -15,3 +15,8 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
export type PublishResponse = Schemas["PublishResponse"];
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
export type ProjectActivityRequest = Schemas["ProjectActivityRequest"];
export type UpdateProjectActivityRequest = Schemas["UpdateProjectActivityRequest"];
export type ProjectActivityResponse = Schemas["ProjectActivityResponse"];
@@ -841,7 +841,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/studio/home-focus": {
"/api/v1/studio/home/focus": {
parameters: {
query?: never;
header?: never;
@@ -883,7 +883,12 @@ export interface paths {
get?: never;
put: operations["updateProjectActivity"];
post?: never;
delete?: never;
/** @description . ,
* .
*
* `AUTO`
* . `MANUAL` . */
delete: operations["deleteProjectActivity"];
options?: never;
head?: never;
patch?: never;
@@ -963,6 +968,24 @@ export interface components {
data: components["schemas"]["ReleaseEditResponse"];
meta: components["schemas"]["ResponseMeta"];
};
HomeFocusResponseEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["HomeFocusResponse"];
meta: components["schemas"]["ResponseMeta"];
};
ProjectActivityResponseEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["ProjectActivityResponse"];
meta: components["schemas"]["ResponseMeta"];
};
ProjectActivityListEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["ProjectActivityResponse"][];
meta: components["schemas"]["ResponseMeta"];
};
PublishResponseEnvelope: {
/** @constant */
success: true;
@@ -5519,7 +5542,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublishResponse"];
"application/json": components["schemas"]["PublishResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -5528,7 +5551,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -5537,7 +5560,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -5546,7 +5569,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -5555,7 +5578,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -5564,7 +5587,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -5573,7 +5596,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -5582,7 +5605,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -5610,7 +5633,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectEditResponse"];
"application/json": components["schemas"]["ProjectEditResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -5619,7 +5642,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -5628,7 +5651,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -5637,7 +5660,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -5646,7 +5669,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -5655,7 +5678,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -5664,7 +5687,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -5673,7 +5696,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8117,7 +8140,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HomeFocusResponse"];
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -8126,7 +8149,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -8135,7 +8158,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -8144,7 +8167,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -8153,7 +8176,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -8162,7 +8185,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8188,7 +8211,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HomeFocusResponse"];
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -8197,7 +8220,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -8206,7 +8229,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -8215,7 +8238,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -8224,7 +8247,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -8233,7 +8256,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -8242,7 +8265,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -8251,7 +8274,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8273,7 +8296,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectActivityResponse"][];
"application/json": components["schemas"]["ProjectActivityListEnvelope"];
};
};
/** @description 401 */
@@ -8282,7 +8305,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 403 */
@@ -8291,7 +8314,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 404 */
@@ -8300,7 +8323,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 500 */
@@ -8309,7 +8332,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8337,7 +8360,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectActivityResponse"];
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
};
};
/** @description 400 */
@@ -8346,7 +8369,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 401 */
@@ -8355,7 +8378,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 403 */
@@ -8364,7 +8387,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 404 */
@@ -8373,7 +8396,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 409 */
@@ -8382,7 +8405,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 422 */
@@ -8391,7 +8414,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 500 */
@@ -8400,7 +8423,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8429,7 +8452,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectActivityResponse"];
"application/json": components["schemas"]["ProjectActivityResponseEnvelope"];
};
};
/** @description 400 */
@@ -8438,7 +8461,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 401 */
@@ -8447,7 +8470,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 403 */
@@ -8456,7 +8479,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 404 */
@@ -8465,7 +8488,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 409 */
@@ -8474,7 +8497,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 422 */
@@ -8483,7 +8506,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description 500 */
@@ -8492,7 +8515,97 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
deleteProjectActivity: {
parameters: {
query?: never;
header: {
"X-CSRF-TOKEN": components["parameters"]["CsrfToken"];
};
path: {
id: string;
activityId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ExpectedVersionRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -3015,49 +3015,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/PublishResponse'
$ref: '#/components/schemas/PublishResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -3085,49 +3085,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectEditResponse'
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -4973,7 +4973,7 @@ paths:
$ref: '#/components/schemas/ExpectedVersionRequest'
security:
- sessionCookie: []
/api/v1/studio/home-focus:
/api/v1/studio/home/focus:
get:
operationId: getHomeFocus
tags:
@@ -4985,37 +4985,37 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/HomeFocusResponse'
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
security:
- sessionCookie: []
put:
@@ -5030,49 +5030,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/HomeFocusResponse'
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -5101,33 +5101,31 @@ paths:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityListEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
post:
operationId: createProjectActivity
tags:
@@ -5154,49 +5152,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
'400':
description: '400'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: '409'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: '422'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
/api/v1/studio/projects/{id}/activities/{activityId}:
put:
operationId: updateProjectActivity
@@ -5230,49 +5228,126 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectActivityResponse'
$ref: '#/components/schemas/ProjectActivityResponseEnvelope'
'400':
description: '400'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: '401'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: '403'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: '404'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: '409'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: '422'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: '500'
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
delete:
operationId: deleteProjectActivity
tags:
- Projects
description: |-
프로젝트 활동 한 줄을 지운다. 계약에는 만들기와 고치기만 있었고 지우기가 없어서, 잘못
적은 줄이 공개 타임라인에 영구히 남았다.
`AUTO` 로 기록된 줄은 게시 같은 실제 사건의 흔적이므로 지우지 않는다 — 지우면 무슨 일이
있었는지가 사라진다. 손으로 적은 `MANUAL` 줄만 지울 수 있다.
parameters:
- name: id
in: path
required: true
schema:
type: string
format: uuid
- name: activityId
in: path
required: true
schema:
type: string
format: uuid
- $ref: '#/components/parameters/CsrfToken'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ExpectedVersionRequest'
responses:
'204':
description: No Content
'400':
description: Bad Request
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorEnvelope'
security:
- sessionCookie: []
components:
securitySchemes:
sessionCookie:
@@ -5510,6 +5585,53 @@ components:
$ref: '#/components/schemas/ReleaseEditResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
HomeFocusResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/HomeFocusResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
ProjectActivityResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/ProjectActivityResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
ProjectActivityListEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
type: array
items:
$ref: '#/components/schemas/ProjectActivityResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
PublishResponseEnvelope:
type: object
additionalProperties: false
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/public-contract",
"version": "2.0.0",
"digest": "sha256:6575a09317a1ffe951747b12102ad2cf884110007426a45d6f59a53b65612d59",
"sourceRevision": "65a04fc",
"version": "2.1.0",
"digest": "sha256:7eb668e39e279e49767306dd36e1dd51302071c39d78495d21307bbd9676220e",
"sourceRevision": "ef49d3a",
"operationIds": [
"getPublicSite",
"getPublicHome",
@@ -373,6 +373,30 @@ export interface components {
height?: number;
contentType?: string;
};
/** @description 본문이 `:::evidence key="..."` 로 가리키는 Asset 이다.
*
* 본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID
* 로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수
* 없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을
* 함께 준다.
*
* 목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset
* 을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다.
* */
BodyAsset: {
assetKey: string;
/** Format: uuid */
assetId: string;
url: string;
contentType: string;
altText?: string;
width?: number;
height?: number;
/** @description 장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개
* 화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다.
* */
decorative: boolean;
};
RelatedEntry: {
/** @enum {string} */
type: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT" | "PROJECT_DECISION" | "RELEASE";
@@ -430,7 +454,7 @@ export interface components {
};
LatestEntry: {
/** @enum {string} */
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
entryType: "CASE" | "REFERENCE" | "QUESTION" | "PROJECT_ACTIVITY" | "RELEASE";
title: string;
summary: string;
path: string;
@@ -512,6 +536,7 @@ export interface components {
indexable: boolean;
case: {
title: string;
summary?: string;
problemSummary: string;
conclusionSummary: string;
environmentSummary?: string[];
@@ -523,6 +548,10 @@ export interface components {
tags: components["schemas"]["TagSummary"][];
primaryProject?: components["schemas"]["ProjectSummary"];
coverAsset?: components["schemas"]["AssetReference"];
/** @description 본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
* Asset 이 더 이상 서빙되지 않는 경우다.
* */
bodyAssets?: components["schemas"]["BodyAsset"][];
/** Format: date-time */
publishedAt: string;
/** Format: date-time */
@@ -543,9 +572,15 @@ export interface components {
indexable: boolean;
reference: {
title: string;
summary?: string;
scopeSummary: string;
appliesTo: string[];
excludedScope: string[];
rules?: {
title: string;
body: string;
}[];
examples?: string[];
/** @enum {string} */
freshnessStatus: "CURRENT" | "REVIEW_DUE" | "HISTORICAL";
content: string;
@@ -642,6 +677,9 @@ export interface components {
nextStep?: string;
systemOverviewMarkdown?: string;
technologies?: string[];
/** @description 이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
* 화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다. */
topics?: components["schemas"]["TopicSummary"][];
/** Format: date-time */
updatedAt: string;
};
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Tech Log Public API
version: 2.0.0
version: 2.1.0
description: |
Tech Log 공개 조회 계약이다. 인증이 필요하지 않다.
@@ -815,6 +815,47 @@ components:
type: integer
contentType:
type: string
BodyAsset:
type: object
description: |
본문이 `:::evidence key="..."` 로 가리키는 Asset 이다.
본문은 Markdown 원문으로 나가고 그 안에는 key 만 있는데, `/media/{assetId}` 는 UUID
로만 서빙한다 — 주소가 추측 불가능한 것이 의도된 성질이므로 key 에서 주소를 만들 수
없다. 그래서 공개 화면이 key 를 해석할 수 있도록, 게시된 기록이 실제로 참조하는 Asset 을
함께 준다.
목록은 게시 시점에 고정된 `PUBLISHED` scope 의 참조에서 온다. 게시 이후 작업본이 Asset
을 바꿔도 이미 공개된 본문이 가리키는 대상은 달라지지 않는다.
required:
- assetKey
- assetId
- url
- contentType
- decorative
properties:
assetKey:
type: string
minLength: 1
maxLength: 200
assetId:
type: string
format: uuid
url:
type: string
contentType:
type: string
altText:
type: string
width:
type: integer
height:
type: integer
decorative:
type: boolean
description: |
장식용이면 대체 텍스트가 비어 있어도 된다. 게시 검증이 이 값으로 판정하므로 공개
화면도 같은 값을 보고 `alt` 를 정해야 판정과 표시가 어긋나지 않는다.
RelatedEntry:
type: object
required:
@@ -981,6 +1022,7 @@ components:
enum:
- CASE
- REFERENCE
- QUESTION
- PROJECT_ACTIVITY
- RELEASE
title:
@@ -1198,6 +1240,12 @@ components:
properties:
title:
type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
problemSummary:
type: string
conclusionSummary:
@@ -1224,6 +1272,13 @@ components:
$ref: '#/components/schemas/ProjectSummary'
coverAsset:
$ref: '#/components/schemas/AssetReference'
bodyAssets:
type: array
description: |
본문이 참조하는 Asset. 비어 있을 수 있다 — 본문에 evidence 가 없거나, 참조한
Asset 이 더 이상 서빙되지 않는 경우다.
items:
$ref: '#/components/schemas/BodyAsset'
publishedAt: *id003
updatedAt: *id003
lastVerifiedAt: *id003
@@ -1277,6 +1332,12 @@ components:
properties:
title:
type: string
# 문서가 스스로 밝히는 한 줄 요약이다. 제목 바로 아래에 온다.
#
# 이 자리가 없어서 화면은 problemSummary / scopeSummary 를 대신 썼고, 그러면 머리말이
# 바로 아래의 "문제" 나 "이 기준을 쓰는 이유" 와 같은 글을 두 번 말한다.
summary:
type: string
scopeSummary:
type: string
appliesTo:
@@ -1287,6 +1348,23 @@ components:
type: array
items:
type: string
# Reference 의 본문은 `content` 마크다운이 아니라 이 두 칸에 있다. Studio 의 Reference
# 편집기는 규칙(제목+본문)과 예시를 따로 받고 body_markdown 은 비워 두므로, 이것을
# 내보내지 않으면 공개 화면에 판단 기준과 예시가 통째로 빠진다.
rules:
type: array
items:
type: object
required: [title, body]
properties:
title:
type: string
body:
type: string
examples:
type: array
items:
type: string
freshnessStatus:
type: string
enum:
@@ -1524,6 +1602,13 @@ components:
type: array
items:
type: string
topics:
type: array
description: |-
이 프로젝트가 다루는 주제. 프로젝트 화면의 "주요 주제" 가 이 목록을 그린다.
화면은 처음부터 이 값을 읽고 있었지만 계약에 자리가 없어 늘 비어 있었다.
items:
$ref: '#/components/schemas/TopicSummary'
updatedAt: *id003
featuredDecision:
$ref: '#/components/schemas/RelatedEntry'
@@ -1,8 +1,8 @@
{
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:5229865c3d242f19d75030d3f524a44dfebbf444324068d6ae88e43b8047dba4",
"sourceRevision": "65a04fc",
"version": "3.1.0",
"digest": "sha256:18dd46898be64b07f7e826409d19347512613ee2e22420028a4a0644f50f37dd",
"sourceRevision": "ef49d3a",
"operationIds": [
"getStudioSession",
"getStudioDashboard",
@@ -1074,7 +1074,32 @@ export interface components {
height: number | null;
decorative: boolean;
};
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"];
/** @description `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
* */
ThematicBreakBlock: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "THEMATIC_BREAK";
};
/** @description `![alt](/media/...)` 로 쓴 그림이다.
*
* `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
* 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
* 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
* */
ImageBlock: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "IMAGE";
src: string;
alt: string;
title: string | null;
};
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"];
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @enum {string} */
kind: "CASE";
@@ -1142,7 +1167,7 @@ export interface components {
/** @enum {string} */
status: "PROPOSED" | "ADOPTED";
/** Format: date */
decidedOn: string;
decidedOn: string | null;
statement: string;
rationale: string;
consequences: components["schemas"]["OrderedText"][];
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Tech Log Studio API
version: 3.0.0
version: 3.1.0
description: |
Tech Log Studio orchestration 계약이다.
@@ -871,7 +871,7 @@ components:
required: [id, text, order]
properties:
id: { type: string, format: uuid }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
order: { type: integer, minimum: 0 }
ReferenceRule:
type: object
@@ -1242,7 +1242,7 @@ components:
kind: { $ref: "#/components/schemas/RecordKind" }
slug: { type: string, minLength: 3, maxLength: 100, pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$" }
title: { type: string, minLength: 1, maxLength: 120 }
summary: { type: string, minLength: 1, maxLength: 300 }
summary: { type: string, maxLength: 300 }
publicPath: { type: string, minLength: 1, maxLength: 500 }
topic: { $ref: "#/components/schemas/DisplayTarget" }
project:
@@ -1257,7 +1257,7 @@ components:
required: [type, text]
properties:
type: { type: string, enum: [TEXT] }
text: { type: string, minLength: 1, maxLength: 100000 }
text: { type: string, maxLength: 100000 }
InlineContainer:
type: object
required: [type, children]
@@ -1321,7 +1321,10 @@ components:
properties:
type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 }
level: { type: integer, minimum: 2, maximum: 4 }
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
level: { type: integer, minimum: 1, maximum: 6 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ParagraphBlock:
type: object
@@ -1452,6 +1455,29 @@ components:
width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 }
decorative: { type: boolean }
ThematicBreakBlock:
type: object
additionalProperties: false
description: |
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
required: [type]
properties:
type: { type: string, enum: [THEMATIC_BREAK] }
ImageBlock:
type: object
additionalProperties: false
description: |
`![alt](/media/...)` 로 쓴 그림이다.
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
required: [type, src, alt, title]
properties:
type: { type: string, enum: [IMAGE] }
src: { type: string, minLength: 1, maxLength: 500 }
alt: { type: string, maxLength: 300 }
title: { type: [string, "null"], maxLength: 300 }
CaseRenderBlock:
oneOf:
- { $ref: "#/components/schemas/HeadingBlock" }
@@ -1463,6 +1489,8 @@ components:
- { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
- { $ref: "#/components/schemas/ThematicBreakBlock" }
- { $ref: "#/components/schemas/ImageBlock" }
discriminator:
propertyName: type
mapping:
@@ -1475,6 +1503,8 @@ components:
DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
IMAGE: "#/components/schemas/ImageBlock"
CasePublicRenderModel:
unevaluatedProperties: false
allOf:
@@ -1483,8 +1513,8 @@ components:
required: [kind, problem, conclusion, environment, reproduction, lastVerifiedOn, bodyBlocks]
properties:
kind: { type: string, enum: [CASE] }
problem: { type: string, minLength: 1, maxLength: 100000 }
conclusion: { type: string, minLength: 1, maxLength: 100000 }
problem: { type: string, maxLength: 100000 }
conclusion: { type: string, maxLength: 100000 }
environment: { type: string, maxLength: 100000 }
reproduction: { type: string, maxLength: 100000 }
lastVerifiedOn: { type: string, format: date }
@@ -1497,7 +1527,7 @@ components:
required: [kind, purpose, rules, applyWhen, exceptions, examples, verifiedOn]
properties:
kind: { type: string, enum: [REFERENCE] }
purpose: { type: string, minLength: 1, maxLength: 100000 }
purpose: { type: string, maxLength: 100000 }
rules: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/ReferenceRule" } }
applyWhen: { type: array, minItems: 1, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
exceptions: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
@@ -1508,7 +1538,7 @@ components:
additionalProperties: false
required: [summary, evidenceTarget, linkLabel]
properties:
summary: { type: string, minLength: 1, maxLength: 100000 }
summary: { type: string, maxLength: 100000 }
evidenceTarget: { $ref: "#/components/schemas/DisplayTarget" }
linkLabel: { type: string, minLength: 1, maxLength: 120 }
QuestionPublicRenderModel:
@@ -1528,7 +1558,7 @@ components:
unknowns: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
constraints: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
options: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/QuestionOption" } }
nextValidation: { type: string, minLength: 1, maxLength: 100000 }
nextValidation: { type: string, maxLength: 100000 }
resolution:
oneOf:
- { $ref: "#/components/schemas/ResolvedQuestionResolution" }
@@ -1542,9 +1572,12 @@ components:
properties:
kind: { type: string, enum: [PROJECT_DECISION] }
status: { type: string, enum: [PROPOSED, ADOPTED] }
decidedOn: { type: string, format: date }
statement: { type: string, minLength: 1, maxLength: 100000 }
rationale: { type: string, minLength: 1, maxLength: 100000 }
# 결정일은 비어 있을 수 있다. 검증은 이것을 경고로만 다루므로(DECIDED_ON_REQUIRED)
# 날짜 없이 게시할 수 있는데, 렌더 모델이 필수로 요구하면 그 문서는 미리보기조차
# 열리지 않는다 — 두 규칙이 어긋나면 작성자는 "경고라며 왜 안 되냐"를 만난다.
decidedOn: { type: [string, "null"], format: date }
statement: { type: string, maxLength: 100000 }
rationale: { type: string, maxLength: 100000 }
consequences: { type: array, maxItems: 50, items: { $ref: "#/components/schemas/OrderedText" } }
PublicRenderModel:
oneOf:
@@ -310,6 +310,103 @@ const HTTP_CONTRACTS = Object.freeze([
});
},
),
readOperation("listStudioProjectActivities", `${P}/{id}/activities`, 262_144, byId),
writeOperation(
"createProjectActivity",
"POST",
`${P}/{id}/activities`,
{ acceptedStatuses: [201], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; body: unknown }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"updateProjectActivity",
"PUT",
`${P}/{id}/activities/{activityId}`,
{ acceptedStatuses: [200], requestByteLimit: 16_384, responseByteLimit: 16_384 },
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
activityId: string;
body: unknown;
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
queryEntries: NO_QUERY,
body: value.body,
});
},
),
writeOperation(
"deleteProjectActivity",
"DELETE",
`${P}/{id}/activities/{activityId}`,
{
acceptedStatuses: [204],
emptyBodyStatuses: [204],
requestByteLimit: 1_024,
responseByteLimit: 1_024,
},
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
activityId: string;
expectedVersion: number;
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id, activityId: value.activityId }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
writeOperation(
"publishProject",
"POST",
`${P}/{id}/publish`,
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
(input: never) => {
const value = input as unknown as Readonly<{
id: string;
expectedVersion: number;
visibility: "PUBLIC" | "UNLISTED";
}>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion, visibility: value.visibility },
});
},
),
writeOperation(
"unpublishProject",
"POST",
`${P}/{id}/unpublish`,
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 },
(input: never) => {
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
return Object.freeze({
pathValues: Object.freeze({ id: value.id }),
queryEntries: NO_QUERY,
body: { expectedVersion: value.expectedVersion },
});
},
),
readOperation("getHomeFocus", `${D}/home/focus`, 8_192),
writeOperation(
"updateHomeFocus",
"PUT",
`${D}/home/focus`,
{ acceptedStatuses: [200], requestByteLimit: 4_096, responseByteLimit: 8_192 },
(input: never) =>
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
),
writeOperation(
"publishRelease",
"POST",
@@ -49,7 +49,9 @@ const TECH_LOG_ROUTE_SPECS = [
defineSpec({ routeId: "TECH_LOG_STUDIO_PUBLICATION_PREVIEW", path: "/studio/publications/:publicationEventId/preview", layoutGroup: "STUDIO", paramsSchema: "TechLogPublicationEventIdParams", searchSchema: null, title: "게시 Snapshot", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_ASSETS", path: "/studio/assets", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "Asset", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_TAXONOMY", path: "/studio/taxonomy", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "주제와 프로젝트", navigationLabel: "주제·프로젝트", navigationOrder: 40 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_PROJECT_EDIT", path: "/studio/projects/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "프로젝트 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASES", path: "/studio/releases", layoutGroup: "STUDIO", paramsSchema: null, searchSchema: null, title: "릴리즈", navigationLabel: "릴리즈", navigationOrder: 50 }),
defineSpec({ routeId: "TECH_LOG_STUDIO_RELEASE_EDIT", path: "/studio/releases/:id", layoutGroup: "STUDIO", paramsSchema: "TechLogDocumentIdParams", searchSchema: null, title: "릴리즈 편집", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "TECH_LOG_STUDIO_NOT_FOUND", path: "/studio/*", layoutGroup: "STUDIO", paramsSchema: "TechLogStudioSplat", searchSchema: null, title: "Studio 화면을 찾을 수 없습니다", navigationLabel: null, navigationOrder: null }),
defineSpec({ routeId: "NOT_FOUND", path: "*", layoutGroup: "PUBLIC", paramsSchema: "NotFoundSplat", searchSchema: null, title: "페이지를 찾을 수 없습니다.", navigationLabel: null, navigationOrder: null }),
] as const;
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
return line;
}
/*
`:::name key="value"` 를 remark-directive 가 읽는 `:::name{key="value"}` 로 바꾼다.
이름과 나머지 사이의 경계를 `\s` 로 못 박는 것이 중요하다. 예전에는 이름을
`[a-z0-9-]*` 로 두고 나머지가 `{` 로 시작하지 않기만 요구했는데, 정규식이 되돌아가며
이름의 마지막 글자를 나머지 쪽으로 넘겨 그 조건을 피해 갔다 — `:::note` 는 이름 `not`
에 본문 `e` 가 되어 `:::not{e}` 로, 이미 중괄호를 쓴 `:::table{id="t"}` 는
`:::tabl{e{id="t"}}` 로 망가졌다. 그래서 속성 없는 디렉티브는 이름이 통째로 바뀌고,
중괄호 형태는 아예 해석되지 않았다.
*/
return line.replace(
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
(
_match,
marker: string,
attributes: string,
newline: string,
) => `${marker}{${attributes.trim()}}${newline}`,
) => {
const trimmed = attributes.trim();
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
},
);
})
.join("");
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
const trustedRelativeLinkOrigin = "https://techlog.invalid";
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
note: "참고",
tip: "도움말",
warning: "주의",
danger: "위험",
};
function hasAsciiControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
@@ -326,8 +347,8 @@ function headingBlock(
node: Heading,
usedIds: Set<string>,
): components["schemas"]["HeadingBlock"] {
if (node.depth < 2 || node.depth > 4) {
invalid(node, "only heading levels 2 through 4 are supported");
if (node.depth < 1 || node.depth > 6) {
invalid(node, "only heading levels 1 through 6 are supported");
}
const children = [...node.children];
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
return inlineFromNodes(cell.children);
}
/**
* `:::table` 없이 쓴 GFM 표에 붙일 값.
*
* <p>서버 렌더러는 파이프 표를 그대로 읽는다 — `:::table` 이라는 directive 자체를 모른다.
* 여기서만 감싸기를 요구하면 같은 본문이 Studio 와 공개 화면에서 다르게 읽히므로, 감싸지 않은
* 표도 받는다. `id` 는 자리 순서로 만들고 `caption` 은 비운다. 설명이 필요하면 `:::table` 로
* 감싸 `caption` 을 주면 된다.
*/
function bareTableAttributes(index: number): Record<string, string> {
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
}
function tableBlock(
node: ContainerDirective,
node: ContainerDirective | Table,
usedIds: Set<string>,
bareIndex?: number,
): components["schemas"]["DataTableBlock"] {
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
if (node.children.length !== 1 || node.children[0].type !== "table") {
invalid(node, "table directive must contain exactly one GFM table");
const bare = node.type === "table";
const attributes = bare
? bareTableAttributes(bareIndex!)
: attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]);
if (!bare) {
const container = node as ContainerDirective;
if (container.children.length !== 1 || container.children[0].type !== "table") {
invalid(node, "table directive must contain exactly one GFM table");
}
}
const table = node.children[0] as Table;
const table = (bare ? node : (node as ContainerDirective).children[0]) as Table;
if (table.children.length === 0) invalid(table, "table header is required");
const id = attributes.id;
@@ -506,6 +546,25 @@ function directiveBlock(
content: inlineFromNodes((node.children[0] as Paragraph).children),
};
}
/*
서버 렌더러가 아는 callout 이름이다(`note`/`tip` 은 정보, `warning`/`danger` 는 경고).
`:::callout tone="..."` 만 받으면 서버가 정상으로 읽는 본문을 여기서 거절하게 되므로 둘 다
받는다. 이름이 곧 tone 이라 속성이 없고, 라벨은 이름에서 만든다.
*/
case "note":
case "tip":
case "warning":
case "danger": {
if (node.children.length !== 1 || node.children[0].type !== "paragraph") {
invalid(node, `${node.name} directive must contain exactly one paragraph`);
}
return {
type: "CALLOUT",
tone: node.name === "note" || node.name === "tip" ? "info" : "warning",
label: CALLOUT_LABELS[node.name],
content: inlineFromNodes((node.children[0] as Paragraph).children),
};
}
case "evidence": {
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
if (node.children.length !== 0) {
@@ -533,10 +592,34 @@ function directiveBlock(
}
}
/**
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
*
* <p>Markdown 에서 `![alt](/media/...)` 는 문단 안의 inline 이다. 인라인 유니온에는 그림이 없고
* 앞으로도 둘 이유가 없다 — 글 가운데 끼워 넣는 그림은 이 문서 형식이 다루는 대상이 아니다.
* 그래서 "문단이 그림 하나로만 이루어진 경우"만 블록으로 올린다.
*/
function imageBlockOf(node: Paragraph): components["schemas"]["ImageBlock"] | null {
const visible = node.children.filter(
(child) => !(child.type === "text" && child.value.trim() === ""),
);
if (visible.length !== 1) return null;
const only = visible[0]!;
if (only.type !== "image") return null;
const image = only as unknown as { url: string; alt?: string | null; title?: string | null };
if (!isSafeLink(image.url)) invalid(node, `unsafe image URL: ${image.url}`);
return {
type: "IMAGE",
src: image.url,
alt: image.alt ?? "",
title: image.title ?? null,
};
}
function paragraphBlock(
node: Paragraph,
): components["schemas"]["ParagraphBlock"] {
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
}
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
listItemCount += 1;
return `list-item-${listItemCount}`;
};
let bareTableCount = 0;
return tree.children.map((node: Content): CaseAuthoringBlock => {
switch (node.type) {
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
return codeBlock(node);
case "containerDirective":
return directiveBlock(node, usedIds);
case "html":
case "table": {
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
// 공개 화면에서 다르게 읽힌다.
bareTableCount += 1;
return tableBlock(node, usedIds, bareTableCount);
}
case "thematicBreak":
return { type: "THEMATIC_BREAK" };
case "html":
case "definition":
case "yaml":
case "table":
case "footnoteDefinition":
case "leafDirective":
return invalid(node, `unsupported block syntax: ${node.type}`);
@@ -131,7 +131,18 @@ function renderContext(
function publicPath(input: WorkingCopyInput, project: CatalogEntry | null) {
if (input.kind === "PROJECT_DECISION") {
if (!project?.publicPath) fail("PROJECT public path is required");
/*
Decision 의 공개 주소는 자기 slug 가 아니라 프로젝트 주소 아래에 있다. 그래서 프로젝트가
공개되어 있지 않으면 이 문서에는 아직 주소가 없다.
예전 문구는 "PROJECT public path is required" 였다. 사실이지만 작성자가 할 일을 말해 주지
않는다 — 무엇을 어디서 눌러야 하는지 적는다.
*/
if (!project?.publicPath) {
fail(
"이 Decision 의 공개 주소는 프로젝트 주소 아래에 있습니다. 주제·프로젝트 화면에서 이 프로젝트를 먼저 게시해 주세요.",
);
}
return `${project.publicPath}/decisions#${input.slug}`;
}
const prefix =
@@ -186,7 +197,7 @@ export function projectWorkingCopy(
const project = catalogEntry(catalog, input.projectId, "PROJECT", false);
if (!topic) fail("TOPIC catalog entry is required");
if (input.kind === "PROJECT_DECISION" && !project) {
fail("PROJECT catalog entry is required");
fail("Decision 은 프로젝트에 속합니다. 기본 정보에서 프로젝트를 골라 주세요.");
}
const base = {
@@ -273,12 +284,16 @@ export function projectWorkingCopy(
case "PROJECT_DECISION":
if (!input.decisionStatus) fail("Decision status is required");
if (!input.decidedOn) fail("Decision date is required");
/*
결정일은 비어 있어도 모델을 만든다. 검증이 그것을 경고로만 다루므로 날짜 없이 게시할 수
있는데, 여기서 막으면 그 문서는 미리보기조차 열리지 않는다 — 작성자는 "경고라면서 왜
안 되냐"를 만난다. 화면이 "미정"이라고 말하면 된다.
*/
return {
...base,
kind: "PROJECT_DECISION",
status: input.decisionStatus,
decidedOn: input.decidedOn,
decidedOn: input.decidedOn ?? null,
statement: input.statement,
rationale: input.rationale,
consequences: ordered(input.consequences),
@@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string {
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
":::",
].join("\n");
case "THEMATIC_BREAK":
return "---";
case "IMAGE":
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
// 않는다.
return block.title === null
? `![${escapeText(block.alt)}](${block.src})`
: `![${escapeText(block.alt)}](${block.src} "${block.title.replaceAll('"', '\\"')}")`;
default:
return assertNever(block);
}
@@ -11,3 +11,28 @@ export function createLocalId(
const entropy = Math.floor(random() * 1_000_000_000).toString().padStart(9, "0");
return `${prefix}-${now()}-${entropy}`;
}
/**
* 편집기가 새로 만든 목록 항목(관계·근거·규칙·선택지·순서 있는 문장)의 id.
*
* `createLocalId` 와 나눠 두는 이유는 이 값이 화면 밖으로 나가기 때문이다. 그쪽은 접두사를 붙여
* `relation-<uuid>` 같은 문자열을 만들고, 그건 React key 나 Idempotency-Key 로는 좋지만 계약에는
* 넣을 수 없다 — 계약이 요구하는 형식은 uuid 이고, 접두사가 붙은 값은 서버가 파싱조차 하지 못해
* 400 (`InvalidFormatException`) 이 된다. 저장 버튼이 "계약을 어겼다"는 말만 남기고 아무것도 저장하지
* 않던 이유가 이것이었다.
*
* 서버는 자기가 소유하지 않은 id 를 신뢰하지 않고 새로 부여한다({@code StudioRelationStore.replace}).
* 그래서 여기서 만드는 값은 "이 줄은 새것"이라는 표시일 뿐, 저장 뒤의 진짜 id 는 서버가 정한다.
*/
export function createNewItemId(
source: RandomUuidSource | null | undefined = globalThis.crypto,
random: () => number = Math.random,
): string {
const uuid = source?.randomUUID?.();
if (uuid) return uuid;
// randomUUID 가 없는 환경(비보안 컨텍스트)을 위한 대비. 형식만 uuid v4 를 지키면 된다 — 값 자체는
// 서버가 어차피 새로 부여한다.
const hex = (length: number) =>
Array.from({ length }, () => Math.floor(random() * 16).toString(16)).join("");
return `${hex(8)}-${hex(4)}-4${hex(3)}-${"89ab"[Math.floor(random() * 4)]}${hex(3)}-${hex(12)}`;
}
@@ -180,7 +180,42 @@ function resolvePublicEvidenceAssetDescriptor(
};
}
/**
* 게시된 본문을 블록으로 바꾼다.
*
* <p>예전에는 하드코딩된 슬러그 하나만 진짜 파서를 탔고 나머지는 모두 {@link genericCaseBlocks}
* 를 거쳤다 — 정규식이 `##` 제목과 `-` 불릿만 알아보므로 표·코드·callout 은 물론 evidence
* directive 까지 글자 그대로 문단이 되어 공개 화면에 그대로 보였다.
*
* <p>본문이 지원하지 않는 문법을 담고 있으면 화면 전체를 잃는 대신 예전 방식으로 돌아간다.
* 읽는 사람에게는 덜 정확한 화면이 빈 화면보다 낫다.
*/
function caseBodyBlocks(record: CaseRecord): CaseAuthoringBlock[] {
if (record.slug === "collection-fetch-join-pagination") {
return parseCaseContent(fetchJoinBody);
}
if (!record.content.trim()) return genericCaseBlocks(record);
try {
return parseCaseContent(record.content);
} catch {
return genericCaseBlocks(record);
}
}
export function CaseDocumentPage({ record }: { record: CaseRecord }) {
/*
본문은 evidence 를 key 로만 가리키고 `/media/{assetId}` 는 UUID 로만 서빙하므로, 계약이 함께
준 `bodyAssets` 로 key 를 주소로 바꾼다. 대응이 없는 key 는 그 블록을 지운다 — 해석기가
던지면 문서 전체가 사라지고, 남겨 두면 주소 없는 그림 자리가 남는다.
*/
const assetsByKey = new Map(record.bodyAssets.map((asset) => [asset.assetKey, asset]));
const blocks = caseBodyBlocks(record).filter(
(block) =>
block.type !== "EVIDENCE_FIGURE" ||
record.slug === "collection-fetch-join-pagination" ||
assetsByKey.has(block.key),
);
const model = resolveCaseEvidenceAssets(
{
...publicRenderModelBase(record),
@@ -190,18 +225,37 @@ export function CaseDocumentPage({ record }: { record: CaseRecord }) {
environment: record.environment,
reproduction: record.verification,
lastVerifiedOn: record.lastVerifiedLabel.replaceAll(".", "-"),
bodyBlocks:
record.slug === "collection-fetch-join-pagination"
? parseCaseContent(fetchJoinBody)
: genericCaseBlocks(record),
bodyBlocks: blocks,
},
(key) => {
const asset = assetsByKey.get(key);
if (!asset) return resolvePublicEvidenceAssetDescriptor(key);
return {
assetId: asset.assetId,
assetKey: asset.assetKey,
mediaType: asset.contentType,
publicPath: asset.url,
width: asset.width,
height: asset.height,
decorative: asset.decorative,
};
},
resolvePublicEvidenceAssetDescriptor,
);
return (
<PublicRecordRenderer
model={model}
resolveEvidenceAsset={resolvePublicEvidenceAsset}
resolveEvidenceAsset={(key) => {
const asset = assetsByKey.get(key);
if (!asset) return resolvePublicEvidenceAsset(key);
return {
src: asset.url,
width: asset.width ?? 0,
height: asset.height ?? 0,
triggerLabel: `${asset.altText || asset.assetKey} 크게 보기`,
dialogLabel: asset.altText || asset.assetKey,
};
}}
resolvePublishedLabel={(path) =>
path === record.path ? record.publishedLabel : undefined
}
@@ -37,7 +37,18 @@ export function ExploreFilterForm({
);
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
return {
topics: [...new Set(records.map((record) => record.topic))].sort(),
// 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
topics: [
...new Map(
records
.filter((record) => record.topicSlug)
.map((record) => [record.topicSlug, record.topic] as const),
),
]
.map(([slug, name]) => ({ slug, name }))
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
projects: resolved
.filter((item) => item !== undefined)
.map((item) => ({ slug: item.slug, title: item.title })),
@@ -47,8 +58,10 @@ export function ExploreFilterForm({
const projects = view.data?.projects ?? [];
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
const selectedTopic = topics.find(
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
);
(item) =>
item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
)?.slug;
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
const selectedProject = projects.find(
(item) =>
@@ -67,7 +80,7 @@ export function ExploreFilterForm({
const data = new FormData(event.currentTarget);
const search = new URLSearchParams();
for (const [key, value] of data) {
if (typeof value === "string") search.append(key, value);
if (typeof value === "string" && value !== "") search.append(key, value);
}
void navigate(`${action}?${search.toString()}`);
}
@@ -83,7 +96,7 @@ export function ExploreFilterForm({
{showType ? (
<label><span></span><select name="type" defaultValue={kind ?? ""}><option value=""></option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
) : null}
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
<label><span></span><select name="topic" defaultValue={selectedTopic ?? ""}><option value=""></option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</option>)}</select></label>
<label><span></span><select name="project" defaultValue={selectedProject ?? ""}><option value=""></option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
<button type="submit"></button>
{hasActiveFilter ? <Link to={action}> </Link> : null}
@@ -19,7 +19,13 @@ export function PublicDocumentHeader({ record }: { record: PublicRecord }) {
{kindLabels[record.kind]}
</Link>
<span aria-hidden="true">/</span>
<Link to={`/topics/${record.topicSlug}`}>{record.topic}</Link>
{/*
주제 페이지가 아니라 그 주제로 거른 탐색으로 보낸다. `/topics/:slug` 는 세 개를
하드코딩해 두고 있어 실제 주제는 무엇이든 404 가 되고, 설명·범위·대표 기록이 전부
비어 있어 지금 채울 내용도 없다. 독자가 여기서 기대하는 것 — 같은 주제의 기록 목록 —
은 탐색 필터가 그대로 준다.
*/}
<Link to={`/explore?topic=${encodeURIComponent(record.topicSlug)}`}>{record.topic}</Link>
<span aria-hidden="true">/</span>
<Link to={`/projects/${record.projectSlug}`}>
{record.projectTitle}
@@ -57,7 +63,9 @@ export function publicRenderModelBase(
topic: {
id: `topic-${record.topicSlug}`,
label: record.topic,
publicPath: `/topics/${record.topicSlug}`,
// 공개 문서의 머리말이 실제로 그리는 주제 링크는 이 값이다 — 위의 breadcrumb 과 같은
// 이유로 탐색 필터를 가리킨다. 둘 중 하나만 고치면 화면에서는 그대로 404 로 간다.
publicPath: `/explore?topic=${encodeURIComponent(record.topicSlug)}`,
},
project: {
id: `project-${record.projectSlug}`,
@@ -39,56 +39,43 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
/**
* 서버가 고른 최근 기록에 릴리스를 얹는다.
*
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
*
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
* 시간순으로 합친다.
*/
const latestTypeLabels: Readonly<Record<string, string>> = {
PROJECT_ACTIVITY: "PROJECT ACTIVITY",
QUESTION: "OPEN QUESTION",
};
async function getLatestEntries(
publicContent: PublicContentQueries,
): Promise<LatestEntry[]> {
const publicRecords = await publicContent.listRecords();
const publicRecordByPath = new Map(
publicRecords.map((record) => [record.path, record]),
);
const searchableEntities = await publicContent.searchPublicContent("");
const projectPrefix = "/projects/";
const projectSlugs = searchableEntities
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) =>
entity.path.startsWith(projectPrefix)
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [],
);
// One project at a time would serialise a request per project; issuing them
// together keeps the timeline's cost at its slowest project rather than their
// sum. The flatten below restores the original single-list shape.
const projectTimeline = (
await Promise.all(
projectSlugs.map(async (projectSlug) => {
const project = await publicContent.getProject(projectSlug);
if (!project) return [];
const activities = await publicContent.getProjectActivity(projectSlug);
return activities.map((activity) => {
const record = publicRecordByPath.get(
activity.recordPath ?? activity.path,
);
return {
id: activity.id,
typeLabel:
activity.type === "PUBLICATION" && record
? record.kind
: "PROJECT ACTIVITY",
title:
activity.type === "PUBLICATION" && record
? record.title
: activity.title,
summary: activity.summary,
date: activity.date,
dateTime: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
path: activity.path,
};
});
}),
)
).flat();
const [records, searchableEntities] = await Promise.all([
publicContent.getLatestEntries(),
publicContent.searchPublicContent(""),
]);
const recordTimeline: LatestEntry[] = records.map((entry) => ({
id: entry.id,
// 목록의 다른 이름들과 같은 자리에 놓이므로 표기도 같은 규칙을 쓴다 — 대문자에 공백.
typeLabel: latestTypeLabels[entry.entryType] ?? entry.entryType,
title: entry.title,
summary: entry.summary,
date: dateLabel(entry.publishedAt),
dateTime: entry.publishedAt,
topic: entry.topic,
project: entry.project,
path: entry.path,
}));
const releaseTimeline = (
await Promise.all(
searchableEntities
@@ -102,14 +89,14 @@ async function getLatestEntries(
if (!release) return [];
return [
{
id: `release-${release.version}`,
typeLabel: "RELEASE",
title: release.title,
summary: release.summary,
date: release.publishedLabel,
dateTime: release.publishedAt,
topic: "TechLog",
project: "TechLog",
id: `release-${release.version}`,
typeLabel: "RELEASE",
title: release.title,
summary: release.summary,
date: release.publishedLabel,
dateTime: release.publishedAt,
topic: "TechLog",
project: "TechLog",
path: release.path,
},
];
@@ -117,11 +104,20 @@ async function getLatestEntries(
)
).flat();
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime),
);
}
/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */
function dateLabel(isoTimestamp: string): string {
const parsed = new Date(isoTimestamp);
if (Number.isNaN(parsed.getTime())) return "";
return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String(
parsed.getDate(),
).padStart(2, "0")}`;
}
export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus);
@@ -1,5 +1,3 @@
import { Link } from "react-router-dom";
import {
RegisteredNotFoundRoute,
useRouteInput,
@@ -21,28 +19,33 @@ export function ProjectActivityPage() {
const { project, activity } = view.data;
if (!project) return <RegisteredNotFoundRoute />;
/*
활동은 로그다 — 언제 무엇을 올렸는지만 적는다.
예전에는 각 줄에 그 기록으로 가는 링크가 있었다. 그러면 같은 글에 닿는 길이 둘이 되고,
읽는 사람은 "기록"과 "활동"이 어떻게 다른지 매번 다시 판단해야 한다. 글을 읽는 자리는
기록 화면 하나로 둔다.
*/
return (
<main id="main-content" className="shell project-page">
<ProjectPageHeader project={project} title={`${project.title} 활동`} />
<ol className="project-activity-list">
{activity.map((item) => (
<li key={item.id}>
<article id={item.id}>
<div>
<span>{item.type}</span>
<time dateTime={item.dateTime}>{item.date}</time>
</div>
<h2>{item.title}</h2>
<p>{item.summary}</p>
<Link to={item.recordPath ?? item.path}>
{item.recordPath
? "연결된 공개 기록 읽기"
: "이 활동 위치 열기"}
</Link>
</article>
</li>
))}
</ol>
{activity.length === 0 ? (
<p className="public-empty-note"> .</p>
) : (
<ol className="project-activity-list">
{activity.map((item) => (
<li key={item.id}>
<article id={item.id}>
<div>
<span>{item.type}</span>
<time dateTime={item.dateTime}>{item.date}</time>
</div>
<h2>{item.title}</h2>
</article>
</li>
))}
</ol>
)}
</main>
);
}
@@ -38,10 +38,11 @@ export function TopicPage() {
const topic = topicConfig(params.slug);
// Hooks run unconditionally, so the unknown-topic case is handled by the
// loader and the not-found route is chosen after it.
const slug = typeof params.slug === "string" ? params.slug : "";
const view = usePublicContent(
["tech-log", "topic", topic?.title],
["tech-log", "topic", slug],
async (queries) =>
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
);
if (!topic) return <RegisteredNotFoundRoute />;
if (!view.ready) return view.fallback;
@@ -53,8 +53,12 @@ export type ResolvedAssetLike = Readonly<{
function fromDescriptor(descriptor: ResolvedAssetLike): EvidenceAsset {
return Object.freeze({
src: descriptor.publicPath,
width: descriptor.width ?? 1,
height: descriptor.height ?? 1,
// 치수를 모르면 0 으로 둔다 — 1 이 아니라. 1×1 은 "아주 작은 그림" 이라는 거짓말이고,
// `loading="lazy"` 와 만나면 브라우저는 화면에 걸리지 않는 1×1 상자를 영영 가져오지 않는다.
// 실제로 업로드가 치수를 기록하지 않아 모든 그림이 그렇게 사라졌다. 0 은 figure 가
// 자기 값을 모른다는 뜻이고, 렌더러가 그때 속성을 빼고 즉시 로드로 바꾼다.
width: descriptor.width ?? 0,
height: descriptor.height ?? 0,
triggerLabel: `${descriptor.assetKey} 이미지 크게 보기`,
dialogLabel: `${descriptor.assetKey} 확대`,
});
@@ -99,6 +99,19 @@ function renderBlock(
resolveEvidenceAsset={resolveEvidenceAsset}
/>
);
case "THEMATIC_BREAK":
return <hr key={key} className="document-rule" />;
case "IMAGE":
/*
작성자가 적은 경로를 그대로 쓴다. 경로 검증은 파싱할 때 끝났다(`isSafeLink`).
`title` 이 있으면 그림 설명으로 보여 준다 — Markdown 이 제목을 그런 뜻으로 쓴다.
*/
return (
<figure key={key} className="document-image">
<img src={block.src} alt={block.alt} loading="lazy" />
{block.title ? <figcaption>{block.title}</figcaption> : null}
</figure>
);
default:
return assertNever(block);
}
@@ -12,9 +12,20 @@ type DocumentTocProps = {
export function DocumentToc({ headings, variant }: DocumentTocProps) {
const [currentId, setCurrentId] = useState(headings[0]?.id ?? "");
const empty = headings.length === 0;
const detailsRef = useRef<HTMLDetailsElement>(null);
useEffect(() => {
/*
`IntersectionObserver` 가 없는 환경이 있다 — jsdom 이 그렇고, 오래된 브라우저도 그렇다.
없으면 "지금 읽는 절" 표시만 못 할 뿐 목차 자체는 쓸 수 있으므로, 없다고 문서를 통째로
못 그리게 두지 않는다.
한동안 이 컴포넌트는 픽스처 Case 하나에서만 쓰여 그 환경을 만난 적이 없었다. 모든 Case 가
목차를 받게 되면서 드러났다.
*/
if (typeof IntersectionObserver === "undefined") return undefined;
const elements = headings
.map((heading) => document.getElementById(heading.id))
.filter((element): element is HTMLElement => Boolean(element));
@@ -41,6 +52,12 @@ export function DocumentToc({ headings, variant }: DocumentTocProps) {
if (detailsRef.current) detailsRef.current.open = false;
}
/*
제목이 없는 문서에는 목차를 그리지 않는다. 예전에는 이 컴포넌트가 픽스처 문서 하나에서만
쓰여 빈 경우를 만날 일이 없었지만, 이제 모든 Case 가 지나므로 빈 레일이 남을 수 있다.
*/
if (empty) return null;
if (variant === "mobile") {
const current =
headings.find((heading) => heading.id === currentId) ?? headings[0];
@@ -30,16 +30,20 @@ export function PublicEvidenceFigure({
dialogRef.current?.close();
}
/**
* 치수를 아는 그림만 자리를 미리 잡는다. 모르는데 숫자를 적으면 그 상자가 진짜 크기가 되고,
* `loading="lazy"` 는 화면에 걸리지 않는 상자를 끝내 가져오지 않는다 — 그림이 통째로 사라진다.
* 모를 때는 속성을 빼고 즉시 로드해, 브라우저가 원래 크기로 그리게 둔다.
*/
const known = asset.width > 0 && asset.height > 0;
const sizing = known
? ({ width: asset.width, height: asset.height, loading: "lazy" } as const)
: ({ loading: "eager" } as const);
if (!zoom) {
return (
<figure className="evidence-figure">
<img
src={asset.src}
width={asset.width}
height={asset.height}
alt={alt}
loading="lazy"
/>
<img src={asset.src} alt={alt} {...sizing} />
<figcaption>{caption}</figcaption>
</figure>
);
@@ -56,13 +60,7 @@ export function PublicEvidenceFigure({
aria-describedby={descriptionId}
onClick={open}
>
<img
src={asset.src}
width={asset.width}
height={asset.height}
alt={alt}
loading="lazy"
/>
<img src={asset.src} alt={alt} {...sizing} />
<span> </span>
<span className="visually-hidden" id={descriptionId}>
{alt}
@@ -84,13 +82,7 @@ export function PublicEvidenceFigure({
<button type="button" className="dialog-close" onClick={close}>
</button>
<img
src={asset.src}
width={asset.width}
height={asset.height}
alt={alt}
loading="lazy"
/>
<img src={asset.src} alt={alt} {...sizing} />
<p>{caption}</p>
</div>
</dialog>
@@ -128,57 +128,18 @@ function publicRelations(
}));
}
function GenericCase({
model,
embedded,
resolveEvidenceAsset,
resolvePublishedLabel,
}: {
model: CasePublicRenderModel;
embedded: boolean;
} & RenderDependencies) {
const Root = embedded ? "div" : "main";
return (
<Root
id={embedded ? undefined : "main-content"}
className={`shell public-document-page${embedded ? " public-record-embedded" : ""}`}
>
<ModelDocumentHeader
model={model}
resolvePublishedLabel={resolvePublishedLabel}
/>
<section className="document-snapshot" aria-label="문제와 결론">
<div>
<p className="snapshot-label"></p>
<p><PlainText text={model.problem} /></p>
</div>
<div>
<p className="snapshot-label snapshot-label--answer"></p>
<p><PlainText text={model.conclusion} /></p>
</div>
</section>
<dl className="document-facts">
<div>
<dt> </dt>
<dd><PlainText text={model.environment} /></dd>
</div>
<div>
<dt> </dt>
<dd><PlainText text={model.reproduction} /></dd>
</div>
</dl>
<article className="public-document-body">
<CaseBodyRenderer
blocks={model.bodyBlocks}
resolveEvidenceAsset={resolveEvidenceAsset}
/>
</article>
<PublicDocumentRelations relations={publicRelations(model.relations)} />
</Root>
);
}
function FetchJoinCase({
/**
* Case 한 편.
*
* 한동안 Case 렌더러가 둘이었다. 이 완성된 배치와, 목차도 breadcrumb 도 없는 축약본. 어느 쪽을
* 쓸지는 `publicPath === "/cases/collection-fetch-join-pagination"` 라는 슬러그 비교가 정했다 —
* 설계 픽스처로 만든 문서 하나만 제대로 된 화면을 받고, 실제로 작성한 Case 는 전부 축약본으로
* 떨어졌다. 그래서 오른쪽 목차가 어떤 문서에서도 나타나지 않았다.
*
* 배치는 하나다. 목차는 본문에 제목이 있을 때 나온다.
*/
function CaseDocument({
model,
embedded,
resolveEvidenceAsset,
@@ -233,7 +194,7 @@ function FetchJoinCase({
<dd><PlainText text={model.environment} /></dd>
</div>
<div>
<dt></dt>
<dt> </dt>
<dd><PlainText text={model.reproduction.replace(/^Dataset:\s*/, "")} /></dd>
</div>
<div>
@@ -474,7 +435,11 @@ function ProjectDecisionDocument({
<header>
<div>
<span>{model.status}</span>
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
{model.decidedOn ? (
<time dateTime={model.decidedOn}>{displayDate(model.decidedOn)}</time>
) : (
<span> </span>
)}
</div>
<h2>{model.title}</h2>
<p><PlainText text={model.statement} /></p>
@@ -519,16 +484,8 @@ export function PublicRecordRenderer({
} & RenderDependencies) {
switch (model.kind) {
case "CASE":
return model.publicPath ===
"/cases/collection-fetch-join-pagination" ? (
<FetchJoinCase
model={model}
embedded={embedded}
resolveEvidenceAsset={resolveEvidenceAsset}
resolvePublishedLabel={resolvePublishedLabel}
/>
) : (
<GenericCase
return (
<CaseDocument
model={model}
embedded={embedded}
resolveEvidenceAsset={resolveEvidenceAsset}
@@ -5,9 +5,20 @@ import type { Asset, AssetKind } from "../../../contracts/studio/contract.ts";
import { useStudioAssetGateway } from "../use-studio.ts";
import { AssetPicker, buildEvidenceDirective } from "./asset-picker.tsx";
import { AssetUploadDialog } from "./asset-upload-dialog.tsx";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
type CaseInput = components["schemas"]["CaseInput"];
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
export const CASE_FIELD_PATHS = [
"/problem",
"/conclusion",
"/environment",
"/reproduction",
"/lastVerifiedOn",
"/bodyMarkdown",
] as const;
const ASSET_KIND_OPTIONS: ReadonlyArray<{ value: AssetKind; label: string }> = [
{ value: "IMAGE", label: "이미지" },
{ value: "DIAGRAM", label: "다이어그램" },
@@ -36,11 +47,14 @@ function insertAtCursor(
export function CaseFields({
draft,
issues,
onChange,
onAssetsObserved,
onAssetUploaded,
}: {
draft: CaseInput;
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
issues: readonly FieldIssue[];
onChange(draft: CaseInput): void;
/**
* The editor screen owns the resolution catalog Instant Preview reads, and
@@ -81,14 +95,14 @@ export function CaseFields({
<section className="studio-editor-section" aria-labelledby="studio-case-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">CASE</p><h2 id="studio-case-fields-title"> </h2></div>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /></label>
<label className="studio-field"><span> </span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /></label>
<label className="studio-field studio-field--wide"><span> Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.problem} onChange={(event) => update({ problem: event.currentTarget.value })} /><FieldNotice issues={issues} path="/problem" /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.conclusion} onChange={(event) => update({ conclusion: event.currentTarget.value })} /><FieldNotice issues={issues} path="/conclusion" /></label>
<label className="studio-field"><span> </span><textarea value={draft.environment} onChange={(event) => update({ environment: event.currentTarget.value })} /><FieldNotice issues={issues} path="/environment" /></label>
<label className="studio-field"><span> </span><textarea value={draft.reproduction} onChange={(event) => update({ reproduction: event.currentTarget.value })} /><FieldNotice issues={issues} path="/reproduction" /></label>
<label className="studio-field"><span> </span><input type="date" value={draft.lastVerifiedOn ?? ""} onChange={(event) => update({ lastVerifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/lastVerifiedOn" /></label>
<label className="studio-field studio-field--wide"><span> Markdown</span><textarea ref={bodyRef} className="studio-markdown-field" value={draft.bodyMarkdown} onChange={(event) => update({ bodyMarkdown: event.currentTarget.value })} /><FieldNotice issues={issues} path="/bodyMarkdown" /></label>
</div>
<div className="studio-asset-panel" aria-labelledby="studio-asset-panel-title">
<div className="studio-asset-panel" role="group" aria-labelledby="studio-asset-panel-title">
<p className="studio-eyebrow">EVIDENCE</p>
<h3 id="studio-asset-panel-title"> Asset </h3>
<p> evidence . READY Asset만 .</p>
@@ -1,33 +1,48 @@
import type { components } from "../../../contracts/studio/generated.ts";
import type { WorkingCopyInput } from "../../../contracts/studio/contract.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { RelationEditor } from "./relation-editor.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. 나머지는 게시 버튼 옆에 남는다. */
export const COMMON_FIELD_PATHS = [
"/title",
"/slug",
"/summary",
"/topicId",
"/projectId",
"/relations",
] as const;
export function CommonDocumentFields({
draft,
topics,
projects,
relations,
issues,
onUpdate,
}: {
draft: WorkingCopyInput;
topics: CatalogEntry[];
projects: CatalogEntry[];
relations: CatalogEntry[];
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
issues: readonly FieldIssue[];
onUpdate(patch: Partial<WorkingCopyInput>): void;
}) {
return (
<section className="studio-editor-section" aria-labelledby="studio-common-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">DOCUMENT</p><h2 id="studio-common-fields-title"> </h2></div>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide"><span></span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /></label>
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /></label>
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value=""> </option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value=""></option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
<label className="studio-field studio-field--wide"><span></span><input value={draft.title} maxLength={120} onChange={(event) => onUpdate({ title: event.currentTarget.value })} /><FieldNotice issues={issues} path="/title" /></label>
<label className="studio-field"><span>slug</span><input value={draft.slug} maxLength={100} placeholder="비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)" onChange={(event) => onUpdate({ slug: event.currentTarget.value as WorkingCopyInput["slug"] })} /><FieldNotice issues={issues} path="/slug" /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.summary} maxLength={300} onChange={(event) => onUpdate({ summary: event.currentTarget.value })} /><FieldNotice issues={issues} path="/summary" /></label>
<label className="studio-field"><span>Topic</span><select value={draft.topicId ?? ""} onChange={(event) => onUpdate({ topicId: event.currentTarget.value || null })}><option value=""> </option>{topics.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select><FieldNotice issues={issues} path="/topicId" /></label>
<label className="studio-field"><span>Project</span><select value={draft.projectId ?? ""} onChange={(event) => onUpdate({ projectId: event.currentTarget.value || null })}><option value=""></option>{projects.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select><FieldNotice issues={issues} path="/projectId" /></label>
</div>
<RelationEditor evidence={draft.kind === "PROJECT_DECISION"} relations={draft.relations} catalog={relations} onChange={(next) => onUpdate({ relations: next })} />
<FieldNotice issues={issues} path="/relations" />
</section>
);
}
@@ -14,7 +14,35 @@ export type DocumentEditorController = {
* 남아 있어, 작성자는 저장된 줄 알고 검증에서 "slug 이 없다" 를 만났다.
*/
saveError: string;
/**
* 마지막 검증이 남긴 항목. 게시를 막는 것이 무엇인지 고치는 자리에서 보여 주기 위한 것이며,
* `current` 가 거짓이면 지금 저장된 버전을 검사한 결과가 아니다.
*/
validation: Readonly<{
current: boolean;
version: number;
issues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
}> | null;
/**
* 게시가 진행 중인가. 한 번의 클릭이 저장·검증·미리보기·게시 네 번의 왕복을 만들므로, 그
* 사이에 다시 누르면 같은 문서를 두 번 게시하려 든다.
*/
publishing: boolean;
/** 게시가 실패한 이유. 서버가 준 문구를 그대로 쓴다. */
publishError: string;
/**
* 게시를 막은 검증 항목. 예전에는 이것을 보려면 검증 화면으로 나가야 했다 — 고칠 칸은 편집
* 화면에 있는데 무엇이 모자란지는 다른 화면에 있었다. 지금은 막힌 자리에서 바로 보여 준다.
*/
publishIssues: readonly { severity: "ERROR" | "WARNING"; code: string; message: string; path: string }[];
update(patch: Partial<WorkingCopyInput>): void;
replace(draft: WorkingCopyInput): void;
save(): Promise<void>;
/**
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다. 백엔드는 게시 요청에 신선한 검증 id 와
* 미리보기 id, 그리고 현재 경고를 모두 확인했다는 목록을 요구한다 —
* `PublishStudioDocumentUseCase` 가 셋을 모두 검사한다. 그 셋은 작성자에게 물어볼 것이 없으므로
* 여기서 채운다. 작성자가 밟는 단계는 저장과 게시 둘뿐이다.
*/
publish(): Promise<void>;
};
@@ -6,13 +6,16 @@ import type {
Asset,
WorkingCopy,
WorkingCopyInput,
WorkingCopyDetail,
} from "../../../contracts/studio/contract.ts";
import { mergeAssetCatalog } from "../../../domain/content-format/asset-evidence-catalog.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { DocumentEditor } from "./document-editor.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { deriveValidationState } from "../../../domain/studio/document-state.ts";
import { slugFromName } from "./slug-from-name.ts";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio, useStudioEditorSession } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
@@ -31,6 +34,22 @@ function inputOf(document: WorkingCopy): WorkingCopyInput {
* 보는 이유는, 어긋난 값을 보내면 서버가 details 없는 422 로 거절하고 편집기는 그것을 이유 없는
* 실패로만 보여 주기 때문이다 — 작성자에게는 어느 칸이 문제인지 알 방법이 없었다.
*/
type ValidationIssue = components["schemas"]["ValidationIssue"];
/**
* 마지막 검증을 편집기가 쓸 모양으로 줄인다. `current` 는 "지금 저장된 버전을 검사한 결과인가"
* 다 — 아니면 목록은 참고일 뿐이고, 방금 고친 것이 아직 모자라다고 말할 수 있다.
*/
function validationOf(detail: WorkingCopyDetail, now: Date) {
const report = detail.currentValidation;
if (!report) return null;
return Object.freeze({
current: deriveValidationState({ ...detail, currentValidation: report, now }).freshness === "CURRENT",
version: report.validatedVersion,
issues: report.issues,
});
}
const SLUG_SHAPE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
export function DocumentEditorScreen({ documentId }: { documentId: string }) {
@@ -57,6 +76,15 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
// below, when the screen switches to a different document.
const [assets, setAssets] = useState<readonly Asset[]>([]);
const [saveError, setSaveError] = useState("");
/**
* 마지막 검증 결과. 편집기가 이것을 들고 있는 이유는, 무엇이 모자라 게시가 막히는지 작성자가
* 고치는 자리에서 보여야 하기 때문이다 — 예전에는 별도 화면으로 나가야만 알 수 있었고,
* 돌아와서 어느 칸이었는지 기억해야 했다.
*/
const [validationSource, setValidationSource] = useState<WorkingCopyDetail | null>(null);
const [publishing, setPublishing] = useState(false);
const [publishError, setPublishError] = useState("");
const [publishIssues, setPublishIssues] = useState<readonly ValidationIssue[]>([]);
const observeAssets = useCallback((observed: readonly Asset[]) => {
setAssets((current) => mergeAssetCatalog(current, observed));
}, []);
@@ -83,6 +111,7 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
problem: null,
});
begin(detail.document, inputOf(detail.document));
setValidationSource(detail);
},
(error: unknown) => {
if (request.signal.aborted) return;
@@ -130,6 +159,9 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
{ idempotencyKey: createLocalId("studio-editor-save") },
);
begin(detail.document, inputOf(detail.document));
// 저장하면 직전 검증은 그 버전의 것이 아니게 된다. 그 사실을 바로 반영해야 낡은 목록이
// 방금 고친 항목을 아직 모자라다고 말하지 않는다.
setValidationSource(detail);
studio.setRequestAnnouncement(
`버전 ${detail.document.version}으로 저장했습니다.`,
);
@@ -145,6 +177,125 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
}
}, [begin, editor, setStatus, studio]);
/*
`save` 가 스스로 CLEAN·SAVING·CONFLICT 를 걸러 내므로 단축키는 늘 열어 둔다 — 저장할 것이
없을 때 눌러도 아무 일도 일어나지 않는다.
*/
useSaveShortcut(save);
/**
* 저장 → 검증 → 미리보기 → 게시를 한 번에 수행한다.
*
* <p>백엔드는 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두 확인했다는
* 목록을 요구한다(`PublishStudioDocumentUseCase`). 예전에는 그 셋을 작성자가 세 화면을 차례로
* 밟아 만들었다 — 검증 화면에서 버튼을 누르고, 미리보기 화면에서 또 누르고, 게시 화면에서
* 경고를 하나씩 체크했다.
*
* <p>그 셋은 작성자에게 물어볼 것이 없다. 검증 결과는 서버가 판정하고, 미리보기는 그 판정에서
* 만들어지며, 경고는 게시를 막지 않는다. 그래서 여기서 잇달아 부른다. 서버가 지키던 불변식은
* 그대로다 — 사라진 것은 작성자가 밟던 화면뿐이다.
*
* <p>막는 것은 `ERROR` 뿐이다. 그때는 게시를 멈추고 지적을 그 칸 아래에 보여 준다.
*/
const publish = useCallback(async () => {
const current = editor;
if (!current || current.status === "SAVING" || current.status === "CONFLICT") return;
setPublishError("");
setPublishIssues([]);
setPublishing(true);
const id = current.documentId;
try {
// 게시는 저장된 버전을 대상으로 한다. 편집 중인 값이 있으면 먼저 맞춘다 — 그러지 않으면
// 방금 고친 칸이 반영되지 않은 채 검증받는다.
let version = current.saved.version;
if (current.status === "DIRTY") {
const slug = current.draft.slug.trim() || slugFromName(current.draft.title);
if (slug && !SLUG_SHAPE.test(slug)) {
setPublishError("slug 은 영문 소문자·숫자·하이픈만 쓸 수 있습니다. 비워 두면 제목에서 만들어 드립니다.");
return;
}
const draft = slug === current.draft.slug
? current.draft
: ({ ...current.draft, slug } as typeof current.draft);
setStatus("SAVING");
const saved = await studio.gateway.saveDocument(
id,
{ expectedVersion: version, document: draft },
{ idempotencyKey: createLocalId("studio-publish-save") },
);
begin(saved.document, inputOf(saved.document));
setValidationSource(saved);
version = saved.document.version;
}
const report = await studio.gateway.validateDocument(
id,
{ expectedVersion: version },
{ idempotencyKey: createLocalId("studio-publish-validate") },
);
if (report.status === "INVALID") {
setPublishIssues(report.issues);
setPublishError("게시할 수 없습니다. 표시한 칸을 채워 주세요.");
studio.setRequestAnnouncement("게시할 수 없습니다. 표시한 칸을 채워 주세요.");
return;
}
const preview = await studio.gateway.createPreview(
id,
{ expectedVersion: version, validationId: report.validationId },
{ idempotencyKey: createLocalId("studio-publish-preview") },
);
// 경고는 게시를 막지 않는다. 서버는 "현재 경고를 모두 확인했다"는 목록을 요구할 뿐이므로
// 방금 받은 경고를 그대로 넘긴다.
const acknowledged = [
...new Set(
report.issues
.filter((issue) => issue.severity === "WARNING")
.map((issue) => issue.code),
),
].sort();
const result = await studio.gateway.publishDocument(
id,
{
expectedVersion: version,
validationId: report.validationId,
previewId: preview.previewId,
acknowledgedWarningCodes: acknowledged,
},
{ idempotencyKey: createLocalId("studio-publish") },
);
// 경고를 남긴 채 게시했다면 그 사실은 남겨 둔다 — 게시가 되었다고 해서 지적이 사라진 것은
// 아니다.
setPublishIssues(report.issues.filter((issue) => issue.severity === "WARNING"));
studio.setRequestAnnouncement("게시했습니다.");
studio.clearEditor();
studio.navigateInternal(
`/studio/publications/${result.event.publicationEventId}/preview`,
);
} catch (error) {
if (isStudioGatewayError(error) && error.code === "VERSION_CONFLICT") {
setStatus("CONFLICT");
} else if (current.status === "DIRTY") {
setStatus("DIRTY");
}
const detail = isStudioGatewayError(error)
? error.problem.detail
: "게시하지 못했습니다. 다시 시도해 주세요.";
setPublishError(detail);
studio.setRequestAnnouncement(detail);
} finally {
setPublishing(false);
}
}, [begin, editor, setStatus, studio]);
/**
* 검증의 신선도는 시각에 달렸는데, 시계는 렌더마다 새 함수다. 그것을 효과 의존성에 넣었더니
* 문서를 끝없이 다시 불러왔다 — 화면이 정착하지 못해 탭 전환조차 먹히지 않았다. 시각은
* 저장할 값이 아니라 지금 묻는 값이므로 렌더에서 읽는다.
*/
const validation = validationSource ? validationOf(validationSource, studio.now()) : null;
const controller = useMemo<DocumentEditorController | null>(() => {
if (!editor || editor.documentId !== documentId) return null;
return {
@@ -152,15 +303,20 @@ export function DocumentEditorScreen({ documentId }: { documentId: string }) {
draft: editor.draft,
status: editor.status,
saveError,
validation,
publishing,
publishError,
publishIssues,
update(patch) {
updateDraft({ ...editor.draft, ...patch, kind: editor.draft.kind } as WorkingCopyInput);
},
replace(draft) {
if (draft.kind === editor.draft.kind) updateDraft(draft);
},
publish,
save,
};
}, [documentId, editor, save, saveError, updateDraft]);
}, [documentId, editor, publish, publishError, publishIssues, publishing, save, saveError, updateDraft, validation]);
const currentResult = result?.key === requestKey ? result : null;
const problem = currentResult?.problem ?? null;
@@ -1,15 +1,14 @@
import { useRef, useState, type KeyboardEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type { Asset } from "../../../contracts/studio/contract.ts";
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { CaseFields } from "./case-fields.tsx";
import { CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusRail } from "./document-status-rail.tsx";
import { CASE_FIELD_PATHS, CaseFields } from "./case-fields.tsx";
import { COMMON_FIELD_PATHS, CommonDocumentFields } from "./common-document-fields.tsx";
import { DocumentStatusBar } from "./document-status-bar.tsx";
import { InstantPreview } from "./instant-preview.tsx";
import { ProjectDecisionFields } from "./project-decision-fields.tsx";
import { QuestionFields } from "./question-fields.tsx";
import { ReferenceFields } from "./reference-fields.tsx";
import { DECISION_FIELD_PATHS, ProjectDecisionFields } from "./project-decision-fields.tsx";
import { issuesOutside } from "./field-issues.tsx";
import { QUESTION_FIELD_PATHS, QuestionFields } from "./question-fields.tsx";
import { REFERENCE_FIELD_PATHS, ReferenceFields } from "./reference-fields.tsx";
type CatalogEntry = components["schemas"]["CatalogEntry"];
@@ -27,56 +26,69 @@ export function DocumentEditor({
onAssetsObserved: (assets: readonly Asset[]) => void;
onAssetUploaded: (asset: Asset) => void;
}) {
const [tab, setTab] = useState<"EDIT" | "PREVIEW">("EDIT");
const editTab = useRef<HTMLButtonElement>(null);
const previewTab = useRef<HTMLButtonElement>(null);
const selectTab = (next: "EDIT" | "PREVIEW") => {
setTab(next);
(next === "EDIT" ? editTab : previewTab).current?.focus();
};
const keyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
let next: "EDIT" | "PREVIEW" | null = null;
if (event.key === "ArrowLeft" || event.key === "ArrowRight") next = tab === "EDIT" ? "PREVIEW" : "EDIT";
if (event.key === "Home") next = "EDIT";
if (event.key === "End") next = "PREVIEW";
if (!next) return;
event.preventDefault();
selectTab(next);
};
const topics = catalog.filter(({ type }) => type === "TOPIC");
const projects = catalog.filter(({ type }) => type === "PROJECT");
const relations = catalog.filter(({ type }) => type === "RELATION");
const evidence = catalog.filter(({ type }) => type === "EVIDENCE");
/*
게시를 막는 것이 무엇인지 그 칸 아래에 적는다. 예전에는 이 목록이 화면 맨 위에 한 덩어리로
있었고, 작성자는 `/topicId` 같은 경로를 읽고 어느 칸인지 스스로 찾아야 했다.
어느 칸에도 붙지 못한 것은 게시 버튼 옆에 남긴다 — 사라지면 이유를 말해 주지 않는 실패만
남는다.
*/
const issues = controller.publishIssues;
const kindPaths = controller.draft.kind === "CASE"
? CASE_FIELD_PATHS
: controller.draft.kind === "REFERENCE"
? REFERENCE_FIELD_PATHS
: controller.draft.kind === "QUESTION"
? QUESTION_FIELD_PATHS
: DECISION_FIELD_PATHS;
const unplaced = issuesOutside(issues, [...COMMON_FIELD_PATHS, ...kindPaths]);
/*
편집과 미리보기를 한 화면에 나란히 둔다. 예전에는 탭이었고, 고친 것이 어떻게 보이는지
확인하려면 탭을 옮겨야 했다 — 옮기는 동안 편집 중이던 칸은 화면에서 사라졌고, 돌아오면
스크롤 위치도 잃었다. 두 패널을 동시에 두면 그 왕복이 통째로 없어진다.
탭을 없앴으므로 각 패널은 스스로 이름을 가져야 한다. `aria-labelledby` 로 제목을 가리켜
landmark 로 만든다 — 탭 목록이 하던 "여기는 편집, 저기는 미리보기" 안내를 대신한다.
*/
return (
<div className="studio-editor-page">
<div className="studio-editor-tabs" role="tablist" aria-label="문서 편집 화면">
<button id="studio-edit-tab" ref={editTab} type="button" role="tab" aria-selected={tab === "EDIT"} aria-controls="studio-edit-panel" tabIndex={tab === "EDIT" ? 0 : -1} onClick={() => selectTab("EDIT")} onKeyDown={keyDown}></button>
<button id="studio-preview-tab" ref={previewTab} type="button" role="tab" aria-selected={tab === "PREVIEW"} aria-controls="studio-preview-panel" tabIndex={tab === "PREVIEW" ? 0 : -1} onClick={() => selectTab("PREVIEW")} onKeyDown={keyDown}> </button>
</div>
<div className="studio-editor-layout">
<div className="studio-editor-workspace">
<div id="studio-edit-panel" role="tabpanel" aria-labelledby="studio-edit-tab" hidden={tab !== "EDIT"}>
<header className="studio-editor-heading">
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
<h1> </h1>
<p>{controller.draft.title || "제목 없는 작업본"}</p>
</header>
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} onUpdate={controller.update} />
{controller.draft.kind === "CASE"
? <CaseFields draft={controller.draft} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
: controller.draft.kind === "REFERENCE"
? <ReferenceFields draft={controller.draft} onChange={controller.replace} />
: controller.draft.kind === "QUESTION"
? <QuestionFields draft={controller.draft} evidence={evidence} onChange={controller.replace} />
: <ProjectDecisionFields draft={controller.draft} onChange={controller.replace} />}
</div>
<div id="studio-preview-panel" role="tabpanel" aria-labelledby="studio-preview-tab" hidden={tab !== "PREVIEW"}>
<div className="studio-editor-split">
<section className="studio-editor-workspace" aria-labelledby="studio-edit-title">
<header className="studio-editor-heading">
<p className="studio-eyebrow">{controller.draft.kind} · VERSION {controller.saved.version}</p>
<h1 id="studio-edit-title"> </h1>
<p>{controller.draft.title || "제목 없는 작업본"}</p>
</header>
<CommonDocumentFields draft={controller.draft} topics={topics} projects={projects} relations={relations} issues={issues} onUpdate={controller.update} />
{controller.draft.kind === "CASE"
? <CaseFields draft={controller.draft} issues={issues} onChange={controller.replace} onAssetsObserved={onAssetsObserved} onAssetUploaded={onAssetUploaded} />
: controller.draft.kind === "REFERENCE"
? <ReferenceFields draft={controller.draft} issues={issues} onChange={controller.replace} />
: controller.draft.kind === "QUESTION"
? <QuestionFields draft={controller.draft} evidence={evidence} issues={issues} onChange={controller.replace} />
: <ProjectDecisionFields draft={controller.draft} issues={issues} onChange={controller.replace} />}
</section>
{/*
미리보기는 편집 칸보다 훨씬 짧을 수도, 길 수도 있다. 그대로 두면 본문을 스크롤하는
동안 화면 밖으로 나가 버리므로 붙잡아 두고 자기 높이 안에서 따로 스크롤한다.
*/}
<section className="studio-editor-preview" aria-labelledby="studio-preview-title">
<header className="studio-editor-preview__heading">
<p className="studio-eyebrow">LIVE</p>
<h2 id="studio-preview-title"> </h2>
</header>
<div className="studio-editor-preview__body">
<InstantPreview draft={controller.draft} catalog={catalog} assets={assets} />
</div>
</div>
<DocumentStatusRail controller={controller} />
</section>
</div>
<DocumentStatusBar controller={controller} unplacedIssues={unplaced} />
</div>
);
}
@@ -2,6 +2,7 @@ import { useEffect, useState, type FormEvent } from "react";
import type { ListDocumentsQuery } from "../../../application/ports/studio-gateway.ts";
import type { DocumentPage } from "../../../contracts/studio/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
@@ -25,6 +26,21 @@ const nextLabel = {
NONE: "완료",
} as const;
/**
* 다음 단계는 이름만 있고 갈 곳이 없었다. 작성자는 편집 → 검증 → 미리보기 → 게시를 순서대로
* 밟아야만 게시 화면에 닿을 수 있었고, 그 경로 어디에도 "게시" 라는 말이 먼저 보이지 않아
* 게시하는 방법을 알기 어려웠다. 서버가 이미 다음 할 일을 알려 주므로, 그 말을 그대로 링크로
* 만든다.
*/
const nextHref = {
CONTINUE_EDITING: "edit",
VALIDATE: "validation",
FIX_VALIDATION: "edit",
CREATE_PREVIEW: "preview",
PUBLISH: "publish",
NONE: "",
} as const;
function isAbortError(error: unknown): boolean {
return error instanceof DOMException && error.name === "AbortError";
}
@@ -83,10 +99,10 @@ export function DocumentList() {
? { ...current, items: current.items.filter((row) => row.id !== item.id) }
: current,
);
} catch {
setDeleteError(
"삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.",
);
} catch (error) {
// 서버는 무엇이 막았는지 정확히 알고 그것을 보내 준다. 예전에는 그 문구를 버리고 추측 셋을
// 늘어놓아, 버전 충돌도 "사용 중" 으로 읽혔다.
setDeleteError(managementFailureMessage(error, "삭제하지 못했습니다."));
} finally {
setDeletingId(null);
}
@@ -251,7 +267,17 @@ export function DocumentList() {
</div>
<div>
<dt></dt>
<dd>{nextLabel[item.nextAction]}</dd>
<dd>
{nextHref[item.nextAction] ? (
<GuardedStudioLink
href={`/studio/documents/${item.id}/${nextHref[item.nextAction]}`}
>
{nextLabel[item.nextAction]}
</GuardedStudioLink>
) : (
nextLabel[item.nextAction]
)}
</dd>
</div>
<div>
<dt></dt>
@@ -0,0 +1,91 @@
import type { DocumentEditorController } from "./document-editor-controller.ts";
import type { FieldIssue } from "./field-issues.tsx";
const labels = {
CLEAN: "저장됨",
DIRTY: "저장되지 않음",
SAVING: "저장 중…",
CONFLICT: "저장 충돌",
} as const;
const kindLabels = {
CASE: "CASE",
REFERENCE: "REFERENCE",
QUESTION: "QUESTION",
PROJECT_DECISION: "Decision",
} as const;
/**
* 작업 상태와 저장·게시를 화면 아래에 고정한다.
*
* 예전에는 오른쪽 세로 rail 이었다. 그 자리는 편집기와 미리보기가 함께 쓸 가로 폭을 가져갔고,
* 편집 칸이 길어질수록 rail 은 위에 붙은 채 본문만 멀어졌다. 아래에 고정하면 폭을 돌려주면서
* 스크롤 위치와 무관하게 저장·게시에 손이 닿는다 — 릴리즈 편집에서 버튼이 아래로 밀려나던
* 것과 같은 문제를 여기서는 만들지 않는다.
*
* `aside` 와 「작업 상태」라는 이름은 그대로 둔다. 자리가 바뀐 것이지 이 묶음이 무엇인지가
* 바뀐 것은 아니고, 보조 기술이 이 영역을 부르던 이름도 그대로여야 한다.
*/
export function DocumentStatusBar({
controller,
unplacedIssues,
}: {
controller: DocumentEditorController;
/**
* 어느 칸에도 붙지 못한 지적. 화면에 없는 칸을 가리키는 것들이며, 여기 남기지 않으면 이유를
* 말해 주지 않는 실패만 남는다.
*/
unplacedIssues: readonly FieldIssue[];
}) {
const busy = controller.status === "SAVING" || controller.publishing;
return (
<aside className="studio-document-status-bar" aria-labelledby="studio-document-status-title">
<h2 id="studio-document-status-title" className="studio-visually-hidden"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
{/*
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
두 번 말하게 된다.
*/}
{controller.status === "CONFLICT" ? (
<p className="studio-editor-conflict" role="alert"> . .</p>
) : controller.saveError ? (
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
) : controller.publishError ? (
<p className="studio-editor-conflict" role="alert">{controller.publishError}</p>
) : (
<p className="studio-document-status-bar__note"> . Ctrl+S . .</p>
)}
<div className="studio-document-status-bar__actions">
<button type="button" title="Ctrl+S" onClick={() => { void controller.save(); }} disabled={busy || controller.status === "CLEAN" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
{/*
버튼은 저장과 게시 둘뿐이다. 예전에는 게시까지 검증 → 미리보기 → 게시 세 화면을 차례로
밟아야 했다 — 백엔드가 게시 요청에 신선한 검증 id 와 미리보기 id, 그리고 현재 경고를 모두
확인했다는 목록을 요구하기 때문이다(`PublishStudioDocumentUseCase`).
그 셋은 작성자에게 물어볼 것이 없다. 검증은 서버가 판정하고, 미리보기는 그 판정으로부터
만들어지며, 경고는 게시를 막지 않는다. 그래서 세 요청을 이 버튼 뒤로 옮겼다. 계약도
백엔드도 그대로다 — 사라진 것은 작성자가 밟던 화면이지 서버가 지키던 불변식이 아니다.
*/}
<button
className="studio-primary-button"
type="button"
onClick={() => { void controller.publish(); }}
disabled={busy || controller.status === "CONFLICT"}
>
{controller.publishing ? "게시 중…" : "게시"}
</button>
</div>
{unplacedIssues.length ? (
<ul className="studio-editor-unplaced-issues" aria-label="칸에 붙지 못한 지적">
{unplacedIssues.map((issue) => (
<li key={`${issue.code}:${issue.path}`} data-severity={issue.severity}>
{issue.message} <code>{issue.path}</code>
</li>
))}
</ul>
) : null}
</aside>
);
}
@@ -1,41 +0,0 @@
import type { DocumentEditorController } from "./document-editor-controller.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
const labels = {
CLEAN: "저장됨",
DIRTY: "저장되지 않음",
SAVING: "저장 중…",
CONFLICT: "저장 충돌",
} as const;
const kindLabels = {
CASE: "CASE",
REFERENCE: "REFERENCE",
QUESTION: "QUESTION",
PROJECT_DECISION: "Decision",
} as const;
export function DocumentStatusRail({ controller }: { controller: DocumentEditorController }) {
return (
<aside className="studio-document-status-rail" aria-labelledby="studio-document-status-title">
<p className="studio-eyebrow">WORKING COPY</p>
<h2 id="studio-document-status-title"> </h2>
<p className={`studio-editor-status studio-editor-status--${controller.status.toLowerCase()}`} role="status" aria-label="편집 상태">{labels[controller.status]}</p>
<dl><div><dt> </dt><dd>{controller.saved.version}</dd></div><div><dt></dt><dd>{kindLabels[controller.draft.kind]}</dd></div></dl>
<button type="button" onClick={() => { void controller.save(); }} disabled={controller.status === "CLEAN" || controller.status === "SAVING" || controller.status === "CONFLICT"}>{controller.status === "SAVING" ? "저장 중…" : "저장"}</button>
<GuardedStudioLink className="studio-editor-next-link" href={`/studio/documents/${controller.saved.id}/validation`}> </GuardedStudioLink>
{/*
경고는 하나만 띄운다. 충돌은 그 자체로 무엇을 해야 하는지 말해 주므로 서버가 준 문구보다
앞서고, 그 밖의 실패는 서버가 준 이유를 그대로 보여 준다. 둘을 함께 띄우면 같은 실패를
두 번 말하게 된다.
*/}
{controller.status === "CONFLICT" ? (
<p className="studio-editor-conflict" role="alert"> . .</p>
) : controller.saveError ? (
<p className="studio-editor-conflict" role="alert">{controller.saveError}</p>
) : (
<p> . .</p>
)}
</aside>
);
}
@@ -0,0 +1,73 @@
import type { components } from "../../../contracts/studio/generated.ts";
export type FieldIssue = components["schemas"]["ValidationIssue"];
/**
* 검증 항목을 그 항목이 가리키는 칸 옆으로 나눠 주기 위한 것들.
*
* <p>예전에는 게시하기 전에 검증 화면으로 나가서 버튼을 누르고, 지적을 읽고, 편집 화면으로
* 돌아와 어느 칸이었는지 기억해서 고쳐야 했다. 검증 결과는 이미 `path` 로 어느 칸인지 말하고
* 있었으므로 — `/title`, `/topicId`, `/problem` — 그 자리에 그대로 붙이면 화면을 오갈 이유가
* 없어진다.
*/
/**
* `path` 가 가리키는 칸의 지적을 고른다.
*
* <p>정확히 같은 경로뿐 아니라 그 아래 경로도 함께 고른다. 배열 칸은 서버가
* `/relations/0/targetId` 처럼 항목을 짚어 주는데, 화면에는 `relations` 라는 칸 하나만 있기
* 때문이다. 이렇게 하지 않으면 그런 지적은 어느 칸에도 붙지 못하고 사라진다.
*/
export function issuesFor(
issues: readonly FieldIssue[],
path: string,
): readonly FieldIssue[] {
return issues.filter(
(issue) => issue.path === path || issue.path.startsWith(`${path}/`),
);
}
/**
* `paths` 중 어느 것에도 붙지 않는 지적. 게시 버튼 옆에 남겨 두기 위한 것이다 — 화면에 없는
* 칸을 가리키는 지적이 조용히 사라지면, 작성자는 이유를 말해 주지 않는 실패만 보게 된다.
*/
export function issuesOutside(
issues: readonly FieldIssue[],
paths: readonly string[],
): readonly FieldIssue[] {
return issues.filter(
(issue) =>
!paths.some(
(path) => issue.path === path || issue.path.startsWith(`${path}/`),
),
);
}
/**
* 한 칸에 붙는 지적을 그 칸 아래에 적는다. `ERROR` 는 게시를 막고 `WARNING` 은 막지 않으므로
* 둘을 다른 색으로 구분하되, 둘 다 읽히도록 `role` 을 준다.
*/
export function FieldNotice({
issues,
path,
}: {
issues: readonly FieldIssue[];
path: string;
}) {
const matched = issuesFor(issues, path);
if (matched.length === 0) return null;
return (
<>
{matched.map((issue) => (
<span
key={`${issue.code}:${issue.path}`}
className={`studio-field-notice studio-field-notice--${issue.severity.toLowerCase()}`}
data-severity={issue.severity}
role={issue.severity === "ERROR" ? "alert" : "status"}
>
{issue.message}
</span>
))}
</>
);
}
@@ -0,0 +1,200 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type {
HomeFocusResponse,
ProjectIndexItem,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/**
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
*
* <p>그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며 셋이 모두 비면 홈은 영역 자체를 그리지
* 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울 화면이 없었으므로,
* 홈에서는 그 영역이 한 번도 나타난 적이 없었다.
*
* <p>고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의 `RELATION` 목록에서
* 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른 기준을 쓰면 두 화면이 서로 다른
* 것을 보여 준다.
*
* <p>비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이 정상적인
* 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로 그 사실을 적어 둔다.
*/
export function HomeFocusEditor() {
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
const [focus, setFocus] = useState<HomeFocusResponse | null>(null);
const [projects, setProjects] = useState<ProjectIndexItem[]>([]);
const [relations, setRelations] = useState<CatalogEntry[]>([]);
const [projectId, setProjectId] = useState("");
const [questionId, setQuestionId] = useState("");
const [decisionId, setDecisionId] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
/*
try/catch 로 감싼다. `Promise.all([gateway.foo()])` 은 `foo` 가 거절하는 것만 잡는다 —
호출 자체가 동기적으로 던지면(예: 그 표면을 갖추지 않은 게이트웨이) 배열을 만드는 중에
터지므로 rejection handler 를 지나지 못한다. 그러면 이 섹션이 아니라 대시보드 전체가
빈 화면이 된다. 한 칸의 실패가 화면을 통째로 날리지 않게 한다.
*/
void (async () => {
try {
const [current, projectPage, catalog] = await Promise.all([
managementGateway.getHomeFocus(),
managementGateway.listProjects(0, 50),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]);
if (cancelled) return;
setFocus(current);
setProjects(projectPage.items ?? []);
setRelations(catalog.items ?? []);
setProjectId(current.currentProjectId ?? "");
setQuestionId(current.openQuestionId ?? "");
setDecisionId(current.recentDecisionId ?? "");
setError("");
} catch {
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
}
})();
return () => {
cancelled = true;
};
}, [gateway, managementGateway, generation]);
const questions = relations.filter((entry) => entry.kind === "QUESTION");
const decisions = relations.filter((entry) => entry.kind === "PROJECT_DECISION");
const save = async (event: FormEvent) => {
event.preventDefault();
if (pending || !focus) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateHomeFocus({
expectedVersion: focus.version,
// 빈 문자열은 "고르지 않음" 이다. 계약은 uuid 만 받으므로 보내지 않는다.
currentProjectId: projectId || undefined,
openQuestionId: questionId || undefined,
recentDecisionId: decisionId || undefined,
});
setFocus(saved);
setRequestAnnouncement("홈에 표시할 항목을 저장했습니다.");
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "홈 설정을 저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const chosenProject = projects.find((project) => project.id === projectId);
const projectHidden = chosenProject && chosenProject.targetVisibility === "PRIVATE";
const nothingChosen = !projectId && !questionId && !decisionId;
return (
<section className="studio-editor-section" aria-labelledby="studio-home-focus-title">
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">PUBLIC HOME</p>
<h2 id="studio-home-focus-title"> </h2>
<p>
. .
</p>
</div>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!focus && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{focus ? (
<form onSubmit={save}>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide">
<span> ()</span>
<select value={projectId} onChange={(event) => setProjectId(event.currentTarget.value)}>
<option value=""> </option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
{project.targetVisibility === "PRIVATE" ? " (비공개)" : ""}
</option>
))}
</select>
{projectHidden ? (
<small>
. · .
</small>
) : (
<small>· · .</small>
)}
</label>
<label className="studio-field">
<span> </span>
<select
value={questionId}
onChange={(event) => setQuestionId(event.currentTarget.value)}
disabled={questions.length === 0}
>
<option value=""> </option>
{questions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
{questions.length === 0 ? <small> Question .</small> : null}
</label>
<label className="studio-field">
<span> </span>
<select
value={decisionId}
onChange={(event) => setDecisionId(event.currentTarget.value)}
disabled={decisions.length === 0}
>
<option value=""> </option>
{decisions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
{decisions.length === 0 ? <small> Decision .</small> : null}
</label>
</div>
<div className="studio-editor-footer">
<button className="studio-secondary-button" type="submit" disabled={pending}>
{pending ? "저장하는 중" : "홈 설정 저장"}
</button>
<p className="studio-field-note">
{nothingChosen
? "지금은 아무것도 고르지 않아 홈에 이 영역이 나타나지 않습니다."
: "저장하면 공개 홈에 바로 반영됩니다."}
{" "}
<GuardedStudioLink href="/studio/taxonomy">·</GuardedStudioLink>
.
</p>
</div>
</form>
) : null}
</section>
);
}
@@ -1,5 +1,5 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { createNewItemId } from "../../../domain/studio/local-id.ts";
type OrderedText = components["schemas"]["OrderedText"];
@@ -25,7 +25,7 @@ export function OrderedTextList({ label, fieldId, items, onChange }: { label: st
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => move(index, 1)} disabled={index === items.length - 1}></button><button type="button" onClick={() => onChange(ordered(items.filter((_, candidateIndex) => candidateIndex !== index)))}></button></div>
</div>
))}
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createLocalId("item"), text: "", order: items.length }])); }}>{label} </button>
<button className="studio-add-item" type="button" disabled={items.length >= 50} onClick={() => { if (items.length < 50) onChange(ordered([...items, { id: createNewItemId(), text: "", order: items.length }])); }}>{label} </button>
</fieldset>
);
}
@@ -1,13 +1,26 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { OrderedTextList } from "./ordered-text-list.tsx";
type ProjectDecisionInput = components["schemas"]["ProjectDecisionInput"];
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
export const DECISION_FIELD_PATHS = [
"/decisionStatus",
"/decidedOn",
"/statement",
"/rationale",
"/consequences",
] as const;
export function ProjectDecisionFields({
draft,
issues,
onChange,
}: {
draft: ProjectDecisionInput;
/** 마지막 게시 시도가 남긴 지적. 각 칸 아래에는 그 칸의 것만 붙는다. */
issues: readonly FieldIssue[];
onChange(draft: ProjectDecisionInput): void;
}) {
const update = (patch: Partial<ProjectDecisionInput>) => {
@@ -41,6 +54,7 @@ export function ProjectDecisionFields({
<option value="PROPOSED">PROPOSED</option>
<option value="ADOPTED">ADOPTED</option>
</select>
<FieldNotice issues={issues} path="/decisionStatus" />
</label>
<label className="studio-field">
<span></span>
@@ -51,6 +65,7 @@ export function ProjectDecisionFields({
update({ decidedOn: event.currentTarget.value || null });
}}
/>
<FieldNotice issues={issues} path="/decidedOn" />
</label>
<label className="studio-field studio-field--wide">
<span></span>
@@ -58,6 +73,7 @@ export function ProjectDecisionFields({
value={draft.statement}
onChange={(event) => update({ statement: event.currentTarget.value })}
/>
<FieldNotice issues={issues} path="/statement" />
</label>
<label className="studio-field studio-field--wide">
<span> </span>
@@ -65,6 +81,7 @@ export function ProjectDecisionFields({
value={draft.rationale}
onChange={(event) => update({ rationale: event.currentTarget.value })}
/>
<FieldNotice issues={issues} path="/rationale" />
</label>
</div>
<OrderedTextList
@@ -73,6 +90,7 @@ export function ProjectDecisionFields({
items={draft.consequences}
onChange={(consequences) => update({ consequences })}
/>
<FieldNotice issues={issues} path="/consequences" />
</section>
);
}
@@ -0,0 +1,458 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { components } from "../../../contracts/studio/generated.ts";
import type {
ProjectActivityResponse,
ProjectEditResponse,
TopicEdit,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { slugFromName } from "./slug-from-name.ts";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio } from "../use-studio.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
function routeId(value: unknown): string {
return typeof value === "string" ? value : "";
}
/** `project_phase_check` 와 같은 집합이다 — 화면이 더 많은 값을 보여 주면 저장이 제약에서 터진다. */
const PHASES = [
{ value: "RESEARCH", label: "조사" },
{ value: "DESIGN", label: "설계" },
{ value: "IMPLEMENTATION", label: "구현" },
{ value: "VERIFICATION", label: "검증" },
{ value: "MAINTENANCE", label: "유지" },
{ value: "PAUSED", label: "중단" },
{ value: "COMPLETED", label: "완료" },
] as const;
/** `project_activity_activity_type_check` 와 같은 집합이다. */
const ACTIVITY_TYPES = [
{ value: "MILESTONE_REACHED", label: "이정표 도달" },
{ value: "PHASE_CHANGED", label: "단계 전환" },
{ value: "QUESTION_OPENED", label: "질문 열림" },
{ value: "QUESTION_RESOLVED", label: "질문 해결" },
{ value: "DECISION_ACCEPTED", label: "결정 수락" },
{ value: "CASE_PUBLISHED", label: "Case 게시" },
{ value: "REFERENCE_PUBLISHED", label: "Reference 게시" },
{ value: "PROJECT_PAUSED", label: "프로젝트 중단" },
{ value: "PROJECT_RESUMED", label: "프로젝트 재개" },
] as const;
const activityTypeLabel = (value: string) =>
ACTIVITY_TYPES.find((type) => type.value === value)?.label ?? value;
/** `<input type="datetime-local">` 은 초와 시간대가 없는 지역 시각을 쓴다. */
function toLocalInput(isoTimestamp: string): string {
const value = new Date(isoTimestamp);
if (Number.isNaN(value.getTime())) return "";
const pad = (part: number) => String(part).padStart(2, "0");
return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(
value.getHours(),
)}:${pad(value.getMinutes())}`;
}
function fromLocalInput(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
}
type Draft = {
name: string;
slug: string;
oneLinePurpose: string;
purposeMarkdown: string;
boundaryMarkdown: string;
systemOverviewMarkdown: string;
phase: string;
currentObjective: string;
nextStep: string;
technologyLabels: string;
topicIds: readonly string[];
};
function toDraft(project: ProjectEditResponse): Draft {
return {
name: project.name,
slug: project.slug ?? "",
oneLinePurpose: project.oneLinePurpose ?? "",
purposeMarkdown: project.purposeMarkdown ?? "",
boundaryMarkdown: project.boundaryMarkdown ?? "",
systemOverviewMarkdown: project.systemOverviewMarkdown ?? "",
phase: project.phase,
currentObjective: project.currentObjective ?? "",
nextStep: project.nextStep ?? "",
technologyLabels: (project.technologyLabels ?? []).join(", "),
topicIds: project.topicIds ?? [],
};
}
/**
* 프로젝트 한 편의 편집 화면.
*
* <p>이 화면이 없는 동안 프로젝트는 이름과 slug 만 가질 수 있었다. 그래서 공개 화면의 "현재 목표"·"다음
* 작업"·"주요 주제" 가 늘 비어 있었고 홈의 집중 카드도 제목만 남았다 — 백엔드의 `updateProject` 는
* 처음부터 구현돼 있었고 채울 화면만 없었다.
*
* <p>활동도 여기서 다룬다. 프로젝트 밖의 활동은 존재하지 않으므로 따로 둘 이유가 없고, 무엇이 공개
* 타임라인에 실리는지를 프로젝트 필드와 같은 자리에서 보는 편이 낫다.
*
* <p>새 CSS 를 만들지 않는다 — 문서 편집기의 `studio-editor-section`·`studio-field-grid` 와 작업본
* 목록의 행 클래스를 그대로 쓰므로 Studio 의 나머지와 같은 간격·타이포·색을 따른다.
*/
export function ProjectEditor() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_PROJECT_EDIT">();
const projectId = routeId(params.id);
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
const [project, setProject] = useState<ProjectEditResponse | null>(null);
const [topics, setTopics] = useState<TopicEdit[]>([]);
const [records, setRecords] = useState<CatalogEntry[]>([]);
const [activities, setActivities] = useState<ProjectActivityResponse[]>([]);
const [draft, setDraft] = useState<Draft | null>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
// 동기적으로 던지는 호출도 잡는다 — `home-focus-editor` 와 같은 이유다.
void (async () => {
try {
const [loaded, activityList, topicList, catalog] = await Promise.all([
managementGateway.getProject(projectId),
managementGateway.listProjectActivities(projectId),
managementGateway.listTopics(),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]);
if (cancelled) return;
setProject(loaded);
setDraft(toDraft(loaded));
setActivities(activityList);
setTopics(topicList.filter((topic) => topic.status !== "ARCHIVED"));
setRecords(catalog.items ?? []);
setError("");
} catch {
if (!cancelled) setError("프로젝트를 불러오지 못했습니다.");
}
})();
return () => {
cancelled = true;
};
}, [gateway, managementGateway, projectId, generation]);
const update = (patch: Partial<Draft>) =>
setDraft((current) => (current ? { ...current, ...patch } : current));
const toggleTopic = (topicId: string) =>
setDraft((current) => {
if (!current) return current;
const chosen = current.topicIds.includes(topicId)
? current.topicIds.filter((id) => id !== topicId)
: [...current.topicIds, topicId];
return { ...current, topicIds: chosen };
});
const save = async (event: FormEvent) => {
event.preventDefault();
if (pending || !project || !draft) return;
setPending(true);
setError("");
try {
/*
slug 는 공개 주소다. 비워 두면 게시할 수 없으므로 이름에서 만들어 채운다 — 예전에는 조용히
빈 값으로 저장되었고, 게시할 때가 되어서야 "slug 가 없다" 는 말을 들었다.
*/
const saved = await managementGateway.updateProject(project.id, {
expectedVersion: project.version,
name: draft.name.trim(),
slug: draft.slug.trim() || slugFromName(draft.name),
oneLinePurpose: draft.oneLinePurpose,
purposeMarkdown: draft.purposeMarkdown,
boundaryMarkdown: draft.boundaryMarkdown,
systemOverviewMarkdown: draft.systemOverviewMarkdown,
phase: draft.phase,
currentObjective: draft.currentObjective,
nextStep: draft.nextStep,
technologyLabels: draft.technologyLabels
.split(",")
.map((label) => label.trim())
.filter(Boolean),
topicIds: [...draft.topicIds],
targetVisibility:
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
? project.targetVisibility
: "PRIVATE",
featuredOrder: project.featuredOrder,
});
/*
이미 공개된 프로젝트라면 공개 투영도 함께 세워야 한다. 투영은 게시할 때 만들어지므로 저장만
하면 공개 화면에는 예전 값이 남는다.
*/
if (saved.targetVisibility !== "PRIVATE") {
await managementGateway.publishProject(
saved.id,
saved.version,
saved.targetVisibility === "UNLISTED" ? "UNLISTED" : "PUBLIC",
);
}
setRequestAnnouncement(`프로젝트를 버전 ${saved.version}으로 저장했습니다.`);
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "프로젝트를 저장하지 못했습니다."));
} finally {
setPending(false);
}
};
// 폼 제출과 같은 일을 하므로 submit 이벤트를 만들어 보낸다 — 검증·기본값 처리가 한 곳에 남는다.
useSaveShortcut(
() => void save({ preventDefault: () => {} } as FormEvent),
Boolean(project && draft) && !pending,
);
const published = project ? project.targetVisibility !== "PRIVATE" : false;
return (
<div className="studio-page studio-editor-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">
PROJECT · VERSION {project?.version ?? "—"}
</p>
<h1>{project?.name ?? "프로젝트"}</h1>
<p>
&ldquo; &rdquo;,
.
</p>
</div>
<GuardedStudioLink className="studio-primary-action" href="/studio/taxonomy">
·
</GuardedStudioLink>
</header>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!project && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{project && draft ? (
<>
<form onSubmit={save}>
<section
className="studio-editor-section"
aria-labelledby="studio-project-basics-title"
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">PROJECT</p>
<h2 id="studio-project-basics-title"> </h2>
<p> .</p>
</div>
<div className="studio-field-grid">
<label className="studio-field studio-field--wide">
<span></span>
<input
value={draft.name}
maxLength={120}
onChange={(event) => update({ name: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span>slug</span>
<input
value={draft.slug}
maxLength={100}
placeholder="비우면 이름에서 만듭니다"
onChange={(event) => update({ slug: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<select
value={draft.phase}
onChange={(event) => update({ phase: event.currentTarget.value })}
>
{PHASES.map((phase) => (
<option key={phase.value} value={phase.value}>
{phase.label}
</option>
))}
</select>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<input
value={draft.oneLinePurpose}
maxLength={200}
onChange={(event) => update({ oneLinePurpose: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span> </span>
<input
value={draft.currentObjective}
onChange={(event) => update({ currentObjective: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span> </span>
<input
value={draft.nextStep}
onChange={(event) => update({ nextStep: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<input
value={draft.technologyLabels}
placeholder="쉼표로 구분합니다"
onChange={(event) => update({ technologyLabels: event.currentTarget.value })}
/>
</label>
</div>
</section>
<section
className="studio-editor-section"
aria-labelledby="studio-project-topics-title"
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">TOPICS</p>
<h2 id="studio-project-topics-title"> </h2>
<p> &ldquo; &rdquo; .</p>
</div>
{topics.length === 0 ? (
<p className="studio-empty-inline">
. · .
</p>
) : (
<ul className="studio-choice-list">
{topics.map((topic) => (
<li key={topic.id}>
<label className="studio-choice">
<input
type="checkbox"
checked={draft.topicIds.includes(topic.id ?? "")}
onChange={() => toggleTopic(topic.id ?? "")}
/>
<span>{topic.name}</span>
<small>{topic.slug}</small>
</label>
</li>
))}
</ul>
)}
</section>
<section
className="studio-editor-section"
aria-labelledby="studio-project-narrative-title"
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">NARRATIVE</p>
<h2 id="studio-project-narrative-title"></h2>
<p>
&ldquo; &rdquo; .
</p>
</div>
{/*
경계와 시스템 개요 칸이 여기 함께 있었다. 저장은 되지만 공개 화면 어디에도
나오지 않는다 — 프로젝트 화면이 그리는 것은 목적 하나다. 쓰는 사람에게는 쓴 글이
어딘가 실릴 것처럼 보이는 칸이었고, 실제로는 아무도 읽지 않았다.
열의 데이터는 남겨 둔다(계약이 요구하고, 이미 적어 둔 글이 있을 수 있다). 저장할
때 서버가 가진 값을 그대로 돌려보내므로 지워지지 않는다.
*/}
<div className="studio-field-grid">
<label className="studio-field studio-field--wide">
<span></span>
<textarea
className="studio-markdown"
value={draft.purposeMarkdown}
onChange={(event) => update({ purposeMarkdown: event.currentTarget.value })}
/>
<small> .</small>
</label>
</div>
</section>
<div className="studio-editor-footer">
<button className="studio-primary-button" type="submit" title="Ctrl+S" disabled={pending}>
{pending ? "저장하는 중" : "저장"}
</button>
<p className="studio-field-note">
{published
? "공개된 프로젝트입니다. 저장하면 공개 화면도 함께 갱신됩니다."
: "아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 공개 화면에 나옵니다."}
</p>
</div>
</form>
<section
className="studio-editor-section"
aria-labelledby="studio-project-activity-title"
>
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">ACTIVITY</p>
<h2 id="studio-project-activity-title"></h2>
<p>
. .
</p>
</div>
{activities.length === 0 ? (
<p className="studio-empty-inline">
.
</p>
) : (
<div className="studio-document-list">
{activities.map((activity) => (
<article className="studio-document-row" key={activity.id}>
<p className="studio-row-label">{activityTypeLabel(activity.activityType)}</p>
<div className="studio-document-title">
<h2>{activity.title}</h2>
<p>
{activity.visibility === "PUBLIC"
? "공개 타임라인에 나옵니다"
: "비공개 — 타임라인에 나오지 않습니다"}
</p>
</div>
<dl>
<div>
<dt></dt>
<dd>
<time dateTime={activity.occurredAt}>
{new Date(activity.occurredAt).toLocaleDateString("ko-KR", {
timeZone: "Asia/Seoul",
})}
</time>
</dd>
</div>
<div>
<dt></dt>
<dd>{activity.origin === "AUTO" ? "게시가 남김" : "손으로 적음"}</dd>
</div>
</dl>
</article>
))}
</div>
)}
</section>
</>
) : null}
</div>
);
}
@@ -1,7 +1,20 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { createNewItemId } from "../../../domain/studio/local-id.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { OrderedTextList } from "./ordered-text-list.tsx";
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
export const QUESTION_FIELD_PATHS = [
"/questionStatus",
"/facts",
"/assumptions",
"/unknowns",
"/constraints",
"/options",
"/nextValidation",
"/resolution",
] as const;
type CatalogEntry = components["schemas"]["CatalogEntry"];
type QuestionInput = components["schemas"]["QuestionInput"];
type QuestionOption = components["schemas"]["QuestionOption"];
@@ -10,7 +23,7 @@ function orderedOptions(options: QuestionOption[]): QuestionOption[] {
return options.map((option, order) => ({ ...option, order }));
}
export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; onChange(draft: QuestionInput): void }) {
export function QuestionFields({ draft, evidence, issues, onChange }: { draft: QuestionInput; evidence: CatalogEntry[]; issues: readonly FieldIssue[]; onChange(draft: QuestionInput): void }) {
const update = (patch: Partial<QuestionInput>) => onChange({ ...draft, ...patch });
const moveOption = (index: number, delta: -1 | 1) => {
const target = index + delta;
@@ -26,22 +39,28 @@ export function QuestionFields({ draft, evidence, onChange }: { draft: QuestionI
const value = event.currentTarget.value;
if (value === "RESOLVED") update({ questionStatus: "RESOLVED", resolution: draft.resolution ?? { summary: "", evidenceTargetId: null, linkLabel: "" } });
else update({ questionStatus: value === "OPEN" ? "OPEN" : null, resolution: null });
}}><option value=""> </option><option value="OPEN">OPEN</option><option value="RESOLVED">RESOLVED</option></select></label>
}}><option value=""> </option><option value="OPEN">OPEN</option><option value="RESOLVED">RESOLVED</option></select><FieldNotice issues={issues} path="/questionStatus" /></label>
<OrderedTextList label="사실" fieldId="studio-field-facts" items={draft.facts} onChange={(facts) => update({ facts })} />
<FieldNotice issues={issues} path="/facts" />
<OrderedTextList label="가정" fieldId="studio-field-assumptions" items={draft.assumptions} onChange={(assumptions) => update({ assumptions })} />
<FieldNotice issues={issues} path="/assumptions" />
<OrderedTextList label="미지수" fieldId="studio-field-unknowns" items={draft.unknowns} onChange={(unknowns) => update({ unknowns })} />
<FieldNotice issues={issues} path="/unknowns" />
<OrderedTextList label="제약" fieldId="studio-field-constraints" items={draft.constraints} onChange={(constraints) => update({ constraints })} />
<FieldNotice issues={issues} path="/constraints" />
<fieldset id="studio-field-options" className="studio-ordered-list" tabIndex={-1}><legend></legend>
<FieldNotice issues={issues} path="/options" />
{draft.options.length === 0 ? <p> .</p> : null}
{draft.options.map((option, index) => <div className="studio-ordered-item" key={option.id}>
<label><span> {index + 1} </span><input value={option.title} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
<label><span> {index + 1} </span><textarea value={option.description} onChange={(event) => update({ options: orderedOptions(draft.options.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, description: event.currentTarget.value } : candidate)) })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => moveOption(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => moveOption(index, 1)} disabled={index === draft.options.length - 1}></button><button type="button" onClick={() => update({ options: orderedOptions(draft.options.filter((_, candidateIndex) => candidateIndex !== index)) })}></button></div>
</div>)}
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createLocalId("option"), title: "", description: "", order: draft.options.length }]) }); }}> </button>
<button className="studio-add-item" type="button" disabled={draft.options.length >= 50} onClick={() => { if (draft.options.length < 50) update({ options: orderedOptions([...draft.options, { id: createNewItemId(), title: "", description: "", order: draft.options.length }]) }); }}> </button>
</fieldset>
<label className="studio-field studio-field--wide"><span> </span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /></label>
<label className="studio-field studio-field--wide"><span> </span><textarea value={draft.nextValidation} onChange={(event) => update({ nextValidation: event.currentTarget.value })} /><FieldNotice issues={issues} path="/nextValidation" /></label>
{draft.questionStatus === "RESOLVED" && draft.resolution ? <fieldset className="studio-resolution-fields"><legend> </legend>
<FieldNotice issues={issues} path="/resolution" />
<label className="studio-field studio-field--wide"><span> </span><textarea value={draft.resolution.summary} onChange={(event) => update({ resolution: { ...draft.resolution!, summary: event.currentTarget.value } })} /></label>
<label className="studio-field"><span> </span><select value={draft.resolution.evidenceTargetId ?? ""} onChange={(event) => update({ resolution: { ...draft.resolution!, evidenceTargetId: event.currentTarget.value || null } })}><option value=""> </option>{evidence.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}</select></label>
<label className="studio-field"><span> </span><input value={draft.resolution.linkLabel} onChange={(event) => update({ resolution: { ...draft.resolution!, linkLabel: event.currentTarget.value } })} /></label>
@@ -1,15 +1,26 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { createNewItemId } from "../../../domain/studio/local-id.ts";
import { FieldNotice, type FieldIssue } from "./field-issues.tsx";
import { OrderedTextList } from "./ordered-text-list.tsx";
type ReferenceInput = components["schemas"]["ReferenceInput"];
type ReferenceRule = components["schemas"]["ReferenceRule"];
/** 이 화면이 자기 칸 아래에 보여 줄 수 있는 경로. */
export const REFERENCE_FIELD_PATHS = [
"/purpose",
"/rules",
"/applyWhen",
"/exceptions",
"/examples",
"/verifiedOn",
] as const;
function orderedRules(rules: ReferenceRule[]): ReferenceRule[] {
return rules.map((rule, order) => ({ ...rule, order }));
}
export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; onChange(draft: ReferenceInput): void }) {
export function ReferenceFields({ draft, issues, onChange }: { draft: ReferenceInput; issues: readonly FieldIssue[]; onChange(draft: ReferenceInput): void }) {
const update = (patch: Partial<ReferenceInput>) => onChange({ ...draft, ...patch });
const moveRule = (index: number, delta: -1 | 1) => {
const target = index + delta;
@@ -21,20 +32,24 @@ export function ReferenceFields({ draft, onChange }: { draft: ReferenceInput; on
return (
<section className="studio-editor-section" aria-labelledby="studio-reference-fields-title">
<div className="studio-editor-section-heading"><p className="studio-eyebrow">REFERENCE</p><h2 id="studio-reference-fields-title"> </h2></div>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /></label>
<label className="studio-field studio-field--wide"><span></span><textarea value={draft.purpose} onChange={(event) => update({ purpose: event.currentTarget.value })} /><FieldNotice issues={issues} path="/purpose" /></label>
<fieldset className="studio-ordered-list"><legend></legend>
<FieldNotice issues={issues} path="/rules" />
{draft.rules.length === 0 ? <p> .</p> : null}
{draft.rules.map((rule, index) => <div className="studio-ordered-item" key={rule.id}>
<label><span> {index + 1} </span><input value={rule.title} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, title: event.currentTarget.value } : candidate)) })} /></label>
<label><span> {index + 1} </span><textarea value={rule.body} onChange={(event) => update({ rules: orderedRules(draft.rules.map((candidate, candidateIndex) => candidateIndex === index ? { ...candidate, body: event.currentTarget.value } : candidate)) })} /></label>
<div className="studio-item-actions"><button type="button" onClick={() => moveRule(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => moveRule(index, 1)} disabled={index === draft.rules.length - 1}></button><button type="button" onClick={() => update({ rules: orderedRules(draft.rules.filter((_, candidateIndex) => candidateIndex !== index)) })}></button></div>
</div>)}
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createLocalId("rule"), title: "", body: "", order: draft.rules.length }]) }); }}> </button>
<button className="studio-add-item" type="button" disabled={draft.rules.length >= 50} onClick={() => { if (draft.rules.length < 50) update({ rules: orderedRules([...draft.rules, { id: createNewItemId(), title: "", body: "", order: draft.rules.length }]) }); }}> </button>
</fieldset>
<OrderedTextList label="적용 조건" items={draft.applyWhen} onChange={(applyWhen) => update({ applyWhen })} />
<FieldNotice issues={issues} path="/applyWhen" />
<OrderedTextList label="예외" items={draft.exceptions} onChange={(exceptions) => update({ exceptions })} />
<FieldNotice issues={issues} path="/exceptions" />
<OrderedTextList label="예시" items={draft.examples} onChange={(examples) => update({ examples })} />
<label className="studio-field"><span> </span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /></label>
<FieldNotice issues={issues} path="/examples" />
<label className="studio-field"><span> </span><input type="date" value={draft.verifiedOn ?? ""} onChange={(event) => update({ verifiedOn: event.currentTarget.value || null })} /><FieldNotice issues={issues} path="/verifiedOn" /></label>
</section>
);
}
@@ -1,5 +1,5 @@
import type { components } from "../../../contracts/studio/generated.ts";
import { createLocalId } from "../../../domain/studio/local-id.ts";
import { createNewItemId } from "../../../domain/studio/local-id.ts";
type CatalogEntry = components["schemas"]["CatalogEntry"];
type RelationInput = components["schemas"]["RelationInput"];
@@ -29,7 +29,7 @@ export function RelationEditor({ relations, catalog, evidence = false, onChange
<div className="studio-item-actions"><button type="button" onClick={() => move(index, -1)} disabled={index === 0}></button><button type="button" onClick={() => move(index, 1)} disabled={index === relations.length - 1}></button><button type="button" onClick={() => onChange(ordered(relations.filter((_, candidateIndex) => candidateIndex !== index)))}></button></div>
</div>
))}
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createLocalId("relation"), targetId: null, reason: "", order: relations.length }])); }}>{noun} </button>
<button className="studio-add-item" type="button" disabled={relations.length >= 20} onClick={() => { if (relations.length < 20) onChange(ordered([...relations, { id: createNewItemId(), targetId: null, reason: "", order: relations.length }])); }}>{noun} </button>
</fieldset>
);
}
@@ -0,0 +1,342 @@
import { useCallback, useEffect, useState } from "react";
import type {
ReleaseEditResponse,
ReleaseUpdateRequest,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useSaveShortcut } from "./use-save-shortcut.ts";
import { useStudio } from "../use-studio.ts";
/**
* 릴리즈 한 편의 편집 화면.
*
* <p>예전에는 이 폼이 릴리즈 목록 <em>아래</em>에 열렸다. 릴리즈가 열 개 스무 개로 늘면 편집하려고
* 목록 전체를 지나 내려가야 하고, 저장 버튼은 그보다 더 아래에 있다. 문서·프로젝트가 각자 편집
* 주소를 갖는 것과 같은 이유로 릴리즈도 자기 주소를 갖는다.
*
* <p>본문이 마크다운 여섯 칸으로 나뉜 것은 계약의 모양이자 릴리즈 노트의 성격이다. 한 칸짜리 자유
* 서술이었다면 "검증을 안 썼다"를 아무도 알아채지 못한다.
*/
/** 백엔드 {@code UpdateReleaseUseCase.CHANGE_TYPES} 와 같은 집합이다. */
const CHANGE_TYPES = [
{ value: "FEATURE", label: "기능" },
{ value: "FIX", label: "수정" },
{ value: "REFACTOR", label: "구조" },
{ value: "DOCS", label: "문서" },
{ value: "INFRA", label: "인프라" },
{ value: "BREAKING", label: "호환 깨짐" },
] as const;
const SECTIONS = [
{ key: "reasonMarkdown", label: "왜 바꿨나" },
{ key: "changesMarkdown", label: "무엇을 바꿨나" },
{ key: "userImpactMarkdown", label: "사용자에게 달라지는 것" },
{ key: "implementationImpactMarkdown", label: "구현에 남는 것" },
{ key: "verificationMarkdown", label: "어떻게 검증했나" },
{ key: "knownLimitationsMarkdown", label: "아직 못 한 것" },
] as const;
type Draft = Readonly<{
expectedVersion: number;
versionLabel: string;
title: string;
summary: string;
releasedOn: string;
changeTypes: readonly string[];
reasonMarkdown: string;
changesMarkdown: string;
userImpactMarkdown: string;
implementationImpactMarkdown: string;
verificationMarkdown: string;
knownLimitationsMarkdown: string;
}>;
function toDraft(release: ReleaseEditResponse): Draft {
return {
expectedVersion: release.version,
versionLabel: release.versionLabel,
title: release.title,
summary: release.summary ?? "",
releasedOn: release.releasedOn ?? "",
changeTypes: release.changeTypes ?? [],
reasonMarkdown: release.reasonMarkdown ?? "",
changesMarkdown: release.changesMarkdown ?? "",
userImpactMarkdown: release.userImpactMarkdown ?? "",
implementationImpactMarkdown: release.implementationImpactMarkdown ?? "",
verificationMarkdown: release.verificationMarkdown ?? "",
knownLimitationsMarkdown: release.knownLimitationsMarkdown ?? "",
};
}
const STATUS_LABELS: Readonly<Record<string, string>> = {
DRAFT: "작성 중",
PUBLISHED: "공개",
ARCHIVED: "보관",
};
/** 경로 파라미터는 `unknown` 으로 들어온다 — 라우터가 코덱을 통과시킨 값이라도 타입은 좁혀 써야 한다. */
function routeId(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function ReleaseEditor() {
const { params } = useRouteInput<"TECH_LOG_STUDIO_RELEASE_EDIT">();
const releaseId = routeId(params.id);
const { managementGateway, setRequestAnnouncement } = useStudio();
const [draft, setDraft] = useState<Draft | null>(null);
const [status, setStatus] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [generation, setGeneration] = useState(0);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
let cancelled = false;
void managementGateway.getRelease(releaseId).then(
(release) => {
if (cancelled) return;
setDraft(toDraft(release));
setStatus(release.workflowStatus);
setError("");
},
() => {
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
},
);
return () => {
cancelled = true;
};
}, [managementGateway, releaseId, generation]);
const update = (patch: Partial<Draft>) =>
setDraft((current) => (current === null ? current : { ...current, ...patch }));
const requestOf = (current: Draft): ReleaseUpdateRequest =>
({
expectedVersion: current.expectedVersion,
versionLabel: current.versionLabel.trim(),
title: current.title.trim(),
summary: current.summary,
changeTypes: [...current.changeTypes],
changesMarkdown: current.changesMarkdown,
verificationMarkdown: current.verificationMarkdown,
...(current.releasedOn ? { releasedOn: current.releasedOn } : {}),
reasonMarkdown: current.reasonMarkdown,
userImpactMarkdown: current.userImpactMarkdown,
implementationImpactMarkdown: current.implementationImpactMarkdown,
knownLimitationsMarkdown: current.knownLimitationsMarkdown,
}) as ReleaseUpdateRequest;
const save = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "저장하지 못했습니다."));
} finally {
setPending(false);
}
};
const publish = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
/*
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
*/
const saved = await managementGateway.updateRelease(releaseId, requestOf(draft));
setDraft(toDraft(saved));
const published = await managementGateway.publishRelease(releaseId, saved.version);
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
reload();
} catch (failure) {
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
setError(
managementFailureMessage(
failure,
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
),
);
} finally {
setPending(false);
}
};
const archive = async () => {
if (pending || draft === null) return;
setPending(true);
setError("");
try {
await managementGateway.archiveRelease(releaseId, draft.expectedVersion);
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
reload();
} catch (failure) {
setError(managementFailureMessage(failure, "공개에서 내리지 못했습니다."));
} finally {
setPending(false);
}
};
useSaveShortcut(save, draft !== null && !pending);
const toggleChangeType = (value: string) => {
if (draft === null) return;
update({
changeTypes: draft.changeTypes.includes(value)
? draft.changeTypes.filter((entry) => entry !== value)
: [...draft.changeTypes, value],
});
};
return (
<div className="studio-page studio-editor-page">
<header className="studio-page-top">
<div className="studio-page-heading">
<p className="studio-eyebrow">
RELEASE · {STATUS_LABELS[status] ?? status}
</p>
<h1>{draft?.title || "릴리즈"}</h1>
<p> .</p>
</div>
<GuardedStudioLink className="studio-primary-action" href="/studio/releases">
</GuardedStudioLink>
</header>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{draft === null && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{draft === null ? null : (
<section className="studio-editor-section" aria-labelledby="studio-release-edit-title">
<div className="studio-editor-section-heading">
<p className="studio-eyebrow">RELEASE · {draft.versionLabel}</p>
<h2 id="studio-release-edit-title">{draft.title || "릴리즈"}</h2>
<p> .</p>
</div>
<div className="studio-field-grid">
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.versionLabel}
placeholder="0.1.0"
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
/>
{/*
새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아
있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져
보이므로 왜 거절당하는지 알 길이 없었다.
*/}
{draft.versionLabel.startsWith("draft-") ? (
<span className="studio-field-notice studio-field-notice--error" role="alert">
. (: 0.2.0).
</span>
) : null}
</label>
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.title}
onChange={(event) => update({ title: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<input
type="date"
value={draft.releasedOn}
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<textarea
value={draft.summary}
onChange={(event) => update({ summary: event.currentTarget.value })}
/>
</label>
</div>
<fieldset className="studio-checkbox-set">
<legend> </legend>
{CHANGE_TYPES.map((changeType) => (
<label key={changeType.value} className="studio-field studio-field--checkbox">
<input
type="checkbox"
checked={draft.changeTypes.includes(changeType.value)}
onChange={() => toggleChangeType(changeType.value)}
/>
<span>{changeType.label}</span>
</label>
))}
</fieldset>
<div className="studio-field-grid">
{SECTIONS.map((section) => (
<label key={section.key} className="studio-field studio-field--wide">
<span>{section.label}</span>
<textarea
value={draft[section.key]}
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
/>
</label>
))}
</div>
<div className="studio-create-footer">
<button
className="studio-primary-button"
type="button"
title="Ctrl+S"
disabled={pending}
onClick={() => void save()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void publish()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void archive()}
>
</button>
</div>
</section>
)}
</div>
);
}
@@ -5,6 +5,8 @@ import type {
ReleaseIndexItem,
ReleaseUpdateRequest,
} from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { useStudio } from "../use-studio.ts";
/**
@@ -80,13 +82,13 @@ const STATUS_LABELS: Readonly<Record<string, string>> = {
export function ReleaseManager() {
const { managementGateway, setRequestAnnouncement } = useStudio();
const [releases, setReleases] = useState<ReleaseIndexItem[] | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [pending, setPending] = useState(false);
const [generation, setGeneration] = useState(0);
const [newTitle, setNewTitle] = useState("");
/* 방금 만든 릴리즈를 목록에서 짚어 준다 — 목록이 길면 어느 것이 새것인지 알기 어렵다. */
const [createdId, setCreatedId] = useState<string | null>(null);
const reload = useCallback(() => setGeneration((value) => value + 1), []);
@@ -111,29 +113,6 @@ export function ReleaseManager() {
};
}, [managementGateway, generation]);
// 선택된 릴리즈의 본문은 목록에 없다 — 목록 행은 마크다운을 싣지 않으므로 따로 읽는다.
useEffect(() => {
if (selectedId === null) {
setDraft(null);
return undefined;
}
let cancelled = false;
void managementGateway.getRelease(selectedId).then(
(release) => {
if (!cancelled) setDraft(toDraft(release));
},
() => {
if (!cancelled) setError("릴리즈를 불러오지 못했습니다.");
},
);
return () => {
cancelled = true;
};
}, [managementGateway, selectedId, generation]);
const update = (patch: Partial<Draft>) =>
setDraft((current) => (current === null ? current : { ...current, ...patch }));
const submitNew = async (event: FormEvent) => {
event.preventDefault();
if (pending) return;
@@ -147,75 +126,13 @@ export function ReleaseManager() {
try {
const created = await managementGateway.createRelease(title);
setNewTitle("");
setRequestAnnouncement(`릴리즈 ${title} 초안을 만들었습니다.`);
setSelectedId(created.id);
reload();
} catch {
setError("릴리즈를 만들지 못했습니다.");
} finally {
setPending(false);
}
};
const save = async () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
const body: ReleaseUpdateRequest = {
expectedVersion: draft.expectedVersion,
versionLabel: draft.versionLabel.trim(),
title: draft.title.trim(),
summary: draft.summary,
changeTypes: [...draft.changeTypes],
changesMarkdown: draft.changesMarkdown,
verificationMarkdown: draft.verificationMarkdown,
...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}),
reasonMarkdown: draft.reasonMarkdown,
userImpactMarkdown: draft.userImpactMarkdown,
implementationImpactMarkdown: draft.implementationImpactMarkdown,
knownLimitationsMarkdown: draft.knownLimitationsMarkdown,
} as ReleaseUpdateRequest;
const saved = await managementGateway.updateRelease(selectedId, body);
setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload();
} catch {
setError("저장하지 못했습니다. 같은 버전이 이미 있거나 다른 곳에서 먼저 수정되었을 수 있습니다.");
} finally {
setPending(false);
}
};
const publish = async () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
reload();
} catch {
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
setError(
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
setRequestAnnouncement(
`릴리즈 ${title} 초안을 만들었습니다. 목록에서 편집을 눌러 내용을 채웁니다.`,
);
} finally {
setPending(false);
}
};
const archive = async () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
await managementGateway.archiveRelease(selectedId, draft.expectedVersion);
setRequestAnnouncement("릴리즈를 공개에서 내렸습니다.");
setCreatedId(created.id);
reload();
} catch {
setError("공개에서 내리지 못했습니다.");
} catch (error) {
setError(managementFailureMessage(error, "릴리즈를 만들지 못했습니다."));
} finally {
setPending(false);
}
@@ -227,24 +144,15 @@ export function ReleaseManager() {
setError("");
try {
await managementGateway.deleteRelease(release.id, release.version);
if (selectedId === release.id) setSelectedId(null);
setRequestAnnouncement(`릴리즈 ${release.versionLabel} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("삭제하지 못했습니다. 공개된 릴리즈는 삭제 대신 공개에서 내려야 합니다.");
} catch (error) {
setError(managementFailureMessage(error, "삭제하지 못했습니다."));
} finally {
setPending(false);
}
};
const toggleChangeType = (value: string) => {
if (draft === null) return;
const next = draft.changeTypes.includes(value)
? draft.changeTypes.filter((entry) => entry !== value)
: [...draft.changeTypes, value];
update({ changeTypes: next });
};
return (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
@@ -291,137 +199,53 @@ export function ReleaseManager() {
<article
key={release.id}
className="studio-document-row"
aria-current={selectedId === release.id ? "true" : undefined}
aria-current={createdId === release.id ? "true" : undefined}
>
{/*
첫 칸의 유형 라벨은 목록 격자의 첫 열이다. 빠뜨리면 제목이 그 자리로 당겨져
작업본·주제 목록과 줄이 맞지 않는다.
*/}
<p className="studio-row-label">RELEASE</p>
<div className="studio-document-title">
<h2>{release.title}</h2>
<p>
{release.versionLabel}
{release.releasedOn ? ` · ${release.releasedOn}` : ""}
</p>
<p>{release.publication.canonicalPath ?? "아직 공개하지 않음"}</p>
</div>
<dl>
<div>
<dt></dt>
<dd>{release.versionLabel}</dd>
</div>
<div>
<dt></dt>
<dd>{STATUS_LABELS[release.workflowStatus] ?? release.workflowStatus}</dd>
</div>
<div>
<dt></dt>
<dd>{release.releasedOn || "미정"}</dd>
</div>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => setSelectedId(selectedId === release.id ? null : release.id)}
>
{selectedId === release.id ? "닫기" : "편집"}
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void remove(release)}
>
</button>
<div className="studio-row-actions">
<GuardedStudioLink
className="studio-secondary-button"
href={`/studio/releases/${release.id}`}
>
</GuardedStudioLink>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void remove(release)}
>
</button>
</div>
</article>
))}
</div>
</section>
)}
{draft === null ? null : (
<section aria-label="릴리즈 편집">
<h2 className="studio-section-title">{draft.title || "릴리즈"} </h2>
<div className="studio-field-grid">
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.versionLabel}
placeholder="0.1.0"
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<input
type="text"
value={draft.title}
onChange={(event) => update({ title: event.currentTarget.value })}
/>
</label>
<label className="studio-field">
<span></span>
<input
type="date"
value={draft.releasedOn}
onChange={(event) => update({ releasedOn: event.currentTarget.value })}
/>
</label>
<label className="studio-field studio-field--wide">
<span> </span>
<textarea
value={draft.summary}
onChange={(event) => update({ summary: event.currentTarget.value })}
/>
</label>
</div>
<fieldset className="studio-field-grid">
<legend className="studio-field">
<span> </span>
</legend>
{CHANGE_TYPES.map((changeType) => (
<label key={changeType.value} className="studio-field studio-field--checkbox">
<input
type="checkbox"
checked={draft.changeTypes.includes(changeType.value)}
onChange={() => toggleChangeType(changeType.value)}
/>
<span>{changeType.label}</span>
</label>
))}
</fieldset>
<div className="studio-field-grid">
{SECTIONS.map((section) => (
<label key={section.key} className="studio-field studio-field--wide">
<span>{section.label}</span>
<textarea
value={draft[section.key]}
onChange={(event) => update({ [section.key]: event.currentTarget.value })}
/>
</label>
))}
</div>
<div className="studio-create-footer">
<button
className="studio-primary-button"
type="button"
disabled={pending}
onClick={() => void save()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void publish()}
>
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void archive()}
>
</button>
</div>
</section>
)}
</div>
);
}
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
import type { components } from "../../../contracts/studio/generated.ts";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { HomeFocusEditor } from "./home-focus-editor.tsx";
type DocumentSummary = components["schemas"]["DocumentSummary"];
@@ -158,6 +159,7 @@ export function StudioDashboard() {
href="/studio/documents"
empty="게시 준비가 끝난 문서가 없습니다."
/>
<HomeFocusEditor />
<section className="studio-work-section">
<div className="studio-section-title">
<h2> </h2>
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { ProjectIndexItem, TopicEdit } from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { slugFromName } from "./slug-from-name.ts";
import { useStudio } from "../use-studio.ts";
@@ -14,6 +16,23 @@ import { useStudio } from "../use-studio.ts";
* <p>새 CSS 를 만들지 않는다. 작업본 목록이 쓰는 클래스만 재사용하므로 이 화면은 Studio 의
* 나머지와 같은 간격·타이포·색을 그대로 따른다.
*/
/** 공개 프로젝트 화면과 같은 말을 쓴다 — 두 화면이 다른 낱말을 쓰면 같은 것인지 알기 어렵다. */
const phaseLabel: Record<string, string> = {
RESEARCH: "조사",
DESIGN: "설계",
IMPLEMENTATION: "구현",
VERIFICATION: "검증",
MAINTENANCE: "유지",
PAUSED: "중단",
COMPLETED: "완료",
};
const visibilityLabel: Record<string, string> = {
PRIVATE: "비공개",
UNLISTED: "주소로만",
PUBLIC: "공개",
};
export function TaxonomyManager() {
const { managementGateway, setRequestAnnouncement } = useStudio();
const [topics, setTopics] = useState<TopicEdit[] | null>(null);
@@ -75,8 +94,8 @@ export function TaxonomyManager() {
setTopicSlug("");
setRequestAnnouncement(`주제 ${name} 을(를) 만들었습니다.`);
reload();
} catch {
setError("주제를 만들지 못했습니다. 같은 이름이나 slug 가 이미 있을 수 있습니다.");
} catch (error) {
setError(managementFailureMessage(error, "주제를 만들지 못했습니다."));
} finally {
setPending(false);
}
@@ -93,12 +112,36 @@ export function TaxonomyManager() {
setPending(true);
setError("");
try {
await managementGateway.createProject(title);
const created = await managementGateway.createProject(title);
/*
생성 계약은 이름만 받는데(`CreateDraftRequest`), slug 가 없는 프로젝트는 공개 화면에
나타날 수 없다 — 공개 계약의 `ProjectSummary` 는 `slug` 와 `path` 를 요구하므로 서버는
slug 가 빈 프로젝트를 통째로 생략한다. 기록에 프로젝트를 붙여 게시해도 공개 문서의
프로젝트 칸이 비어 있던 이유가 이것이다.
그래서 만든 직후에 이름에서 만든 slug 를 채운다. 주제가 `{name, slug}` 를 함께 보내는
것과 같은 규칙이고(`slugFromName`), 한글 이름도 로마자로 옮겨 유효한 slug 가 된다.
*/
const project = await managementGateway.getProject(created.id);
await managementGateway.updateProject(created.id, {
expectedVersion: project.version,
name: project.name,
slug: slugFromName(project.name),
oneLinePurpose: project.oneLinePurpose ?? "",
purposeMarkdown: project.purposeMarkdown ?? "",
boundaryMarkdown: project.boundaryMarkdown ?? "",
phase: project.phase,
technologyLabels: project.technologyLabels ?? [],
targetVisibility:
project.targetVisibility === "PUBLIC" || project.targetVisibility === "UNLISTED"
? project.targetVisibility
: "PRIVATE",
});
setProjectTitle("");
setRequestAnnouncement(`프로젝트 ${title} 을(를) 만들었습니다.`);
reload();
} catch {
setError("프로젝트를 만들지 못했습니다.");
} catch (error) {
setError(managementFailureMessage(error, "프로젝트를 만들지 못했습니다."));
} finally {
setPending(false);
}
@@ -112,8 +155,8 @@ export function TaxonomyManager() {
await managementGateway.deleteTopic(topic.id, topic.version);
setRequestAnnouncement(`주제 ${topic.name} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("주제를 삭제하지 못했습니다. 이 주제를 쓰는 기록이 있을 수 있습니다.");
} catch (error) {
setError(managementFailureMessage(error, "주제를 삭제하지 못했습니다."));
} finally {
setPending(false);
}
@@ -127,8 +170,39 @@ export function TaxonomyManager() {
await managementGateway.deleteProject(project.id, project.version);
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 삭제했습니다.`);
reload();
} catch {
setError("프로젝트를 삭제하지 못했습니다. 연결된 기록이 있을 수 있습니다.");
} catch (error) {
setError(managementFailureMessage(error, "프로젝트를 삭제하지 못했습니다."));
} finally {
setPending(false);
}
};
/*
프로젝트 게시는 문서 게시와 별개다. 문서를 게시해도 그 문서가 속한 프로젝트는 비공개로 남고,
공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈의 focus)은 전부 게시된 프로젝트만 읽는다.
그래서 그 화면들이 조용히 비어 있었다 — 게시할 방법 자체가 없었기 때문이다.
*/
const togglePublish = async (project: ProjectIndexItem) => {
if (pending) return;
setPending(true);
setError("");
const published = project.targetVisibility !== "PRIVATE";
try {
if (published) {
await managementGateway.unpublishProject(project.id, project.version);
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 비공개로 되돌렸습니다.`);
} else {
await managementGateway.publishProject(project.id, project.version, "PUBLIC");
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 공개했습니다.`);
}
reload();
} catch (error) {
setError(
managementFailureMessage(
error,
published ? "프로젝트를 비공개로 되돌리지 못했습니다." : "프로젝트를 게시하지 못했습니다.",
),
);
} finally {
setPending(false);
}
@@ -210,7 +284,12 @@ export function TaxonomyManager() {
<dl>
<div>
<dt></dt>
<dd>{topic.status === "ARCHIVED" ? "보관" : "사용 중"}</dd>
{/*
`ACTIVE` 를 "사용 중" 이라 적었더니, 방금 만들어 아무도 쓰지 않는 주제까지
"사용 중" 으로 보였다. 그 상태에서 삭제가 거절되면 작성자는 둘을 같은
말로 읽는다 — 실제로는 서로 다른 이야기다.
*/}
<dd>{topic.status === "ARCHIVED" ? "보관" : "활성"}</dd>
</div>
</dl>
<button
@@ -239,27 +318,54 @@ export function TaxonomyManager() {
<article className="studio-document-row" key={project.id}>
<p className="studio-row-label">PROJECT</p>
<div className="studio-document-title">
<h2>{project.name}</h2>
<h2>
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
{project.name}
</GuardedStudioLink>
</h2>
<p>{project.currentObjective ?? "목표 미지정"}</p>
</div>
<dl>
<div>
<dt></dt>
<dd>{project.phase}</dd>
<dd>{phaseLabel[project.phase] ?? project.phase}</dd>
</div>
<div>
<dt></dt>
<dd>{project.targetVisibility}</dd>
{/*
"게시됨" 이 아니라 어디까지 보이는지를 적는다 — UNLISTED 는 주소를 아는
사람만 닿고 목록에는 없다. 둘을 같은 말로 적으면 왜 목록에 안 나오는지
설명할 방법이 없다.
*/}
<dd>{visibilityLabel[project.targetVisibility] ?? project.targetVisibility}</dd>
</div>
<div>
<dt></dt>
<dd>
<GuardedStudioLink href={`/studio/projects/${project.id}`}>
</GuardedStudioLink>
</dd>
</div>
</dl>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeProject(project)}
>
</button>
<div className="studio-row-actions">
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void togglePublish(project)}
>
{project.targetVisibility === "PRIVATE" ? "게시" : "게시 취소"}
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeProject(project)}
>
</button>
</div>
</article>
))}
</div>
@@ -0,0 +1,29 @@
import { useEffect } from "react";
/**
* Ctrl+S(맥에서는 Cmd+S)로 저장한다.
*
* <p>브라우저의 기본 동작은 "페이지를 파일로 저장"이다. 글을 쓰다가 그 손버릇이 나오면 저장 대화상자가
* 뜨고 편집한 내용은 그대로 남는다 — 저장한 줄 알고 창을 닫으면 잃는다. 그래서 기본 동작을 막고 이
* 화면의 저장으로 돌린다.
*
* <p>`enabled` 가 거짓이면 아무것도 하지 않는다. 저장할 것이 없거나 이미 저장 중일 때 단축키가 요청을
* 겹쳐 보내지 않게 하는 것은 호출자의 몫이다 — 화면마다 "지금 저장할 수 있는가"의 뜻이 다르다.
*/
export function useSaveShortcut(
save: () => void | Promise<void>,
enabled = true,
) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "s" && event.key !== "S") return;
if (!(event.ctrlKey || event.metaKey)) return;
// Ctrl+Shift+S 는 다른 뜻으로 쓰는 곳이 있어 가로채지 않는다.
if (event.shiftKey || event.altKey) return;
event.preventDefault();
if (enabled) void save();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [enabled, save]);
}
@@ -55,11 +55,23 @@ export function ValidationReport({
<h2 id="studio-validation-report-title"> </h2>
</div>
<span
className={`studio-workflow-status studio-workflow-status--${report.status.toLowerCase()}`}
className={`studio-workflow-status studio-workflow-status--${current ? report.status.toLowerCase() : "stale"}`}
>
{report.status}
{current ? report.status : "지난 결과"}
</span>
</div>
{/*
낡은 판정을 현재 것과 같은 모양으로 보여주면 읽는 사람은 지금 상태로 읽는다. 실제로
버전 2 때의 오류 목록을 보고 버전 5 가 그렇다고 이해한 일이 있었다 — "이전 결과" 라는
작은 글씨 하나로는 그 오해를 막지 못한다. 무엇에 대한 판정인지 먼저 말한다.
*/}
{current ? null : (
<p className="studio-workflow-stale-notice" role="status">
<strong> {report.validatedVersion}</strong> .
<strong> </strong>
.
</p>
)}
<dl className="studio-workflow-metadata">
<div><dt> </dt><dd>{report.validatedVersion}</dd></div>
<div><dt></dt><dd>{current ? "현재" : "이전 결과"}</dd></div>
@@ -67,7 +79,10 @@ export function ValidationReport({
<div><dt> </dt><dd>{formatDateTime(report.validUntil)}</dd></div>
</dl>
{issues.length ? (
<ol className="studio-workflow-issue-list" aria-label="검증 항목">
<ol
className={`studio-workflow-issue-list${current ? "" : " studio-workflow-issue-list--stale"}`}
aria-label={current ? "검증 항목" : "지난 검증 항목"}
>
{issues.map((issue, index) => (
<li key={`${issue.severity}-${issue.code}-${issue.path}-${index}`}>
<GuardedStudioLink
@@ -0,0 +1,5 @@
import { ProjectEditor } from "../components/project-editor.tsx";
export function ProjectEditPage() {
return <ProjectEditor />;
}
@@ -0,0 +1,5 @@
import { ReleaseEditor } from "../components/release-editor.tsx";
export function ReleaseEditPage() {
return <ReleaseEditor />;
}
@@ -17,7 +17,12 @@
--warning-soft: #fff7ed;
--code-canvas: #15181d;
--shell: 1180px;
--body-copy: 42rem;
/*
읽는 단의 폭. 본문·코드블록·표·콜아웃·그림이 모두 이 값을 쓰므로 한 곳만 바꾸면 단 전체가 함께
움직인다. 42rem(672px)은 영문 기준 측정값이었고, 한글 본문에서는 좁아 보이는 데다 바로 위의
유형/프로젝트/게시 줄이 shell 전체(1180px)를 쓰고 있어 대비가 더 심했다.
*/
--body-copy: 56rem;
}
* {
@@ -95,6 +100,102 @@ a {
same reserved height: with only `main` covered the footer still sat at the
viewport bottom during loading and then dropped out of view when the content
arrived, which is the whole 0.192 the page scored. */
/*
로그인 관문.
이 화면은 공개 셸과 Studio 셸 양쪽에서 나온다 — 보호된 주소를 열면 그 주소가 속한 레이아웃 안에
이 자리가 대신 그려진다. 그래서 두 곳 모두에 있는 토큰만 쓴다.
카드로 세우는 이유는 이것이 "화면을 못 그렸다"가 아니라 "여기서 한 걸음 더 필요하다"이기
때문이다. 오류 화면과 같은 모양이면 작성자는 무언가 고장 난 줄로 읽는다.
*/
.auth-gate {
display: grid;
place-items: center;
padding-block: clamp(64px, 12vh, 140px);
}
.auth-gate .auth-gate__card {
width: min(100%, 34rem);
padding: clamp(32px, 5vw, 52px);
border: 1px solid var(--line-strong);
border-radius: 10px;
background: var(--paper);
}
.auth-gate .page-header {
margin: 0;
}
.auth-gate .page-header__eyebrow {
margin: 0 0 14px;
color: var(--signal);
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
}
.auth-gate .page-header h1 {
margin: 0;
font-size: clamp(27px, 3.2vw, 33px);
font-weight: 660;
letter-spacing: -0.035em;
line-height: 1.25;
}
.auth-gate .page-header__description {
margin: 16px 0 0;
color: var(--ink-soft);
font-size: 16px;
line-height: 1.72;
}
.auth-gate .auth-gate__actions {
display: grid;
gap: 14px;
margin-top: 32px;
padding-top: 28px;
border-top: 1px solid var(--line);
}
/* 이 버튼은 화면에서 유일한 행동이다. 폭을 채워 어디를 눌러야 하는지 묻지 않게 한다. */
.auth-gate .auth-gate__actions button {
display: inline-flex;
min-height: 48px;
align-items: center;
justify-content: center;
width: 100%;
padding-inline: 20px;
border: 1px solid var(--signal);
border-radius: 6px;
background: var(--signal);
color: #fff;
font: inherit;
font-size: 15px;
font-weight: 650;
cursor: pointer;
}
.auth-gate .auth-gate__actions button:disabled {
opacity: 0.55;
cursor: wait;
}
.auth-gate .auth-gate__return {
margin: 0;
color: var(--faint);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.auth-gate .ui-terminal-error {
margin: 20px 0 0;
color: var(--warning);
font-size: 14px;
}
.site-frame > main,
.site-frame > .ui-page {
/* Takes the slack so the footer stays put whether the route rendered a long
@@ -1329,11 +1430,18 @@ dialog::backdrop {
text-align: center;
}
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는 좌상단에
붙는다 — .search-dialog 와 Studio 의 .studio-unsaved-dialog 가 같은 이유로 position/inset/
margin 을 명시한다. 여기만 빠져 있어 "크게 보기" 가 왼쪽 위에 열렸다. */
.figure-dialog {
position: fixed;
inset: 0;
margin: auto;
width: min(1180px, calc(100% - 48px));
max-width: none;
max-height: calc(100dvh - 48px);
padding: 0;
overflow: hidden;
overflow: auto;
border: 1px solid var(--line-strong);
border-radius: 9px;
background: var(--paper);
@@ -2174,6 +2282,20 @@ dialog::backdrop {
max-width: 920px;
}
/*
문서 한 편은 하나의 단으로 읽힌다.
예전에는 세 조각이 저마다 다른 폭이었다 — 머리말 920px, 사실 줄은 shell 전체 1180px, 본문은
672px 를 가운데 정렬. 그래서 눈이 왼쪽 끝을 세 번 다시 찾아야 했고, 가장 좁은 본문이 가장 넓은
사실 줄 안에 갇힌 것처럼 보였다. 셋을 같은 폭·같은 왼쪽 끝에 세운다.
*/
.public-document-header,
.document-snapshot,
.document-facts {
width: min(100%, var(--body-copy));
max-width: var(--body-copy);
}
.public-page-header h1,
.public-document-header h1 {
margin: 15px 0 18px;
@@ -2297,7 +2419,7 @@ dialog::backdrop {
.reference-purpose,
.document-relations {
width: min(100%, var(--body-copy));
margin: 82px auto 0;
margin: 82px 0 0;
}
.public-document-body > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); }
.public-document-body h2,
@@ -2613,3 +2735,29 @@ textarea.studio-control { min-height: 118px; resize: vertical; line-height: 1.65
.studio-diff-grid section, .studio-diff-grid section + section { min-height: 0; padding: 23px 0; border-left: 0; }
.studio-diff-grid section + section { border-top: 1px solid var(--line); }
}
/* 본문 구분선과 그림. 문서 본문의 다른 블록과 같은 세로 리듬을 따른다. */
.document-body > section > .document-rule {
margin: 34px 0;
border: 0;
border-top: 1px solid var(--line);
}
.document-body > section > .document-image {
margin: 26px 0;
}
.document-body > section > .document-image img {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--line);
border-radius: 6px;
}
.document-body > section > .document-image figcaption {
margin-top: 10px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
@@ -1,15 +1,37 @@
.studio-app .studio-editor-page,
.studio-app .studio-editor-layout,
.studio-app .studio-editor-split,
.studio-app .studio-editor-workspace,
.studio-app .studio-editor-workspace > [role="tabpanel"] { min-width: 0; }
.studio-app .studio-editor-preview { min-width: 0; }
.studio-app .studio-editor-loading { min-height: 240px; padding-block: 56px; color: var(--muted); }
.studio-app .studio-editor-tabs { display: flex; gap: 28px; border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-editor-tabs button { position: relative; min-width: 112px; min-height: 44px; padding: 0; border: 0; background: transparent; color: var(--muted); font: inherit; font-weight: 650; }
.studio-app .studio-editor-tabs button[aria-selected="true"] { color: var(--ink); }
.studio-app .studio-editor-tabs button[aria-selected="true"]::after { position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--signal); content: ""; }
/*
편집과 미리보기를 화면에 나란히 둔다. 탭이었을 때는 번에 하나만 보였고, 고친 결과를
보려면 편집하던 자리를 화면에서 치워야 했다.
*/
/*
화면만 공용 컨테이너보다 넓다. `.studio-main` 모든 Studio 화면이 1180px 함께
쓰는데, 여기서는 폭에 본문 벌이 들어가야 해서 칸이 566px 좁아졌다. 헤더와
다른 화면은 그대로 두고 편집기가 놓인 경우에만 넓힌다.
*/
.studio-app:has(.studio-editor-split) .studio-header-inner,
.studio-app:has(.studio-editor-split) .studio-main { width: min(1600px, calc(100% - 80px)); }
.studio-app .studio-editor-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 280px); gap: 64px; align-items: start; padding-top: 40px; }
/*
편집과 미리보기를 화면에 나란히 둔다. 탭이었을 때는 번에 하나만 보였고, 고친 결과를
보려면 편집하던 자리를 화면에서 치워야 했다.
모두 페이지 스크롤을 그대로 탄다. 미리보기를 붙박이로 두고 자체 스크롤을 주었더니
편집기를 내려도 미리보기는 제자리였고, 보려면 안을 따로 굴려야 했다 나란히 이유가
둘을 같이 보는 것인데 움직임이 갈라지면 이점이 없다.
*/
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; padding-top: 40px; }
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
.studio-app .studio-editor-preview { display: flex; flex-direction: column; padding-left: 40px; border-left: 1px solid var(--line); }
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
.studio-app .studio-editor-preview__body { padding-top: 20px; }
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
@@ -17,7 +39,7 @@
.studio-app .studio-editor-section-heading { margin-bottom: 24px; }
.studio-app .studio-editor-section-heading .studio-eyebrow { margin-bottom: 8px; }
.studio-app .studio-editor-section-heading h2,
.studio-app .studio-document-status-rail h2,
.studio-app .studio-editor-preview__heading h2,
.studio-app .studio-preview-error h2 { margin: 0; font-size: 25px; letter-spacing: -0.03em; }
.studio-app .studio-field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
@@ -36,6 +58,31 @@
.studio-app .studio-ordered-item textarea { min-height: 112px; resize: vertical; }
.studio-app .studio-field .studio-markdown-field { min-height: 420px; font-family: "IBM Plex Mono", monospace; font-size: 13px; }
/* 칸 아래 한 줄 설명. 라벨과 같은 격자 안에 들어가므로 별도 여백을 두지 않는다. */
.studio-app .studio-field > small { color: var(--faint); font-size: 12px; font-weight: 450; line-height: 1.6; }
/* 여러 개를 고르는 목록(주제 등). 작업본 목록의 유형 선택과 같은 모양을 쓴다. */
.studio-app .studio-choice-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0 24px; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--line); }
.studio-app .studio-choice { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 4px 12px; align-items: center; min-height: 60px; padding: 12px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
.studio-app .studio-choice input { width: 20px; min-width: 20px; min-height: 20px; padding: 0; }
.studio-app .studio-choice span { font-size: 15px; font-weight: 550; }
.studio-app .studio-choice small { grid-column: 2; color: var(--faint); font-family: "IBM Plex Mono", monospace; font-size: 11px; }
/* 편집 화면 하단의 저장 줄. 버튼과 그 옆의 상태 한 줄. */
.studio-app .studio-editor-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 32px 0 0; }
.studio-app .studio-editor-footer .studio-primary-button,
.studio-app .studio-editor-footer .studio-secondary-button { margin: 0; }
.studio-app .studio-editor-footer .studio-field-note { flex: 1 1 260px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
/* 체크박스 묶음(변경 유형 등). `.studio-field-grid` 를 쓰면 legend 가 한 칸을 차지해 줄이 어긋난다. */
.studio-app .studio-checkbox-set { margin: 34px 0 0; padding: 0; border: 0; }
.studio-app .studio-checkbox-set legend { padding: 0 0 14px; color: var(--muted); font-size: 12px; font-weight: 650; }
.studio-app .studio-checkbox-set { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0 24px; }
.studio-app .studio-checkbox-set legend { grid-column: 1 / -1; }
.studio-app .studio-checkbox-set .studio-field--checkbox { display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: 12px; align-items: center; min-height: 52px; padding: 8px 0; border-bottom: 1px solid var(--line); cursor: pointer; }
.studio-app .studio-checkbox-set .studio-field--checkbox input { width: 20px; min-width: 20px; min-height: 20px; padding: 0; }
.studio-app .studio-checkbox-set .studio-field--checkbox span { font-size: 15px; font-weight: 500; }
.studio-app .studio-ordered-list,
.studio-app .studio-resolution-fields { min-width: 0; margin: 34px 0 0; padding: 0; border: 0; }
.studio-app .studio-ordered-list legend,
@@ -46,27 +93,38 @@
.studio-app .studio-item-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.studio-app .studio-item-actions button,
.studio-app .studio-add-item,
.studio-app .studio-document-status-rail button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
.studio-app .studio-document-status-bar button { min-height: 44px; padding-inline: 13px; border: 1px solid var(--line-strong); border-radius: 5px; background: var(--paper); color: var(--ink); font-weight: 650; }
.studio-app .studio-item-actions button:disabled,
.studio-app .studio-add-item:disabled { opacity: 0.45; }
.studio-app .studio-add-item { margin-top: 12px; }
.studio-app .studio-resolution-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 22px; }
.studio-app .studio-resolution-fields legend { grid-column: 1 / -1; }
.studio-app .studio-document-status-rail { position: sticky; top: 28px; min-width: 0; padding: 24px 0; border-top: 1px solid var(--line-strong); border-bottom: 1px solid var(--line-strong); }
.studio-app .studio-document-status-rail .studio-eyebrow { margin-bottom: 8px; }
.studio-app .studio-editor-status { margin: 20px 0; font-weight: 700; }
/*
작업 상태와 저장·게시를 화면 아래에 붙여 둔다. 문서가 길어져도 스크롤 위치와 무관하게 손이
닿는다. 배경을 불투명하게 두는 것은 장식이 아니라 필요다 밑으로 지나가는 본문이 비치면
상태 글자를 읽을 없다.
*/
.studio-app .studio-document-status-bar { position: sticky; bottom: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 28px; min-width: 0; margin-top: 48px; padding: 16px 0; border-top: 1px solid var(--line-strong); background: var(--paper); }
.studio-app .studio-editor-status { margin: 0; font-weight: 700; }
.studio-app .studio-editor-status--dirty,
.studio-app .studio-editor-status--conflict,
.studio-app .studio-editor-conflict { color: #8f2f27; }
.studio-app .studio-editor-status--clean { color: #166748; }
.studio-app .studio-document-status-rail dl { display: grid; gap: 12px; margin: 0 0 20px; }
.studio-app .studio-document-status-rail dl div { display: flex; justify-content: space-between; gap: 16px; }
.studio-app .studio-document-status-rail dt { color: var(--muted); font-size: 12px; }
.studio-app .studio-document-status-rail dd { margin: 0; font-size: 13px; }
.studio-app .studio-document-status-rail button { width: 100%; border-color: var(--signal); background: var(--signal); color: #fff; }
.studio-app .studio-document-status-rail button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
.studio-app .studio-document-status-rail > p:last-child { margin: 16px 0 0; color: var(--muted); font-size: 12px; line-height: 1.65; }
.studio-app .studio-document-status-bar dl { display: flex; gap: 20px; margin: 0; }
.studio-app .studio-document-status-bar dl div { display: flex; align-items: baseline; gap: 8px; }
.studio-app .studio-document-status-bar dt { color: var(--muted); font-size: 12px; }
.studio-app .studio-document-status-bar dd { margin: 0; font-size: 13px; }
.studio-app .studio-document-status-bar__note { flex: 1 1 240px; margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
/* 색은 위 묶음이 정한다 — 여기서 다시 칠하면 실패가 안내문처럼 보인다. */
.studio-app .studio-document-status-bar .studio-editor-conflict { flex: 1 1 240px; margin: 0; font-size: 12px; line-height: 1.6; }
.studio-app .studio-document-status-bar__actions { display: flex; align-items: center; gap: 12px; margin-left: auto; }
/* `.studio-primary-button` 세로로 쌓이는 자리에 맞춘 바깥 여백을 갖고 있다. 줄에
나란히 두면 여백이 높이를 늘리고, 버튼만 늘어나 짝이 어긋난다. */
.studio-app .studio-document-status-bar__actions .studio-primary-button { margin: 0; }
.studio-app .studio-document-status-bar button { min-width: 104px; border-color: var(--signal); background: var(--signal); color: #fff; }
.studio-app .studio-document-status-bar button:disabled { border-color: var(--line-strong); background: var(--paper); color: var(--muted); }
.studio-app .studio-document-status-bar .studio-editor-unplaced-issues { flex: 1 1 100%; }
.studio-app .studio-asset-panel { margin-top: 28px; padding-top: 28px; border-top: 1px solid var(--line); }
.studio-app .studio-asset-panel .studio-eyebrow { margin-bottom: 8px; }
@@ -82,7 +140,7 @@
matches this form's submit button, and both selectors carry the same
specificity, so only source order tells them apart. */
/* `minmax(0, 1fr)` and the row's `min-width: 0` are both load bearing, for the
reason `.studio-editor-layout` already needs the same pair: a grid track and
reason `.studio-editor-split` already needs the same pair: a grid track and
a flex container both default to an automatic minimum of their min-content
size, so at 360px the search row sized itself to 366px inside a 328px column
and pushed the submit button off-screen (document scrollWidth 382). */
@@ -103,15 +161,19 @@
.studio-app .studio-preview-error ul { margin: 20px 0 0; padding-left: 20px; color: #8f2f27; line-height: 1.7; overflow-wrap: anywhere; }
@media (max-width: 1024px) {
.studio-app .studio-editor-layout { grid-template-columns: minmax(0, 1fr); gap: 40px; }
.studio-app .studio-document-status-rail { position: static; }
.studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
.studio-app .studio-editor-preview { padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
}
@media (max-width: 767px) {
.studio-app .studio-editor-heading { padding-bottom: 28px; }
.studio-app .studio-editor-tabs { gap: 18px; }
.studio-app .studio-editor-tabs button { flex: 1; min-width: 0; }
.studio-app .studio-editor-layout { padding-top: 28px; }
.studio-app .studio-editor-split { padding-top: 28px; }
/* 안내문은 줄로 접혀 뷰포트의 20% 고정으로 가져간다. 실패 문구는 남기고 안내만 접는다
Ctrl+S 여기서 누를 있는 것도 아니다. */
.studio-app .studio-document-status-bar__note { display: none; }
.studio-app .studio-document-status-bar__actions { flex: 1 1 100%; margin-left: 0; }
.studio-app .studio-document-status-bar__actions button { flex: 1; min-width: 0; }
.studio-app .studio-field-grid,
.studio-app .studio-resolution-fields { grid-template-columns: minmax(0, 1fr); }
.studio-app .studio-field--wide,
@@ -119,3 +181,43 @@
.studio-app .studio-item-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
.studio-app .studio-instant-preview .public-record-embedded { padding: 22px 16px; }
}
/* 게시까지의 단계. 순서가 있는 길이라 번호를 붙인다 — 장식이 아니라 밟아야 하는 차례다. */
.studio-app .studio-editor-flow ol { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 14px 0 0; padding: 0; list-style: none; }
.studio-app .studio-editor-flow li { display: flex; align-items: center; gap: 6px; font-size: 13px; }
.studio-app .studio-editor-flow li + li::before { content: "→"; margin-right: 4px; color: var(--faint); }
.studio-app .studio-editor-flow span { display: inline-flex; min-width: 18px; height: 18px; align-items: center; justify-content: center; border-radius: 999px; background: var(--line); color: var(--muted); font-size: 11px; font-weight: 650; }
/* 게시를 막는 항목. 고치는 자리에서 보이지 않으면 작성자는 별도 화면과 편집기를 오가며
어느 칸이었는지 기억해야 한다. */
.studio-app .studio-editor-validation { margin: 0 0 20px; padding: 14px 16px; border: 1px solid var(--danger, #b4232a); border-radius: 6px; background: var(--paper); }
.studio-app .studio-editor-validation--stale { border-color: var(--line-strong); opacity: 0.72; }
.studio-app .studio-editor-validation-title { margin: 0 0 10px; color: var(--danger, #b4232a); font-size: 13px; font-weight: 700; }
.studio-app .studio-editor-validation--stale .studio-editor-validation-title { color: var(--muted); }
.studio-app .studio-editor-validation ul { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
.studio-app .studio-editor-validation li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; font-size: 13px; color: var(--ink); }
.studio-app .studio-editor-validation li span { min-width: 30px; color: var(--danger, #b4232a); font-size: 11px; font-weight: 700; }
.studio-app .studio-editor-validation li[data-severity="WARNING"] span { color: var(--muted); }
.studio-app .studio-editor-validation code { color: var(--faint); font-size: 11px; }
/*
아래에 붙는 지적. 검증 결과를 화면 덩어리로 모아 두는 대신 옆에 두면,
작성자는 `/topicId` 같은 경로를 읽고 어느 칸인지 스스로 찾을 필요가 없다.
`.studio-field` 세로 흐름이므로 별도의 배치가 필요 없다 입력 바로 다음 줄에 놓인다.
*/
.studio-app .studio-field-notice { display: block; margin-top: 6px; font-size: 12px; line-height: 1.5; }
.studio-app .studio-field-notice--error { color: var(--danger, #b4232a); }
.studio-app .studio-field-notice--warning { color: var(--muted); }
.studio-app .studio-editor-unplaced-issues { display: grid; gap: 6px; margin: 10px 0 0; padding: 0; list-style: none; }
.studio-app .studio-editor-unplaced-issues li { font-size: 12px; color: var(--danger, #b4232a); }
.studio-app .studio-editor-unplaced-issues li[data-severity="WARNING"] { color: var(--muted); }
.studio-app .studio-editor-unplaced-issues code { color: var(--faint); font-size: 11px; }
/*
저장과 게시는 되돌릴 있는 정도가 다르다 하나는 초안을 남기고, 하나는 공개한다. 둘이
맞붙어 있으면 누르려던 것을 지나쳐 누르기 쉬우므로 사이를 벌린다.
*/
.studio-app .studio-document-status-bar__actions { gap: 12px; }
/* 게시만 강조한다. 저장은 되돌릴 수 있으므로 같은 무게로 부를 이유가 없다. */
.studio-app .studio-document-status-bar button:not(.studio-primary-button) { border-color: var(--line-strong); background: var(--paper); color: var(--ink); }
@@ -107,6 +107,15 @@
.studio-app .studio-document-row dt { color: var(--muted); font-size: 11px; }
.studio-app .studio-document-row dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.studio-app .studio-secondary-button { margin-top: 24px; }
/*
행에 버튼이 이상일 . `.studio-secondary-button` 위쪽 여백은 버튼이 하나뿐이던 때의
값이라, 묶음 안에서는 묶음이 여백을 갖고 버튼은 나란히 선다.
*/
.studio-app .studio-row-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
.studio-app .studio-field-note { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.studio-app .studio-row-actions .studio-primary-button,
.studio-app .studio-row-actions .studio-secondary-button { margin: 0; }
.studio-app .studio-empty-state { padding-block: 56px; border-bottom: 1px solid var(--line); }
.studio-app .studio-empty-state h2 { margin: 0; font-size: 25px; }
.studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); }
@@ -118,8 +127,14 @@
.studio-app .studio-type-list strong { font-size: 18px; }
.studio-app .studio-type-list span,
.studio-app .studio-type-list small { color: var(--muted); line-height: 1.6; }
.studio-app .studio-create-footer { display: flex; align-items: center; gap: 20px; margin-top: 28px; }
.studio-app .studio-create-footer .studio-primary-button { margin: 0; }
.studio-app .studio-create-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; margin-top: 28px; }
/*
`.studio-secondary-button` 목록 행에서 쓰려고 전역으로 `margin-top: 24px` 갖는다. 여기서
풀어 주지 않으면 primary(저장) 위로 붙고 secondary(공개·공개에서 내리기) 24px 아래로
내려앉아, 같은 줄의 버튼들이 서로 다른 높이에 선다.
*/
.studio-app .studio-create-footer .studio-primary-button,
.studio-app .studio-create-footer .studio-secondary-button { margin: 0; }
.studio-app .studio-create-footer p { margin: 0; color: var(--muted); font-size: 13px; }
.studio-app .studio-primary-button:disabled { opacity: 0.55; cursor: wait; }
@@ -86,3 +86,8 @@
:global(.studio-workflow-issue-list) a > span:last-child { display: none; }
:global(.studio-preview-public-frame .public-record-embedded) { padding: 22px 16px; }
}
/* 지난 판정은 현재 것과 같은 무게로 보이면 안 된다 — 읽는 사람이 지금 상태로 읽는다. */
:global(.studio-workflow-status--stale) { border-color: var(--line); background: var(--paper); color: var(--muted); }
:global(.studio-workflow-stale-notice) { margin: 0 0 18px; padding: 12px 14px; border: 1px solid var(--line-strong); border-radius: 6px; background: var(--paper); color: var(--muted); font-size: 13px; line-height: 1.6; }
:global(.studio-workflow-issue-list--stale) { opacity: 0.62; }
@@ -216,6 +216,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"TaxonomyPage",
),
),
TECH_LOG_STUDIO_PROJECT_EDIT: runtime(
"TECH_LOG_STUDIO_PROJECT_EDIT",
routeModule(
() => import("./studio/pages/project-edit-page.tsx"),
"ProjectEditPage",
),
),
TECH_LOG_STUDIO_RELEASES: runtime(
"TECH_LOG_STUDIO_RELEASES",
routeModule(
@@ -223,6 +230,13 @@ export const TECH_LOG_ROUTE_RUNTIME = Object.freeze({
"StudioReleasesPage",
),
),
TECH_LOG_STUDIO_RELEASE_EDIT: runtime(
"TECH_LOG_STUDIO_RELEASE_EDIT",
routeModule(
() => import("./studio/pages/release-edit-page.tsx"),
"ReleaseEditPage",
),
),
TECH_LOG_STUDIO_NOT_FOUND: runtime(
"TECH_LOG_STUDIO_NOT_FOUND",
routeModule(
+13 -6
View File
@@ -87,11 +87,16 @@ const PLATFORM_KO_MESSAGES = {
"route.auth.integration.description":
"외부 인증 소유자가 연결되면 이 보호 라우트를 사용할 수 있습니다.",
"route.auth.recovering.title": "세션을 복구하고 있습니다.",
"route.auth.required.title": "세션이 필요합니다.",
"route.auth.eyebrow": "SIGN IN",
"route.auth.returnTo": "로그인하면 {path} 로 돌아옵니다.",
"route.auth.required.title": "Studio에 로그인해 주세요.",
"route.auth.recovering.description":
"기존 세션 확인을 계속하려면 복구를 실행하세요.",
"이전 로그인이 아직 살아 있는지 확인하고 있습니다. 잠시 뒤에도 이 화면이면 다시 시도해 주세요.",
// 예전 문구("인증 연동 지점을 확인하기 위한 보호 라우트")는 이 뼈대를 만들던 사람에게 하는
// 말이었다. 실제로 이 화면을 보는 사람은 글을 쓰러 온 작성자이고, 알아야 할 것은 무엇을
// 누르면 되는지다.
"route.auth.required.description":
"이 화면은 인증 연동 지점을 확인하기 위한 보호 라우트입니다.",
"기록을 쓰고 게시하려면 로그인이 필요합니다. 로그인하면 방금 열려던 화면으로 돌아옵니다.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "새 릴리스 정보를 확인하고 있습니다.",
"chunk.reloadOnce": "새 버전으로 한 번만 전환합니다.",
@@ -271,11 +276,13 @@ const PLATFORM_EN_MESSAGES = {
"route.auth.integration.description":
"This protected route is available after an external authentication owner is connected.",
"route.auth.recovering.title": "Recovering the session.",
"route.auth.required.title": "A session is required.",
"route.auth.eyebrow": "SIGN IN",
"route.auth.returnTo": "You return to {path} after signing in.",
"route.auth.required.title": "Sign in to Studio.",
"route.auth.recovering.description":
"Run recovery to continue checking the existing session.",
"Checking whether the previous sign-in is still valid. Try again if this screen stays.",
"route.auth.required.description":
"This protected route demonstrates the authentication integration seam.",
"Writing and publishing records needs a signed-in session. You return to this screen afterwards.",
"route.documentTitle": "{title} · {appName}",
"chunk.checking": "Checking the new release information.",
"chunk.reloadOnce": "Switching to the new version once.",
@@ -19,6 +19,7 @@ export type MessageParameters = Readonly<{
"action.alertCloseNamed": { title: string };
"route.loadingNamed": { title: string };
"route.documentTitle": { title: string; appName: string };
"route.auth.returnTo": { path: string };
"template.supportReference": { reference: string };
"form.remaining": { count: number };
"boot.supportReference": { reference: string };
+41 -28
View File
@@ -241,35 +241,48 @@ function ProtectedRoute({
);
}
const recovering = decision.action === "wait-for-session";
/*
Signing in is a screen, not a bare gate. This used to be a title and a button
dropped on `ui-page`, so opening a protected address showed no page at all
just two stray elements. It is one card now, and it names the address the
visitor came for so they know where signing in returns them.
*/
return (
<section className="ui-page" data-surface="authentication-required">
<PageHeader
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<Button
disabled={pending}
onClick={() => void continueSession()}
>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
{failed ? (
<p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
<section className="ui-page auth-gate" data-surface="authentication-required">
<div className="auth-gate__card">
<PageHeader
eyebrow={message("route.auth.eyebrow")}
title={
recovering
? message("route.auth.recovering.title")
: message("route.auth.required.title")
}
description={
recovering
? message("route.auth.recovering.description")
: message("route.auth.required.description")
}
/>
<div className="auth-gate__actions">
<Button disabled={pending} onClick={() => void continueSession()}>
{pending
? message("common.processing")
: recovering
? message("action.recoverSession")
: message("action.signIn")}
</Button>
<p className="auth-gate__return">
{message("route.auth.returnTo", {
path: `${location.pathname}${location.search}`,
})}
</p>
</div>
{failed ? (
<p className="ui-terminal-error" role="alert">
{message("shell.session.actionFailed")}
</p>
) : null}
</div>
</section>
);
}
@@ -461,8 +461,7 @@ test("inserts the directive at the saved cursor position and the live preview re
`${body.slice(0, cursor)}\n\n${expectedDirective}${body.slice(cursor)}`,
);
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
expect(within(panel).queryByRole("alert")).not.toBeInTheDocument();
// `zoom: true` (DIAGRAM kind) renders the figure's image twice -- once as
@@ -1819,12 +1818,12 @@ test("a Picker search never drops an already-inserted asset out of Instant Previ
// must narrow; the preview must not.
await user.type(screen.getByLabelText("Asset 검색"), "other");
await user.click(screen.getByRole("button", { name: "검색" }));
const picker = screen.getByRole("group", { name: "본문에 Asset 삽입" });
await waitFor(() =>
expect(screen.queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
expect(within(picker).queryByRole("button", { name: /inserted-diagram/ })).not.toBeInTheDocument(),
);
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const panel = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
const panel = screen.getByRole("region", { name: "즉시 미리보기" });
assert.equal(
within(panel).queryByRole("alert")?.textContent ?? null,
+32 -1
View File
@@ -60,6 +60,36 @@ describe("Content Format v1", () => {
]);
});
it("accepts the plain Markdown the server renderer already accepted", () => {
/*
Studio
. `:::table` , callout
note/tip/warning/danger .
*/
expect(parseCaseContent("| A | B |\n|---|---|\n| 1 | 2 |").map((block) => block.type)).toEqual([
"DATA_TABLE",
]);
expect(parseCaseContent(":::note\n\n참고할 것.\n\n:::").map((block) => block.type)).toEqual([
"CALLOUT",
]);
expect(parseCaseContent(":::danger\n\n위험.\n\n:::").map((block) => block.type)).toEqual([
"CALLOUT",
]);
expect(parseCaseContent("# 제목1\n\n###### 제목6").map((block) => block.type)).toEqual([
"HEADING",
"HEADING",
]);
expect(parseCaseContent("---").map((block) => block.type)).toEqual(["THEMATIC_BREAK"]);
expect(parseCaseContent("![대체](/api/v1/public/media/x)").map((block) => block.type)).toEqual([
"IMAGE",
]);
// 속성이 없는 directive 도 이름이 온전해야 한다. 정규화 정규식이 되돌아가며 마지막 글자를
// 속성 쪽으로 넘기는 바람에 `:::note` 가 `:::not{e}` 로 바뀌던 적이 있다.
expect(() => parseCaseContent(":::unknown\n\ntext\n\n:::")).toThrow(
/unknown block directive: unknown/,
);
});
it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
const rejected = [
"<script>alert(1)</script>",
@@ -74,11 +104,12 @@ describe("Content Format v1", () => {
"[x](</safe\u001fpath>)",
"[x](</safe\u007fpath>)",
"- outer\n - nested",
"# level one",
"- [ ] task",
"> > nested",
':::unknown key="value"\ntext\n:::',
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
"![x](javascript:alert(1))",
"![x](//evil.example/x.png)",
];
for (const source of rejected) {
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
test("canonical source records the pinned revision and version", () => {
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
assert.equal(canonicalSource.version, "3.0.0");
assert.equal(canonicalSource.version, "3.1.0");
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
@@ -397,7 +397,10 @@ test("validation issue order, evidence, preview freshness and warning-set public
{ expectedVersion: 2 },
{ idempotencyKey: "invalid" },
);
assert.equal(invalid.status, "INVALID");
// 비어 있음은 더 이상 게시를 막지 않는다 — 무엇을 얼마나 쓸지는 작성자가 정한다. 그래서 이
// 문서는 INVALID 가 아니라 WARNINGS 다. 항목이 사라지는 것이 아니라 심각도만 내려간다는 것이
// 여기서 확인해야 할 점이고, 그래서 코드 목록은 그대로 둔다.
assert.equal(invalid.status, "WARNINGS");
assert.deepEqual(
invalid.issues.map(({ code }) => code),
["QUESTION_FACT_REQUIRED", "QUESTION_OPTIONS_FEWER_THAN_TWO"],
@@ -296,7 +296,7 @@ describe("TechLog explore discovery", () => {
const user = userEvent.setup();
const { router } = await renderDiscoveryRoute(
"TECH_LOG_EXPLORE",
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
"/explore?type=CASE&topic=jpa&project=backend-skeleton",
);
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
@@ -306,22 +306,28 @@ describe("TechLog explore discovery", () => {
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
await waitFor(() => {
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
expect(screen.getByLabelText("주제")).toHaveValue("jpa");
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
});
// 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
// 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
// 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
expect(
within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
).toHaveValue("authentication");
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
expect(
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
await user.selectOptions(screen.getByLabelText("주제"), "Authentication");
await user.selectOptions(screen.getByLabelText("주제"), "authentication");
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
await user.click(screen.getByRole("button", { name: "적용" }));
await waitFor(() => {
expect(router.state.location.search).toBe(
"?project=auth-lab&topic=Authentication&type=QUESTION",
"?project=auth-lab&topic=authentication&type=QUESTION",
);
});
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
@@ -42,6 +42,13 @@ const routeComponents = {
type DocumentRouteId = keyof typeof routeComponents;
/** 머리말이 거는 주제 링크를 확인하기 위한 픽스처의 이름 → slug 대응. */
const topicSlugs: Readonly<Record<string, string>> = {
JPA: "jpa",
Authentication: "authentication",
Redis: "redis",
};
class NoopIntersectionObserver implements IntersectionObserver {
readonly root = null;
readonly rootMargin = "0px";
@@ -205,13 +212,30 @@ describe("TechLog canonical Public documents", () => {
const main = screen.getByRole("main");
expect(within(main).getByRole("heading", { level: 1, name: title })).toBeVisible();
expect(within(main).getByText(evidence, { exact: false })).toBeVisible();
expect(within(main).getByRole("navigation", { name: "문서 경로" })).toHaveTextContent(
`${kind}/${topic}/${project}`,
/*
.
"그 글이 문서에 있는가" .
*/
const [firstEvidence] = within(main).getAllByText(evidence, { exact: false });
expect(firstEvidence).toBeVisible();
const breadcrumb = within(main).getByRole("navigation", { name: "문서 경로" });
expect(breadcrumb).toHaveTextContent(`${kind}/${topic}/${project}`);
/*
.
`/topics/:slug` ,
404 .
*/
expect(within(breadcrumb).getByRole("link", { name: topic })).toHaveAttribute(
"href",
`/explore?topic=${topicSlugs[topic]}`,
);
if (path === "/cases/collection-fetch-join-pagination") {
expect(within(main).getByText(`게시 ${published} · 마지막 검증 2026.08.11`)).toBeVisible();
/*
Case . `.case-meta` Case
`.public-document-header dl` . .
*/
if (kind === "Case") {
expect(container.querySelector(".case-meta")).toHaveTextContent(`게시 ${published}`);
} else {
const metadata = container.querySelector(".public-document-header dl");
expect(metadata).toHaveTextContent(`유형${kind}`);
@@ -258,12 +282,12 @@ describe("TechLog canonical Public documents", () => {
expect(image).toHaveAttribute("loading", "lazy");
});
it("uses the generic Case markup and omits source relations for the canonical empty state", async () => {
it("uses the one Case layout and omits source relations for the canonical empty state", async () => {
const generic = await renderDocumentRoute(
"TECH_LOG_CASE",
"/cases/redis-adapter-ttl-boundary",
);
expect(screen.getByRole("main")).toHaveClass("shell", "public-document-page");
expect(screen.getByRole("main")).toHaveClass("case-page");
expect(generic.container.querySelector("#ownership")).toHaveTextContent(
"정책과 저장 명령의 주인을 구분하기",
);
@@ -252,35 +252,26 @@ describe("TechLog project screens", () => {
]);
});
it("keeps project activity ordered and preserves self-fragment and record links", async () => {
/*
.
, "기록" "활동" .
.
*/
it("keeps project activity ordered and leaves reading to the records screen", async () => {
const { container } = await renderPublicRoute(
"TECH_LOG_PROJECT_ACTIVITY",
"/projects/backend-skeleton/activity",
);
expect(
Array.from(container.querySelectorAll(".project-activity-list > li > article"), (item) => ({
id: item.id,
href: item.querySelector("a")?.getAttribute("href"),
label: item.querySelector("a")?.textContent,
})),
).toEqual([
{
id: "fetch-join-case-published",
href: "/cases/collection-fetch-join-pagination",
label: "연결된 공개 기록 읽기",
},
{
id: "storage-contract",
href: "/projects/backend-skeleton/activity#storage-contract",
label: "이 활동 위치 열기",
},
{
id: "redis-case-published",
href: "/cases/redis-adapter-ttl-boundary",
label: "연결된 공개 기록 읽기",
},
const items = Array.from(
container.querySelectorAll(".project-activity-list > li > article"),
);
expect(items.map((item) => item.id)).toEqual([
"fetch-join-case-published",
"storage-contract",
"redis-case-published",
]);
expect(items.flatMap((item) => Array.from(item.querySelectorAll("a")))).toEqual([]);
});
});
@@ -0,0 +1,131 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
import { createHttpPublicContentGateway } from "../../../src/features/tech-log/adapters/http/http-public-content-gateway.ts";
type QuestionDetail = components["schemas"]["QuestionDetailResponse"];
/*
Open Question .
`points` `{group, items}` `.filter` ,
`QuestionPointGroup` ****. `.filter`
. `as` .
"게시했는데 public 에 안 뜬다" , .
,
. .
*/
function gatewayReturning(value: unknown) {
return createHttpPublicContentGateway({
operations: {
execute: () =>
Promise.resolve({
kind: "SUCCESS" as const,
value,
metadata: { status: 200 },
effect: "NOT_APPLICABLE" as const,
}),
},
});
}
const DETAIL = {
canonicalPath: "/questions/refresh-rotation-replica-contention",
indexable: true,
question: {
question: "Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가",
summary: "두 replica가 같은 refresh token으로 동시에 갱신할 수 있다.",
context: "",
importance: "",
status: "OPEN",
nextVerification: "저장소를 공유한 뒤에 재현한다.",
points: {
facts: ["realm은 refresh token rotation과 재사용 허용 0회를 쓴다."],
assumptions: ["운영에서는 replica가 둘 이상이고 저장소를 공유한다."],
unknowns: ["같은 refresh token으로 동시에 갱신하면 어떻게 되는지."],
constraints: ["이미 발급된 access token은 만료 전까지 계속 통한다."],
},
updates: [],
openedAt: "2026-08-24T00:00:00.000Z",
updatedAt: "2026-08-26T13:41:53.974512Z",
},
relations: {
primaryProject: {
type: "PROJECT",
title: "KeyCloak Patterns",
path: "/projects/keycloak-patterns",
summary: "Keycloak을 쓰면서 실제로 부딪힌 인증 경계를 기록합니다",
},
resultCase: {
type: "CASE",
title: "Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출",
path: "/cases/split-custody-access-token",
summary: "토큰 교환과 토큰 관리의 책임이 서버로 이전했다.",
},
derivedReferences: [],
},
} satisfies QuestionDetail;
test("a published Open Question maps its four point groups into the screen's fields", async () => {
const gateway = gatewayReturning(DETAIL);
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
assert.ok(record, "게시된 질문은 상세로 돌아와야 한다");
assert.equal(record.kind, "QUESTION");
assert.equal(record.title, DETAIL.question.question);
assert.equal(record.questionStatus, "OPEN");
assert.deepEqual([...record.facts], DETAIL.question.points.facts);
assert.deepEqual([...record.assumptions], DETAIL.question.points.assumptions);
assert.deepEqual([...record.unknowns], DETAIL.question.points.unknowns);
assert.deepEqual([...record.constraints], DETAIL.question.points.constraints);
assert.equal(record.nextValidation, DETAIL.question.nextVerification);
// 질문 상세는 프로젝트를 `relations` 에 담는다 — `question` 에서 찾으면 머리말의 프로젝트
// 칸이 늘 비어 있다.
assert.equal(record.projectTitle, "KeyCloak Patterns");
assert.equal(record.projectSlug, "keycloak-patterns");
});
/*
. `primaryProject` .
*/
test("a question's relations read their reason from the contract's own group names", async () => {
const gateway = gatewayReturning(DETAIL);
const record = await gateway.getRecord("QUESTION", "refresh-rotation-replica-contention");
assert.deepEqual(
[...record!.relations].map((relation) => ({ reason: relation.reason, path: relation.path })),
[
{
reason: "이 질문에서 나온 기록",
path: "/cases/split-custody-access-token",
},
],
);
});
/*
. `satisfies` ,
.
*/
test("the contract names the four point groups the screen renders", () => {
const points: components["schemas"]["QuestionPointGroup"] = {
facts: [],
assumptions: [],
unknowns: [],
constraints: [],
};
assert.deepEqual(Object.keys(points).sort(), [
"assumptions",
"constraints",
"facts",
"unknowns",
]);
});
@@ -0,0 +1,71 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import type { components } from "../../../src/features/tech-log/contracts/public/generated.ts";
type ReferenceBody =
components["schemas"]["ReferenceDetailResponse"]["reference"];
/*
.
Reference . Studio "
" .
`purposeSummary`, `applyWhenMarkdown`, `exceptionsMarkdown`, `examplesMarkdown`. undefined
`as string` .
****. ,
.
*/
test("the public Reference contract keeps the fields the screen reads", () => {
const reference = {
title: "",
// 제목 바로 아래에 오는 것은 문서의 요약이다. scopeSummary 는 그 아래 "이 기준을 쓰는 이유"다.
summary: "",
scopeSummary: "",
appliesTo: [],
excludedScope: [],
rules: [{ title: "", body: "" }],
examples: [],
freshnessStatus: "CURRENT",
content: "",
contentFormat: "MARKDOWN",
contentFormatVersion: 1,
tags: [],
publishedAt: "",
updatedAt: "",
} satisfies ReferenceBody;
// 화면이 읽는 이름 그대로. 하나라도 계약에서 사라지면 위의 `satisfies` 가 먼저 깨진다.
assert.deepEqual(Object.keys(reference).sort(), [
"appliesTo",
"content",
"contentFormat",
"contentFormatVersion",
"examples",
"excludedScope",
"freshnessStatus",
"publishedAt",
"rules",
"scopeSummary",
"summary",
"tags",
"title",
"updatedAt",
]);
});
/*
Reference `content` . Studio
`body_markdown` `content`
.
*/
test("a rule carries its own title and body, not a slice of markdown", () => {
const rule: NonNullable<ReferenceBody["rules"]>[number] = {
title: "Authorization Endpoint에는 client_secret을 보내지 않는다",
body: "이 요청은 브라우저의 full-page navigation으로 나간다.",
};
assert.equal(typeof rule.title, "string");
assert.equal(typeof rule.body, "string");
});
+11 -3
View File
@@ -386,7 +386,12 @@ describe("shared Public record renderer", () => {
);
});
it("uses generic Case markup and does not present generatedAt as publication", () => {
/*
Case "축약본" .
, breadcrumb Case
. .
*/
it("uses the one Case layout and does not present generatedAt as publication", () => {
renderInRouter(
<PublicRecordRenderer
{...renderDependencies}
@@ -402,14 +407,17 @@ describe("shared Public record renderer", () => {
);
const main = screen.getByRole("main");
expect(main).toHaveClass("shell", "public-document-page");
expect(main).toHaveClass("case-page");
expect(main).toHaveAttribute("id", "main-content");
expect(screen.getByText("게시 전")).toBeVisible();
// 완성된 배치에서는 "게시 전" 이 기록 줄의 한 문장 안에 들어간다 — 홀로 선 노드가 아니다.
expect(main).toHaveTextContent("게시 전");
expect(main).not.toHaveTextContent("2035.05.06");
expect(screen.getByRole("region", { name: "문제와 결론" })).toHaveTextContent(
"문제문제결론결론",
);
expect(screen.getByText("일반 본문")).toBeVisible();
// 제목이 없는 본문에는 목차를 그리지 않는다 — 빈 레일만 남는다.
expect(screen.queryByLabelText("문서 목차")).toBeNull();
});
it("renders supplied Reference and Question preview fields and empty copy", () => {
@@ -39,7 +39,9 @@ const expectedRoutes = [
["TECH_LOG_STUDIO_PUBLICATION_PREVIEW", "/studio/publications/:publicationEventId/preview", "STUDIO", "TechLogPublicationEventIdParams", null],
["TECH_LOG_STUDIO_ASSETS", "/studio/assets", "STUDIO", null, null],
["TECH_LOG_STUDIO_TAXONOMY", "/studio/taxonomy", "STUDIO", null, null],
["TECH_LOG_STUDIO_PROJECT_EDIT", "/studio/projects/:id", "STUDIO", "TechLogDocumentIdParams", null],
["TECH_LOG_STUDIO_RELEASES", "/studio/releases", "STUDIO", null, null],
["TECH_LOG_STUDIO_RELEASE_EDIT", "/studio/releases/:id", "STUDIO", "TechLogDocumentIdParams", null],
["TECH_LOG_STUDIO_NOT_FOUND", "/studio/*", "STUDIO", "TechLogStudioSplat", null],
["NOT_FOUND", "*", "PUBLIC", "NotFoundSplat", null],
] as const;
@@ -72,13 +74,15 @@ const expectedTitles = {
TECH_LOG_STUDIO_PUBLICATION_PREVIEW: "게시 Snapshot",
TECH_LOG_STUDIO_ASSETS: "Asset",
TECH_LOG_STUDIO_TAXONOMY: "주제와 프로젝트",
TECH_LOG_STUDIO_PROJECT_EDIT: "프로젝트 편집",
TECH_LOG_STUDIO_RELEASES: "릴리즈",
TECH_LOG_STUDIO_RELEASE_EDIT: "릴리즈 편집",
TECH_LOG_STUDIO_NOT_FOUND: "Studio 화면을 찾을 수 없습니다",
NOT_FOUND: "페이지를 찾을 수 없습니다.",
} as const;
describe("TechLog route boundary contract", () => {
it("freezes the standalone 29-route inventory before runtime installation", () => {
it("freezes the standalone 31-route inventory before runtime installation", () => {
expect(
Object.values(TECH_LOG_ROUTE_REGISTRY).map((definition) => [
definition.routeId,
@@ -153,7 +157,7 @@ describe("TechLog route boundary contract", () => {
for (const locale of ["ko-KR", "en-US"] as const) {
const catalog: Readonly<Record<string, string>> =
TECH_LOG_MESSAGE_CATALOGS[locale];
expect(Object.keys(catalog)).toHaveLength(60);
expect(Object.keys(catalog)).toHaveLength(64);
for (const [routeId, title] of Object.entries(expectedTitles)) {
expect(catalog[`route.${routeId}.title`]).toBe(title);
expect(catalog[`route.${routeId}.navigation`]).toBe(title);
@@ -0,0 +1,91 @@
// @vitest-environment jsdom
import { strict as assert } from "node:assert";
import { afterEach, test } from "vitest";
import { renderHook } from "@testing-library/react";
import { useSaveShortcut } from "../../../src/features/tech-log/presentation/studio/components/use-save-shortcut.ts";
function pressSave(overrides: Partial<KeyboardEventInit> = {}) {
const event = new KeyboardEvent("keydown", {
key: "s",
ctrlKey: true,
cancelable: true,
bubbles: true,
...overrides,
});
window.dispatchEvent(event);
return event;
}
afterEach(() => {
document.body.innerHTML = "";
});
/*
Ctrl+S "페이지를 파일로 저장".
.
.
*/
test("Ctrl+S saves and keeps the browser from offering the page as a file", () => {
let saved = 0;
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
const event = pressSave();
assert.equal(saved, 1);
assert.equal(event.defaultPrevented, true);
unmount();
});
test("Cmd+S does the same, because the habit is the same on a Mac", () => {
let saved = 0;
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
pressSave({ ctrlKey: false, metaKey: true });
assert.equal(saved, 1);
unmount();
});
/*
. "저장할 것이 없다"
, .
*/
test("a disabled shortcut still swallows the browser default", () => {
let saved = 0;
const { unmount } = renderHook(() =>
useSaveShortcut(() => { saved += 1; }, false),
);
const event = pressSave();
assert.equal(saved, 0);
assert.equal(event.defaultPrevented, true);
unmount();
});
test("modified chords are left to whoever else wants them", () => {
let saved = 0;
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
const shift = pressSave({ shiftKey: true });
const alt = pressSave({ altKey: true });
const plain = pressSave({ ctrlKey: false });
assert.equal(saved, 0);
assert.equal(shift.defaultPrevented, false);
assert.equal(alt.defaultPrevented, false);
assert.equal(plain.defaultPrevented, false);
unmount();
});
test("the listener goes away with the screen", () => {
let saved = 0;
const { unmount } = renderHook(() => useSaveShortcut(() => { saved += 1; }));
unmount();
pressSave();
assert.equal(saved, 0);
});
@@ -166,8 +166,7 @@ describe("TechLog Studio project decision authoring", () => {
expect(screen.getByRole("complementary", { name: "작업 상태" }))
.toHaveTextContent("종류Decision");
await user.click(screen.getByRole("tab", { name: "즉시 미리보기" }));
const preview = screen.getByRole("tabpanel", { name: "즉시 미리보기" });
const preview = screen.getByRole("region", { name: "즉시 미리보기" });
expect(
within(preview).getByRole("heading", {
name: "목록 페이징과 컬렉션 로딩을 분리합니다",
@@ -14,7 +14,10 @@ import {
derivePreviewState,
deriveValidationState,
} from "../../../src/features/tech-log/domain/studio/document-state.ts";
import { createLocalId } from "../../../src/features/tech-log/domain/studio/local-id.ts";
import {
createLocalId,
createNewItemId,
} from "../../../src/features/tech-log/domain/studio/local-id.ts";
const now = "2026-08-14T12:00:00.000Z";
const validUntil = "2026-08-14T12:30:00.000Z";
@@ -317,3 +320,32 @@ test("local Studio IDs are deterministic without Web Crypto and prefer UUIDs whe
"save-fixture-uuid",
);
});
/*
.
·· id `createLocalId` , `relation-<uuid>`
. uuid 400
"saveStudioDocument broke its contract."
. .
*/
const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
test("new list item IDs are bare UUIDs, because they cross the contract", () => {
assert.equal(
createNewItemId({ randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" }),
"3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607",
);
// Web Crypto 가 없는 환경에서도 형식은 uuid 여야 한다 — 값은 서버가 새로 부여하지만, 형식이
// 어긋나면 요청 자체가 거절된다.
assert.match(createNewItemId(null, () => 0.5), UUID_SHAPE);
assert.match(createNewItemId(null, () => 0), UUID_SHAPE);
assert.match(createNewItemId(null, () => 0.999), UUID_SHAPE);
});
test("prefixed local IDs never satisfy the contract's UUID shape", () => {
assert.equal(
UUID_SHAPE.test(createLocalId("relation", { randomUUID: () => "3f1a2b4c-5d6e-4f70-8a91-b2c3d4e5f607" })),
false,
);
});
@@ -104,25 +104,27 @@ describe("TechLog Studio document editor", () => {
expect(screen.getByRole("complementary", { name: "작업 상태" })).toHaveTextContent(
"저장 버전4종류CASE",
);
expect(screen.getByRole("link", { name: "저장본 검증" })).toHaveAttribute(
"href",
`/studio/documents/${FIXTURE_IDS.redisAdapterCase}/validation`,
);
// 작성자가 밟는 단계는 저장과 게시 둘뿐이다. 예전에는 검증 → 미리보기 → 게시 세 화면을
// 차례로 거쳐야 했는데, 그 셋은 백엔드가 게시 요청에 요구하는 값을 만들기 위한 것이지
// 작성자에게 물어볼 것이 아니었다 — 지금은 게시 버튼 뒤에서 잇달아 부른다.
const rail = screen.getByRole("complementary", { name: "작업 상태" });
expect(within(rail).getAllByRole("button").map((button) => button.textContent)).toEqual([
"저장",
"게시",
]);
expect(within(rail).queryAllByRole("link")).toHaveLength(0);
const editTab = screen.getByRole("tab", { name: "편집" });
const previewTab = screen.getByRole("tab", { name: "즉시 미리보기" });
editTab.focus();
await user.keyboard("{ArrowRight}");
expect(previewTab).toHaveFocus();
expect(previewTab).toHaveAttribute("aria-selected", "true");
// 편집과 미리보기는 한 화면에 함께 있다. 탭이었을 때는 고친 결과를 보려면 편집하던 자리를
// 화면에서 치워야 했고, 돌아오면 스크롤 위치도 잃었다 — 그 왕복이 없어야 한다는 것이
// 이 화면의 요구다. 아무것도 누르지 않은 채로 둘 다 보이는지 묻는다.
expect(screen.getByRole("region", { name: "문서 편집" })).toBeVisible();
expect(
within(screen.getByRole("tabpanel", { name: "즉시 미리보기" })).getByRole(
within(screen.getByRole("region", { name: "즉시 미리보기" })).getByRole(
"heading",
{ level: 1, name: "편집한 Redis 경계" },
),
).toBeVisible();
await user.keyboard("{Home}");
expect(editTab).toHaveFocus();
expect(screen.queryByRole("tab", { name: "즉시 미리보기" })).toBeNull();
expect(screen.getByLabelText("제목")).toHaveValue("편집한 Redis 경계");
expect(calls).toEqual({ save: 0, validate: 0, preview: 0, publish: 0 });
});
@@ -79,7 +79,9 @@ describe("TechLog consumer-visible style contract", () => {
expect(rootStyle.getPropertyValue("--ink")).toBe("#17181b");
expect(rootStyle.getPropertyValue("--signal")).toBe("#3e5cc7");
expect(rootStyle.getPropertyValue("--shell")).toBe("1180px");
expect(rootStyle.getPropertyValue("--body-copy")).toBe("42rem");
// 42rem 은 영문 기준 측정값이었다. 한글 본문에서 좁았고, 바로 위의 유형/프로젝트 줄이
// shell 전체를 쓰고 있어 대비가 더 심했다. 읽는 단 전체가 이 값 하나를 따른다.
expect(rootStyle.getPropertyValue("--body-copy")).toBe("56rem");
expect(rootStyle.color).toBe("var(--ink)");
expect(rootStyle.background).toBe("var(--canvas)");
expect(bodyStyle.color).toBe("var(--ink)");
@@ -105,7 +105,8 @@ async function interact(page, action) {
} else if (action === "studio-menu") {
await page.getByRole("button", { name: "Studio 메뉴 열기" }).click();
} else if (action === "immediate-preview") {
await page.getByRole("tab", { name: "즉시 미리보기" }).click();
// 미리보기는 이제 편집 옆에 늘 떠 있다 — 열 탭이 없으므로 자리 잡기를 기다리기만 한다.
await page.getByRole("region", { name: "즉시 미리보기" }).waitFor();
} else if (action === "dirty-dialog") {
await page.getByLabel("요약").fill("저장하지 않은 시각 검증 변경");
await page.getByRole("link", { name: "게시 기록", exact: true }).first().click();
+47 -5
View File
@@ -802,8 +802,10 @@ describe("candidate archive and provider upload boundaries", () => {
// shorter than one `systemctl show`, so the tree has to be sampled, not
// sampled once at whatever moment the assertions happen to arrive.
const tree = recordProviderProcessTree(execution.child.pid!);
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
const unit = await waitForProviderUnit("vulnerability", execution.child.pid, tree.sample);
tree.sample();
const properties = showProviderUnit(unit);
tree.sample();
expect(properties).toMatchObject({
ActiveState: "active",
CPUQuotaPerSecUSec: "1s",
@@ -820,9 +822,29 @@ describe("candidate archive and provider upload boundaries", () => {
await expect(readFile(path.join(cgroupRoot, "pids.max"), "utf8")).resolves.toBe("64\n");
await expect(readFile(path.join(cgroupRoot, "cpu.max"), "utf8")).resolves.toBe("100000 100000\n");
expect(readProviderUnitMetadata(unit)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
expect(showProcessArguments(execution.child.pid)).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
// Read once, while the supervisor is still alive, and reuse below. `argv`
// does not change over a process's life, so the recorded value says the
// same thing a second lookup would -- except that by the time the run has
// completed there is no process left to look up, and `ps` failed.
const supervisorArguments = showProcessArguments(execution.child.pid);
expect(supervisorArguments).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
const reportIdentity = await lstat(fixture.reportPath);
const result = await execution.completion;
/*
. 5ms
1.5
`directChildArguments` .
.
*/
const result = await (async () => {
let settled: Awaited<typeof execution.completion> | undefined;
const completion = execution.completion.then((value) => (settled = value));
while (settled === undefined) {
tree.sample();
await Promise.race([completion, delay(5)]);
}
tree.sample();
return settled;
})();
tree.stop();
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).not.toContain(credential);
@@ -837,14 +859,23 @@ describe("candidate archive and provider upload boundaries", () => {
const directChildPids = tree.directChildPids();
const directChildArguments = tree.directChildArguments();
const observedArguments = [
showProcessArguments(execution.child.pid),
supervisorArguments,
...directChildArguments,
...processArguments,
];
expect(observedArguments.join("\n")).not.toContain(credential);
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
/*
scope "몇 개인가" argv pid fork
, bwrap/prlimit .
(systemd-run 1, raw-guardian 1)
. wrapper scope .
*/
expect(
processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts")).length,
`scope wrapper never observed in the scope; recorded: ${processArguments.join(" | ")}`,
).toBeGreaterThan(0);
expect(
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
@@ -1717,11 +1748,15 @@ function providerSupervisorEnvironment(
async function waitForProviderUnit(
kind: "vulnerability" | "provenance",
supervisorPid: number | undefined,
/** 블로킹 조회 앞뒤로 부른다 — 그 사이에만 살아 있는 프로세스를 놓치지 않기 위한 것이다. */
onPoll?: () => void,
): Promise<string> {
if (!supervisorPid) throw new Error("provider supervisor did not expose its PID");
const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`;
for (let attempt = 0; attempt < 120; attempt += 1) {
onPoll?.();
const units = listProviderUnits(pattern);
onPoll?.();
if (units.length === 1) return units[0]!;
if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`);
await delay(25);
@@ -2020,6 +2055,13 @@ function recordProviderProcessTree(supervisorPid: number) {
timer.unref();
sample();
return Object.freeze({
/*
. `systemctl` `spawnSync` ,
5ms bubblewrap ,
.
.
*/
sample,
stop() {
stopped = true;
clearInterval(timer);
+2 -2
View File
@@ -200,8 +200,8 @@ describe("CI gate contract", () => {
// than taking either side's number.
// Task 11 added TECH_LOG_STUDIO_ASSETS's manual accessibility evidence.
// Alignment follow-up item 2 added the TechLog junit report.
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(111);
expect(contract.artifacts).toHaveLength(132);
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(113);
expect(contract.artifacts).toHaveLength(134);
expect(contract.stages).toHaveLength(5);
expect(contract.retention.classes).toHaveLength(5);
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
@@ -192,7 +192,7 @@ describe("selective Task 3 contract closure", () => {
expect(canonical.gates).toHaveLength(27);
expect(canonical.commands).toHaveLength(85);
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(97);
expect(canonical.artifacts).toHaveLength(132);
expect(canonical.artifacts).toHaveLength(134);
expect(canonical.stages).toHaveLength(5);
expect(canonical.retention.classes).toHaveLength(5);
@@ -48,9 +48,11 @@ describe("TechLog production serving contract", () => {
"^/studio/documents/[^/]+/publish$",
"^/studio/documents/[^/]+/validation$",
"^/studio/documents/new$",
"^/studio/projects/[^/]+$",
"^/studio/publications$",
"^/studio/publications/[^/]+/preview$",
"^/studio/releases$",
"^/studio/releases/[^/]+$",
"^/studio/taxonomy$",
]);
expect(contract.notFound).toEqual({

Some files were not shown because too many files have changed in this diff Show More