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
This commit is contained in:
DongHyeonka
2026-08-23 17:38:49 +09:00
co-authored by Claude Opus 5
parent c03b0c77b8
commit b3aa304975
56 changed files with 935 additions and 1322 deletions
+1
View File
@@ -29,3 +29,4 @@ artifacts/tests/visual/
# Git worktrees created inside the repository. A worktree is a checkout, not # Git worktrees created inside the repository. A worktree is a checkout, not
# source: committing one would nest a second working copy inside this one. # source: committing one would nest a second working copy inside this one.
.worktrees/ .worktrees/
.playwright-mcp/
@@ -1 +0,0 @@
[ 1019ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -1 +0,0 @@
[ 39361ms] [ERROR] Failed to load resource: the server responded with a status of 409 (Conflict) @ https://hyeonworks.com/api/v1/studio/cases/b09f168b-b205-478f-b60f-83872d1b4d01:0
@@ -1 +0,0 @@
[ 4651ms] [ERROR] Failed to load resource: the server responded with a status of 409 (Conflict) @ https://hyeonworks.com/api/v1/studio/cases/b09f168b-b205-478f-b60f-83872d1b4d01:0
@@ -1,16 +0,0 @@
- generic [ref=f1e3]:
- banner [ref=f1e4]:
- generic [ref=f1e5]: prod
- main [ref=f1e6]:
- heading "Sign in to your account" [level=1] [ref=f1e8]
- generic [ref=f1e12]:
- generic [ref=f1e13]:
- generic [ref=f1e14]: Username or email
- textbox "Username or email" [active] [ref=f1e17]
- generic [ref=f1e18]:
- generic [ref=f1e19]: Password
- generic [ref=f1e21]:
- textbox "Password" [ref=f1e24]
- button "Show password" [ref=f1e26] [cursor=pointer]:
- generic [ref=f1e27]:
- button "Sign In" [ref=f1e30] [cursor=pointer]
@@ -1,74 +0,0 @@
- generic [ref=f2e3]:
- link "본문으로 건너뛰기" [ref=f2e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f2e5]:
- generic [ref=f2e6]:
- link "TechLog 홈" [ref=f2e8] [cursor=pointer]:
- /url: /
- text: TechLog
- generic [ref=f2e9]:
- button "TechLog 검색 열기" [ref=f2e11] [cursor=pointer]: 검색
- group [ref=f2e12]:
- generic "메뉴" [ref=f2e13] [cursor=pointer]
- main [ref=f2e14]:
- region [ref=f2e15]:
- heading "TechLog" [level=1] [ref=f2e16]
- paragraph [ref=f2e17]: 문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.
- region [ref=f2e18]:
- generic [ref=f2e19]:
- generic [ref=f2e20]:
- paragraph [ref=f2e21]: Index
- heading "최근 기록" [level=2] [ref=f2e22]
- link "모든 기록 탐색" [ref=f2e23] [cursor=pointer]:
- /url: /explore
- list [ref=f2e24]:
- listitem [ref=f2e25]:
- link "RELEASE 2026.08.21 첫 공개 공개 사이트와 Studio 작성 흐름을 처음으로 실제 서버에 올렸습니다. TechLog · TechLog" [ref=f2e26] [cursor=pointer]:
- /url: /releases/0.1.0
- generic [ref=f2e27]:
- generic [ref=f2e28]: RELEASE
- time [ref=f2e29]: 2026.08.21
- generic [ref=f2e30]:
- heading "첫 공개" [level=3] [ref=f2e31]
- paragraph [ref=f2e32]: 공개 사이트와 Studio 작성 흐름을 처음으로 실제 서버에 올렸습니다.
- paragraph [ref=f2e33]: TechLog · TechLog
- generic [ref=f2e34]:
- region [ref=f2e35]:
- generic [ref=f2e37]:
- paragraph [ref=f2e38]: Explore
- heading "어떤 맥락으로 읽을까요?" [level=2] [ref=f2e39]
- list [ref=f2e40]:
- listitem [ref=f2e41]:
- link "문제를 따라가며 검증 과정을 읽습니다 Case" [ref=f2e42] [cursor=pointer]:
- /url: /explore/cases
- generic [ref=f2e43]: 문제를 따라가며 검증 과정을 읽습니다
- strong [ref=f2e44]: Case
- generic [ref=f2e45]:
- listitem [ref=f2e46]:
- link "다시 찾을 수 있는 기술 기준을 확인합니다 Reference" [ref=f2e47] [cursor=pointer]:
- /url: /explore/references
- generic [ref=f2e48]: 다시 찾을 수 있는 기술 기준을 확인합니다
- strong [ref=f2e49]: Reference
- generic [ref=f2e50]:
- listitem [ref=f2e51]:
- link "아직 끝나지 않은 판단과 다음 검증을 봅니다 OpenQuestion" [ref=f2e52] [cursor=pointer]:
- /url: /explore/questions
- generic [ref=f2e53]: 아직 끝나지 않은 판단과 다음 검증을 봅니다
- strong [ref=f2e54]: OpenQuestion
- generic [ref=f2e55]:
- listitem [ref=f2e56]:
- link "여러 기록을 하나의 시스템 맥락에서 연결합니다 Project" [ref=f2e57] [cursor=pointer]:
- /url: /projects
- generic [ref=f2e58]: 여러 기록을 하나의 시스템 맥락에서 연결합니다
- strong [ref=f2e59]: Project
- generic [ref=f2e60]:
- contentinfo [ref=f2e61]:
- generic [ref=f2e62]:
- generic [ref=f2e63]:
- paragraph [ref=f2e64]: 동현
- paragraph [ref=f2e65]: 문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.
- generic [ref=f2e66]:
- link "프로필" [ref=f2e67] [cursor=pointer]:
- /url: /profile
- link "변경 기록" [ref=f2e68] [cursor=pointer]:
- /url: /releases
@@ -1,100 +0,0 @@
- generic [ref=f3e22]:
- link "본문으로 건너뛰기" [ref=f3e23] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f3e24]:
- generic [ref=f3e25]:
- link "TechLog Studio" [ref=f3e26] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f3e27]: Studio
- navigation "Studio 주 탐색" [ref=f3e29]:
- link "작업본" [ref=f3e30] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f3e31] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f3e32] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f3e33] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f3e34] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f3e35] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f3e36]
- main [ref=f3e37]:
- generic [ref=f3e38]:
- generic [ref=f3e39]:
- generic [ref=f3e40]:
- paragraph [ref=f3e41]: WORKING COPIES
- heading "작업본" [level=1] [ref=f3e42]
- paragraph [ref=f3e43]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f3e44] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f3e45]:
- search [ref=f3e46]:
- generic [ref=f3e47]: 검색
- generic [ref=f3e48]:
- searchbox "검색" [ref=f3e49]
- button "검색" [ref=f3e50]
- generic [ref=f3e51]:
- text: 종류
- combobox "종류" [ref=f3e52]:
- option "전체" [selected]
- option "Case"
- option "Reference"
- option "Question"
- option "Decision"
- generic [ref=f3e53]:
- text: 상태
- combobox "상태" [ref=f3e54]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f3e55]:
- generic [ref=f3e56]: 2개 표시 중
- alert [ref=f3e57]: 삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.
- generic [ref=f3e58]:
- article [ref=f3e1]:
- paragraph [ref=f3e2]: Case
- generic [ref=f3e3]:
- heading [level=2] [ref=f3e4]:
- link "게시 흐름 확인" [ref=f3e5] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit
- paragraph [ref=f3e6]: 프로젝트 미지정
- generic [ref=f3e7]:
- generic [ref=f3e8]:
- term [ref=f3e9]: 상태
- definition [ref=f3e10]: 게시 취소
- generic [ref=f3e11]:
- term [ref=f3e12]: 다음
- definition [ref=f3e13]:
- link "검증하기" [ref=f3e14] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation
- generic [ref=f3e15]:
- term [ref=f3e16]: 수정
- definition [ref=f3e17]:
- time [ref=f3e18]: 2026. 8. 21.
- button "삭제" [ref=f3e19]
- article [ref=f3e59]:
- paragraph [ref=f3e60]: Case
- generic [ref=f3e61]:
- heading [level=2] [ref=f3e62]:
- link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f3e63] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit
- paragraph [ref=f3e64]: 프로젝트 미지정
- generic [ref=f3e65]:
- generic [ref=f3e66]:
- term [ref=f3e67]: 상태
- definition [ref=f3e68]: 게시 전
- generic [ref=f3e69]:
- term [ref=f3e70]: 다음
- definition [ref=f3e71]:
- link "검증하기" [ref=f3e72] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation
- generic [ref=f3e73]:
- term [ref=f3e74]: 수정
- definition [ref=f3e75]:
- time [ref=f3e76]: 2026. 8. 21.
- button "삭제" [ref=f3e77]
- paragraph [ref=f3e78]
@@ -1,55 +0,0 @@
- generic [ref=f4e3]:
- link "본문으로 건너뛰기" [ref=f4e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f4e5]:
- generic [ref=f4e6]:
- link "TechLog Studio" [ref=f4e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f4e8]: Studio
- navigation "Studio 주 탐색" [ref=f4e10]:
- link "작업본" [ref=f4e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f4e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f4e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f4e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f4e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f4e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f4e17]
- main [ref=f4e18]:
- generic [ref=f4e19]:
- generic [ref=f4e20]:
- paragraph [ref=f4e21]: NEW WORKING COPY
- heading "새 문서" [level=1] [ref=f4e22]
- paragraph [ref=f4e23]: 목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다.
- generic [ref=f4e24]:
- group "문서 종류" [ref=f4e25]:
- generic [ref=f4e27] [cursor=pointer]:
- radio "Case 문제를 재현하고 검증한 결론을 기록합니다. 문제 · 결론 · 환경 · 재현 · 본문" [checked] [active] [ref=f4e28]
- strong [ref=f4e29]: Case
- generic [ref=f4e30]: 문제를 재현하고 검증한 결론을 기록합니다.
- generic [ref=f4e31]: 문제 · 결론 · 환경 · 재현 · 본문
- generic [ref=f4e32] [cursor=pointer]:
- radio "Reference 반복해서 적용할 기술 기준을 정리합니다. 목적 · 규칙 · 적용 조건 · 예외 · 예시" [ref=f4e33]
- strong [ref=f4e34]: Reference
- generic [ref=f4e35]: 반복해서 적용할 기술 기준을 정리합니다.
- generic [ref=f4e36]: 목적 · 규칙 · 적용 조건 · 예외 · 예시
- generic [ref=f4e37] [cursor=pointer]:
- radio "Question 아직 닫히지 않은 판단과 다음 검증을 관리합니다. 상태 · 사실 · 가정 · 미지수 · 선택지" [ref=f4e38]
- strong [ref=f4e39]: Question
- generic [ref=f4e40]: 아직 닫히지 않은 판단과 다음 검증을 관리합니다.
- generic [ref=f4e41]: 상태 · 사실 · 가정 · 미지수 · 선택지
- generic [ref=f4e42] [cursor=pointer]:
- radio "Decision 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거" [ref=f4e43]
- strong [ref=f4e44]: Decision
- generic [ref=f4e45]: 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다.
- generic [ref=f4e46]: 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거
- generic [ref=f4e47]:
- button "작업본 만들기" [ref=f4e48]
- paragraph [ref=f4e49]: 이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.
- paragraph [ref=f4e50]
@@ -1,143 +0,0 @@
- generic [ref=f4e3]:
- link "본문으로 건너뛰기" [ref=f4e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f4e5]:
- generic [ref=f4e6]:
- link "TechLog Studio" [ref=f4e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f4e8]: Studio
- navigation "Studio 주 탐색" [ref=f4e10]:
- link "작업본" [ref=f4e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f4e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f4e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f4e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f4e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f4e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f4e17]
- main [ref=f4e18]:
- generic [ref=f4e51]:
- tablist "문서 편집 화면" [ref=f4e52]:
- tab "편집" [selected] [ref=f4e53]
- tab "즉시 미리보기" [ref=f4e54]
- generic [ref=f4e55]:
- tabpanel "편집" [ref=f4e57]:
- generic [ref=f4e58]:
- paragraph [ref=f4e59]: CASE · VERSION 1
- heading "문서 편집" [level=1] [ref=f4e60]
- paragraph [ref=f4e61]: 제목 없는 작업본
- region [ref=f4e62]:
- generic [ref=f4e63]:
- paragraph [ref=f4e64]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f4e65]
- generic [ref=f4e66]:
- generic [ref=f4e67]:
- generic [ref=f4e68]: 제목
- textbox "제목" [ref=f4e69]
- generic [ref=f4e70]:
- generic [ref=f4e71]: slug
- textbox "slug" [ref=f4e72]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- generic [ref=f4e73]:
- generic [ref=f4e74]: 요약
- textbox "요약" [ref=f4e75]
- generic [ref=f4e76]:
- generic [ref=f4e77]: Topic
- combobox "Topic" [ref=f4e78]:
- option "선택하지 않음" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f4e79]:
- generic [ref=f4e80]: Project
- combobox "Project" [ref=f4e81]:
- option "미지정" [selected]
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제"
- group "관계" [ref=f4e82]:
- paragraph [ref=f4e84]: 연결한 공개 기록이 없습니다.
- button "관계 추가" [ref=f4e85]
- region [ref=f4e86]:
- generic [ref=f4e87]:
- paragraph [ref=f4e88]: CASE
- heading "문제와 검증" [level=2] [ref=f4e89]
- generic [ref=f4e90]:
- generic [ref=f4e91]:
- generic [ref=f4e92]: 문제
- textbox "문제" [ref=f4e93]
- generic [ref=f4e94]:
- generic [ref=f4e95]: 결론
- textbox "결론" [ref=f4e96]
- generic [ref=f4e97]:
- generic [ref=f4e98]: 검증 환경
- textbox "검증 환경" [ref=f4e99]
- generic [ref=f4e100]:
- generic [ref=f4e101]: 재현 조건
- textbox "재현 조건" [ref=f4e102]
- generic [ref=f4e103]:
- generic [ref=f4e104]: 마지막 검증일
- textbox "마지막 검증일" [ref=f4e105]
- generic [ref=f4e106]:
- generic [ref=f4e107]: 본문 Markdown
- textbox "본문 Markdown" [ref=f4e108]
- generic [ref=f4e109]:
- paragraph [ref=f4e110]: EVIDENCE
- heading "본문에 Asset 삽입" [level=3] [ref=f4e111]
- paragraph [ref=f4e112]: 목록에서 선택하면 본문 커서 위치에 evidence 구문을 삽입합니다. READY 상태의 Asset만 선택할 수 있습니다.
- generic [ref=f4e113]:
- generic [ref=f4e114]:
- generic [ref=f4e115]: 업로드 종류
- combobox "업로드 종류" [ref=f4e116]:
- option "이미지" [selected]
- option "다이어그램"
- option "첨부파일"
- button "Asset 업로드" [ref=f4e117]
- generic [ref=f4e118]:
- search [ref=f4e119]:
- generic [ref=f4e120]: Asset 검색
- generic [ref=f4e121]:
- searchbox "Asset 검색" [ref=f4e122]
- button "검색" [ref=f4e123]
- generic [ref=f4e124]:
- checkbox "삽입할 때 크게 보기 허용" [checked] [ref=f4e125]
- generic [ref=f4e126]: 삽입할 때 크게 보기 허용
- status [ref=f4e127]: 삽입할 수 있는 Asset 1개
- list [ref=f4e128]:
- listitem [ref=f4e129]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f4e130]
- button "삭제" [ref=f4e131]
- complementary [ref=f4e132]:
- paragraph [ref=f4e133]: WORKING COPY
- heading "작업 상태" [level=2] [ref=f4e134]
- status "편집 상태" [ref=f4e135]: 저장됨
- generic [ref=f4e136]:
- generic [ref=f4e137]:
- term [ref=f4e138]: 저장 버전
- definition [ref=f4e139]: "1"
- generic [ref=f4e140]:
- term [ref=f4e141]: 종류
- definition [ref=f4e142]: CASE
- button "저장" [disabled] [ref=f4e143]
- navigation "게시까지의 단계" [ref=f4e144]:
- list [ref=f4e145]:
- listitem [ref=f4e146]:
- generic [ref=f4e147]: "1"
- link "검증" [ref=f4e148] [cursor=pointer]:
- /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/validation
- listitem [ref=f4e149]:
- text:
- generic [ref=f4e150]: "2"
- link "미리보기" [ref=f4e151] [cursor=pointer]:
- /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/preview
- listitem [ref=f4e152]:
- text:
- generic [ref=f4e153]: "3"
- link "게시" [ref=f4e154] [cursor=pointer]:
- /url: /studio/documents/969cc2c7-be6e-42ca-924a-652c63db9784/publish
- paragraph [ref=f4e155]: 불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.
- paragraph [ref=f4e50]: Case 작업본을 만들었습니다.
@@ -1,99 +0,0 @@
- generic [ref=f5e22]:
- link "본문으로 건너뛰기" [ref=f5e23] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f5e24]:
- generic [ref=f5e25]:
- link "TechLog Studio" [ref=f5e26] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f5e27]: Studio
- navigation "Studio 주 탐색" [ref=f5e29]:
- link "작업본" [ref=f5e30] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f5e31] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f5e32] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f5e33] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f5e34] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f5e35] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f5e36]
- main [ref=f5e37]:
- generic [ref=f5e38]:
- generic [ref=f5e39]:
- generic [ref=f5e40]:
- paragraph [ref=f5e41]: WORKING COPIES
- heading "작업본" [level=1] [ref=f5e42]
- paragraph [ref=f5e43]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f5e44] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f5e45]:
- search [ref=f5e46]:
- generic [ref=f5e47]: 검색
- generic [ref=f5e48]:
- searchbox "검색" [ref=f5e49]
- button "검색" [ref=f5e50]
- generic [ref=f5e51]:
- text: 종류
- combobox "종류" [ref=f5e52]:
- option "전체" [selected]
- option "Case"
- option "Reference"
- option "Question"
- option "Decision"
- generic [ref=f5e53]:
- text: 상태
- combobox "상태" [ref=f5e54]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f5e55]:
- generic [ref=f5e56]: 2개 표시 중
- generic [ref=f5e57]:
- article [ref=f5e58]:
- paragraph [ref=f5e59]: Case
- generic [ref=f5e60]:
- heading [level=2] [ref=f5e61]:
- link "게시 흐름 확인" [ref=f5e62] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit
- paragraph [ref=f5e63]: 프로젝트 미지정
- generic [ref=f5e64]:
- generic [ref=f5e65]:
- term [ref=f5e66]: 상태
- definition [ref=f5e67]: 게시 취소
- generic [ref=f5e68]:
- term [ref=f5e69]: 다음
- definition [ref=f5e70]:
- link "검증하기" [ref=f5e71] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation
- generic [ref=f5e72]:
- term [ref=f5e73]: 수정
- definition [ref=f5e74]:
- time [ref=f5e75]: 2026. 8. 21.
- button "삭제" [ref=f5e76]
- article [ref=f5e77]:
- paragraph [ref=f5e78]: Case
- generic [ref=f5e79]:
- heading [level=2] [ref=f5e80]:
- link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f5e81] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit
- paragraph [ref=f5e82]: 프로젝트 미지정
- generic [ref=f5e83]:
- generic [ref=f5e84]:
- term [ref=f5e85]: 상태
- definition [ref=f5e86]: 게시 전
- generic [ref=f5e87]:
- term [ref=f5e88]: 다음
- definition [ref=f5e89]:
- link "검증하기" [ref=f5e90] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation
- generic [ref=f5e91]:
- term [ref=f5e92]: 수정
- definition [ref=f5e93]:
- time [ref=f5e94]: 2026. 8. 21.
- button "삭제" [ref=f5e95]
- paragraph [ref=f5e96]: 작업본 제목 없음 을(를) 삭제했습니다.
@@ -1,55 +0,0 @@
- generic [ref=f6e3]:
- link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f6e5]:
- generic [ref=f6e6]:
- link "TechLog Studio" [ref=f6e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f6e8]: Studio
- navigation "Studio 주 탐색" [ref=f6e10]:
- link "작업본" [ref=f6e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f6e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f6e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f6e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f6e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f6e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f6e17]
- main [ref=f6e18]:
- generic [ref=f6e19]:
- generic [ref=f6e20]:
- paragraph [ref=f6e21]: NEW WORKING COPY
- heading "새 문서" [level=1] [ref=f6e22]
- paragraph [ref=f6e23]: 목적에 맞는 기록 종류를 선택하면 빈 작업본을 만들고 바로 편집을 시작합니다.
- generic [ref=f6e24]:
- group "문서 종류" [ref=f6e25]:
- generic [ref=f6e27] [cursor=pointer]:
- radio "Case 문제를 재현하고 검증한 결론을 기록합니다. 문제 · 결론 · 환경 · 재현 · 본문" [ref=f6e28]
- strong [ref=f6e29]: Case
- generic [ref=f6e30]: 문제를 재현하고 검증한 결론을 기록합니다.
- generic [ref=f6e31]: 문제 · 결론 · 환경 · 재현 · 본문
- generic [ref=f6e32] [cursor=pointer]:
- radio "Reference 반복해서 적용할 기술 기준을 정리합니다. 목적 · 규칙 · 적용 조건 · 예외 · 예시" [ref=f6e33]
- strong [ref=f6e34]: Reference
- generic [ref=f6e35]: 반복해서 적용할 기술 기준을 정리합니다.
- generic [ref=f6e36]: 목적 · 규칙 · 적용 조건 · 예외 · 예시
- generic [ref=f6e37] [cursor=pointer]:
- radio "Question 아직 닫히지 않은 판단과 다음 검증을 관리합니다. 상태 · 사실 · 가정 · 미지수 · 선택지" [checked] [active] [ref=f6e38]
- strong [ref=f6e39]: Question
- generic [ref=f6e40]: 아직 닫히지 않은 판단과 다음 검증을 관리합니다.
- generic [ref=f6e41]: 상태 · 사실 · 가정 · 미지수 · 선택지
- generic [ref=f6e42] [cursor=pointer]:
- radio "Decision 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다. 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거" [ref=f6e43]
- strong [ref=f6e44]: Decision
- generic [ref=f6e45]: 프로젝트가 선택한 방향과 그 근거·영향을 기록합니다.
- generic [ref=f6e46]: 상태 · 결정일 · 결정문 · 판단 이유 · 영향 · 근거
- generic [ref=f6e47]:
- button "작업본 만들기" [ref=f6e48]
- paragraph [ref=f6e49]: 이 화면의 작업본은 현재 Studio 세션에서만 유지됩니다.
- paragraph [ref=f6e50]
@@ -1,122 +0,0 @@
- generic [ref=f6e3]:
- link "본문으로 건너뛰기" [ref=f6e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f6e5]:
- generic [ref=f6e6]:
- link "TechLog Studio" [ref=f6e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f6e8]: Studio
- navigation "Studio 주 탐색" [ref=f6e10]:
- link "작업본" [ref=f6e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f6e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f6e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f6e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f6e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f6e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f6e17]
- main [ref=f6e18]:
- generic [ref=f6e51]:
- tablist "문서 편집 화면" [ref=f6e52]:
- tab "편집" [selected] [ref=f6e53]
- tab "즉시 미리보기" [ref=f6e54]
- generic [ref=f6e55]:
- tabpanel "편집" [ref=f6e57]:
- generic [ref=f6e58]:
- paragraph [ref=f6e59]: QUESTION · VERSION 1
- heading "문서 편집" [level=1] [ref=f6e60]
- paragraph [ref=f6e61]: 제목 없는 작업본
- region [ref=f6e62]:
- generic [ref=f6e63]:
- paragraph [ref=f6e64]: DOCUMENT
- heading "기본 정보" [level=2] [ref=f6e65]
- generic [ref=f6e66]:
- generic [ref=f6e67]:
- generic [ref=f6e68]: 제목
- textbox "제목" [ref=f6e69]
- generic [ref=f6e70]:
- generic [ref=f6e71]: slug
- textbox "slug" [ref=f6e72]:
- /placeholder: 비우면 제목에서 만듭니다 (영문 소문자·숫자·하이픈)
- generic [ref=f6e73]:
- generic [ref=f6e74]: 요약
- textbox "요약" [ref=f6e75]
- generic [ref=f6e76]:
- generic [ref=f6e77]: Topic
- combobox "Topic" [ref=f6e78]:
- option "선택하지 않음" [selected]
- option "OAuth/OIDC 인증 경계"
- generic [ref=f6e79]:
- generic [ref=f6e80]: Project
- combobox "Project" [ref=f6e81]:
- option "미지정" [selected]
- option "Backend Clean Architecture"
- option "KeyCloak Patterns"
- option "Liner N + 1문제"
- group "관계" [ref=f6e82]:
- paragraph [ref=f6e84]: 연결한 공개 기록이 없습니다.
- button "관계 추가" [ref=f6e85]
- region [ref=f6e86]:
- generic [ref=f6e87]:
- paragraph [ref=f6e88]: QUESTION
- heading "판단과 다음 검증" [level=2] [ref=f6e89]
- generic [ref=f6e90]:
- generic [ref=f6e91]: 질문 상태
- combobox "질문 상태" [ref=f6e92]:
- option "아직 정하지 않음"
- option "OPEN" [selected]
- option "RESOLVED"
- group "사실" [ref=f6e93]:
- paragraph [ref=f6e95]: 아직 입력한 항목이 없습니다.
- button "사실 추가" [ref=f6e96]
- group "가정" [ref=f6e97]:
- paragraph [ref=f6e99]: 아직 입력한 항목이 없습니다.
- button "가정 추가" [ref=f6e100]
- group "미지수" [ref=f6e101]:
- paragraph [ref=f6e103]: 아직 입력한 항목이 없습니다.
- button "미지수 추가" [ref=f6e104]
- group "제약" [ref=f6e105]:
- paragraph [ref=f6e107]: 아직 입력한 항목이 없습니다.
- button "제약 추가" [ref=f6e108]
- group "선택지" [ref=f6e109]:
- paragraph [ref=f6e111]: 아직 입력한 선택지가 없습니다.
- button "선택지 추가" [ref=f6e112]
- generic [ref=f6e113]:
- generic [ref=f6e114]: 다음 검증
- textbox "다음 검증" [ref=f6e115]
- complementary [ref=f6e116]:
- paragraph [ref=f6e117]: WORKING COPY
- heading "작업 상태" [level=2] [ref=f6e118]
- status "편집 상태" [ref=f6e119]: 저장됨
- generic [ref=f6e120]:
- generic [ref=f6e121]:
- term [ref=f6e122]: 저장 버전
- definition [ref=f6e123]: "1"
- generic [ref=f6e124]:
- term [ref=f6e125]: 종류
- definition [ref=f6e126]: QUESTION
- button "저장" [disabled] [ref=f6e127]
- navigation "게시까지의 단계" [ref=f6e128]:
- list [ref=f6e129]:
- listitem [ref=f6e130]:
- generic [ref=f6e131]: "1"
- link "검증" [ref=f6e132] [cursor=pointer]:
- /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/validation
- listitem [ref=f6e133]:
- text:
- generic [ref=f6e134]: "2"
- link "미리보기" [ref=f6e135] [cursor=pointer]:
- /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/preview
- listitem [ref=f6e136]:
- text:
- generic [ref=f6e137]: "3"
- link "게시" [ref=f6e138] [cursor=pointer]:
- /url: /studio/documents/f54170bb-e1a2-466d-9d1c-39563e03266c/publish
- paragraph [ref=f6e139]: 불완전한 초안도 저장할 수 있습니다. 게시 가능 여부는 이후 검증 단계에서 확인합니다.
- paragraph [ref=f6e50]: Question 작업본을 만들었습니다.
@@ -1,99 +0,0 @@
- generic [ref=f7e3]:
- link "본문으로 건너뛰기" [ref=f7e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f7e5]:
- generic [ref=f7e6]:
- link "TechLog Studio" [ref=f7e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f7e8]: Studio
- navigation "Studio 주 탐색" [ref=f7e10]:
- link "작업본" [ref=f7e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f7e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f7e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f7e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f7e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f7e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f7e17]
- main [ref=f7e18]:
- generic [ref=f7e19]:
- generic [ref=f7e20]:
- generic [ref=f7e21]:
- paragraph [ref=f7e22]: WORKING COPIES
- heading "작업본" [level=1] [ref=f7e23]
- paragraph [ref=f7e24]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f7e25] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f7e26]:
- search [ref=f7e27]:
- generic [ref=f7e28]: 검색
- generic [ref=f7e29]:
- searchbox "검색" [ref=f7e30]
- button "검색" [ref=f7e31]
- generic [ref=f7e32]:
- text: 종류
- combobox "종류" [ref=f7e33]:
- option "전체" [selected]
- option "Case"
- option "Reference"
- option "Question"
- option "Decision"
- generic [ref=f7e34]:
- text: 상태
- combobox "상태" [ref=f7e35]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f7e36]:
- generic [ref=f7e37]: 2개 표시 중
- generic [ref=f7e38]:
- article [ref=f7e39]:
- paragraph [ref=f7e40]: Case
- generic [ref=f7e41]:
- heading [level=2] [ref=f7e42]:
- link "게시 흐름 확인" [ref=f7e43] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit
- paragraph [ref=f7e44]: 프로젝트 미지정
- generic [ref=f7e45]:
- generic [ref=f7e46]:
- term [ref=f7e47]: 상태
- definition [ref=f7e48]: 게시 취소
- generic [ref=f7e49]:
- term [ref=f7e50]: 다음
- definition [ref=f7e51]:
- link "검증하기" [ref=f7e52] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation
- generic [ref=f7e53]:
- term [ref=f7e54]: 수정
- definition [ref=f7e55]:
- time [ref=f7e56]: 2026. 8. 21.
- button "삭제" [ref=f7e57]
- article [ref=f7e58]:
- paragraph [ref=f7e59]: Case
- generic [ref=f7e60]:
- heading [level=2] [ref=f7e61]:
- link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f7e62] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit
- paragraph [ref=f7e63]: 프로젝트 미지정
- generic [ref=f7e64]:
- generic [ref=f7e65]:
- term [ref=f7e66]: 상태
- definition [ref=f7e67]: 게시 전
- generic [ref=f7e68]:
- term [ref=f7e69]: 다음
- definition [ref=f7e70]:
- link "검증하기" [ref=f7e71] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation
- generic [ref=f7e72]:
- term [ref=f7e73]: 수정
- definition [ref=f7e74]:
- time [ref=f7e75]: 2026. 8. 21.
- button "삭제" [ref=f7e76]
- paragraph [ref=f7e77]: 작업본 제목 없음 을(를) 삭제했습니다.
@@ -1,106 +0,0 @@
- generic [ref=f8e74]:
- link "본문으로 건너뛰기" [ref=f8e75] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f8e76]:
- generic [ref=f8e77]:
- link "TechLog Studio" [ref=f8e78] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f8e79]: Studio
- navigation "Studio 주 탐색" [ref=f8e81]:
- link "작업본" [ref=f8e82] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f8e83] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f8e84] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f8e85] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f8e86] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f8e87] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f8e88]
- main [ref=f8e89]:
- generic [ref=f8e1]:
- generic [ref=f8e3]:
- paragraph [ref=f8e4]: TAXONOMY
- heading "주제와 프로젝트" [level=1] [ref=f8e5]
- paragraph [ref=f8e6]: 문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.
- region "주제 만들기" [ref=f8e7]:
- generic [ref=f8e8]:
- generic [ref=f8e9]: 새 주제
- generic [ref=f8e10]:
- textbox "새 주제" [ref=f8e11]:
- /placeholder: 주제 이름
- textbox "주제 slug" [ref=f8e12]:
- /placeholder: slug (비우면 이름에서 생성)
- button "추가" [ref=f8e13]
- generic [ref=f8e14]:
- generic [ref=f8e15]: 새 프로젝트
- generic [ref=f8e16]:
- textbox "새 프로젝트" [ref=f8e17]:
- /placeholder: 프로젝트 이름
- button "추가" [ref=f8e18]
- paragraph [ref=f8e90]: 2개의 주제
- generic [ref=f8e91]:
- article [ref=f8e92]:
- paragraph [ref=f8e93]: TOPIC
- generic [ref=f8e94]:
- heading "삭제 시험 주제" [level=2] [ref=f8e95]
- paragraph [ref=f8e96]: sakje-siheom-juje
- generic [ref=f8e98]:
- term [ref=f8e99]: 상태
- definition [ref=f8e100]: 사용 중
- button "삭제" [ref=f8e101]
- article [ref=f8e102]:
- paragraph [ref=f8e103]: TOPIC
- generic [ref=f8e104]:
- heading "OAuth/OIDC 인증 경계" [level=2] [ref=f8e105]
- paragraph [ref=f8e106]: oauth-oidc-auth-boundary
- generic [ref=f8e108]:
- term [ref=f8e109]: 상태
- definition [ref=f8e110]: 사용 중
- button "삭제" [ref=f8e111]
- paragraph [ref=f8e112]: 3개의 프로젝트
- generic [ref=f8e113]:
- article [ref=f8e114]:
- paragraph [ref=f8e115]: PROJECT
- generic [ref=f8e116]:
- heading "Liner N + 1문제" [level=2] [ref=f8e117]
- paragraph [ref=f8e118]: 목표 미지정
- generic [ref=f8e119]:
- generic [ref=f8e120]:
- term [ref=f8e121]: 단계
- definition [ref=f8e122]: RESEARCH
- generic [ref=f8e123]:
- term [ref=f8e124]: 공개
- definition [ref=f8e125]: PRIVATE
- button "삭제" [ref=f8e126]
- article [ref=f8e127]:
- paragraph [ref=f8e128]: PROJECT
- generic [ref=f8e129]:
- heading "KeyCloak Patterns" [level=2] [ref=f8e130]
- paragraph [ref=f8e131]: 목표 미지정
- generic [ref=f8e132]:
- generic [ref=f8e133]:
- term [ref=f8e134]: 단계
- definition [ref=f8e135]: RESEARCH
- generic [ref=f8e136]:
- term [ref=f8e137]: 공개
- definition [ref=f8e138]: PRIVATE
- button "삭제" [ref=f8e139]
- article [ref=f8e140]:
- paragraph [ref=f8e141]: PROJECT
- generic [ref=f8e142]:
- heading "Backend Clean Architecture" [level=2] [ref=f8e143]
- paragraph [ref=f8e144]: 목표 미지정
- generic [ref=f8e145]:
- generic [ref=f8e146]:
- term [ref=f8e147]: 단계
- definition [ref=f8e148]: RESEARCH
- generic [ref=f8e149]:
- term [ref=f8e150]: 공개
- definition [ref=f8e151]: PRIVATE
- button "삭제" [ref=f8e152]
- paragraph [ref=f8e153]: 주제 삭제 시험 주제 을(를) 만들었습니다.
@@ -1,96 +0,0 @@
- generic [ref=f8e74]:
- link "본문으로 건너뛰기" [ref=f8e75] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f8e76]:
- generic [ref=f8e77]:
- link "TechLog Studio" [ref=f8e78] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f8e79]: Studio
- navigation "Studio 주 탐색" [ref=f8e81]:
- link "작업본" [ref=f8e82] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f8e83] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f8e84] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f8e85] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f8e86] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f8e87] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f8e88]
- main [ref=f8e89]:
- generic [ref=f8e1]:
- generic [ref=f8e3]:
- paragraph [ref=f8e4]: TAXONOMY
- heading "주제와 프로젝트" [level=1] [ref=f8e5]
- paragraph [ref=f8e6]: 문서를 게시하려면 주제가 필요합니다. 여기서 만들고 정리합니다.
- region "주제 만들기" [ref=f8e7]:
- generic [ref=f8e8]:
- generic [ref=f8e9]: 새 주제
- generic [ref=f8e10]:
- textbox "새 주제" [ref=f8e11]:
- /placeholder: 주제 이름
- textbox "주제 slug" [ref=f8e12]:
- /placeholder: slug (비우면 이름에서 생성)
- button "추가" [ref=f8e13]
- generic [ref=f8e14]:
- generic [ref=f8e15]: 새 프로젝트
- generic [ref=f8e16]:
- textbox "새 프로젝트" [ref=f8e17]:
- /placeholder: 프로젝트 이름
- button "추가" [ref=f8e18]
- paragraph [ref=f8e154]: 1개의 주제
- article [ref=f8e156]:
- paragraph [ref=f8e157]: TOPIC
- generic [ref=f8e158]:
- heading "OAuth/OIDC 인증 경계" [level=2] [ref=f8e159]
- paragraph [ref=f8e160]: oauth-oidc-auth-boundary
- generic [ref=f8e162]:
- term [ref=f8e163]: 상태
- definition [ref=f8e164]: 사용 중
- button "삭제" [ref=f8e165]
- paragraph [ref=f8e166]: 3개의 프로젝트
- generic [ref=f8e167]:
- article [ref=f8e168]:
- paragraph [ref=f8e169]: PROJECT
- generic [ref=f8e170]:
- heading "Liner N + 1문제" [level=2] [ref=f8e171]
- paragraph [ref=f8e172]: 목표 미지정
- generic [ref=f8e173]:
- generic [ref=f8e174]:
- term [ref=f8e175]: 단계
- definition [ref=f8e176]: RESEARCH
- generic [ref=f8e177]:
- term [ref=f8e178]: 공개
- definition [ref=f8e179]: PRIVATE
- button "삭제" [ref=f8e180]
- article [ref=f8e181]:
- paragraph [ref=f8e182]: PROJECT
- generic [ref=f8e183]:
- heading "KeyCloak Patterns" [level=2] [ref=f8e184]
- paragraph [ref=f8e185]: 목표 미지정
- generic [ref=f8e186]:
- generic [ref=f8e187]:
- term [ref=f8e188]: 단계
- definition [ref=f8e189]: RESEARCH
- generic [ref=f8e190]:
- term [ref=f8e191]: 공개
- definition [ref=f8e192]: PRIVATE
- button "삭제" [ref=f8e193]
- article [ref=f8e194]:
- paragraph [ref=f8e195]: PROJECT
- generic [ref=f8e196]:
- heading "Backend Clean Architecture" [level=2] [ref=f8e197]
- paragraph [ref=f8e198]: 목표 미지정
- generic [ref=f8e199]:
- generic [ref=f8e200]:
- term [ref=f8e201]: 단계
- definition [ref=f8e202]: RESEARCH
- generic [ref=f8e203]:
- term [ref=f8e204]: 공개
- definition [ref=f8e205]: PRIVATE
- button "삭제" [ref=f8e206]
- paragraph [ref=f8e153]: 주제 삭제 시험 주제 을(를) 삭제했습니다.
@@ -1,100 +0,0 @@
- generic [ref=f9e3]:
- link "본문으로 건너뛰기" [ref=f9e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f9e5]:
- generic [ref=f9e6]:
- link "TechLog Studio" [ref=f9e7] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f9e8]: Studio
- navigation "Studio 주 탐색" [ref=f9e10]:
- link "작업본" [ref=f9e11] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f9e12] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f9e13] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f9e14] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f9e15] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f9e16] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f9e17]
- main [ref=f9e18]:
- generic [ref=f9e19]:
- generic [ref=f9e20]:
- generic [ref=f9e21]:
- paragraph [ref=f9e22]: WORKING COPIES
- heading "작업본" [level=1] [ref=f9e23]
- paragraph [ref=f9e24]: 세션에 있는 Case, Reference, Question, Decision을 찾고 다음 작업으로 이동합니다.
- link "새 문서" [ref=f9e25] [cursor=pointer]:
- /url: /studio/documents/new
- region "작업본 검색과 필터" [ref=f9e26]:
- search [ref=f9e27]:
- generic [ref=f9e28]: 검색
- generic [ref=f9e29]:
- searchbox "검색" [ref=f9e30]
- button "검색" [ref=f9e31]
- generic [ref=f9e32]:
- text: 종류
- combobox "종류" [ref=f9e33]:
- option "전체" [selected]
- option "Case"
- option "Reference"
- option "Question"
- option "Decision"
- generic [ref=f9e34]:
- text: 상태
- combobox "상태" [ref=f9e35]:
- option "전체" [selected]
- option "게시 전"
- option "게시 중"
- option "게시 취소"
- paragraph [ref=f9e36]:
- generic [ref=f9e37]: 2개 표시 중
- alert [ref=f9e38]: 삭제하지 못했습니다. 게시 중이거나, 이 기록을 참조하는 곳이 있거나, 다른 곳에서 먼저 수정되었을 수 있습니다.
- generic [ref=f9e39]:
- article [ref=f9e40]:
- paragraph [ref=f9e41]: Case
- generic [ref=f9e42]:
- heading [level=2] [ref=f9e43]:
- link "게시 흐름 확인" [ref=f9e44] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/edit
- paragraph [ref=f9e45]: 프로젝트 미지정
- generic [ref=f9e46]:
- generic [ref=f9e47]:
- term [ref=f9e48]: 상태
- definition [ref=f9e49]: 게시 취소
- generic [ref=f9e50]:
- term [ref=f9e51]: 다음
- definition [ref=f9e52]:
- link "검증하기" [ref=f9e53] [cursor=pointer]:
- /url: /studio/documents/b09f168b-b205-478f-b60f-83872d1b4d01/validation
- generic [ref=f9e54]:
- term [ref=f9e55]: 수정
- definition [ref=f9e56]:
- time [ref=f9e57]: 2026. 8. 21.
- button "삭제" [ref=f9e58]
- article [ref=f9e59]:
- paragraph [ref=f9e60]: Case
- generic [ref=f9e61]:
- heading [level=2] [ref=f9e62]:
- link "브라우저에서 토큰을 어디까지 다뤄야 하는가..?" [ref=f9e63] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/edit
- paragraph [ref=f9e64]: 프로젝트 미지정
- generic [ref=f9e65]:
- generic [ref=f9e66]:
- term [ref=f9e67]: 상태
- definition [ref=f9e68]: 게시 전
- generic [ref=f9e69]:
- term [ref=f9e70]: 다음
- definition [ref=f9e71]:
- link "검증하기" [ref=f9e72] [cursor=pointer]:
- /url: /studio/documents/f363edc8-2c13-4995-acda-934237034a85/validation
- generic [ref=f9e73]:
- term [ref=f9e74]: 수정
- definition [ref=f9e75]:
- time [ref=f9e76]: 2026. 8. 21.
- button "삭제" [ref=f9e77]
- paragraph [ref=f9e78]
@@ -1,50 +0,0 @@
- generic [ref=f10e19]:
- link "본문으로 건너뛰기" [ref=f10e20] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f10e21]:
- generic [ref=f10e22]:
- link "TechLog Studio" [ref=f10e23] [cursor=pointer]:
- /url: /studio
- text: TechLog
- generic [ref=f10e24]: Studio
- navigation "Studio 주 탐색" [ref=f10e26]:
- link "작업본" [ref=f10e27] [cursor=pointer]:
- /url: /studio/documents
- link "게시 기록" [ref=f10e28] [cursor=pointer]:
- /url: /studio/publications
- link "새 문서" [ref=f10e29] [cursor=pointer]:
- /url: /studio/documents/new
- link "주제·프로젝트" [ref=f10e30] [cursor=pointer]:
- /url: /studio/taxonomy
- link "릴리즈" [ref=f10e31] [cursor=pointer]:
- /url: /studio/releases
- link "공개 사이트 보기" [ref=f10e32] [cursor=pointer]:
- /url: /
- button "로그아웃" [ref=f10e33]
- main [ref=f10e34]:
- generic [ref=f10e1]:
- generic [ref=f10e2]:
- paragraph [ref=f10e3]: ASSET LIBRARY
- heading "Asset" [level=1] [ref=f10e4]
- paragraph [ref=f10e5]: 업로드한 Asset을 검색하고 사용처를 확인하며, 사용하지 않는 Asset을 정리합니다.
- region "Asset 검색 도구" [ref=f10e6]:
- search [ref=f10e7]:
- generic [ref=f10e8]: Asset 검색
- generic [ref=f10e9]:
- searchbox "Asset 검색" [ref=f10e10]
- button "검색" [ref=f10e11]
- status
- status [ref=f10e12]: 1개의 Asset
- list [ref=f10e13]:
- listitem [ref=f10e14]:
- button "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [ref=f10e15]
- text: READY
- generic [ref=f10e16]: 사용 0건
- generic "screenshot-from-2026-08-21-18-04-49-72f1f9c6 상세" [ref=f10e35]:
- heading "screenshot-from-2026-08-21-18-04-49-72f1f9c6" [active] [level=2] [ref=f10e36]
- paragraph [ref=f10e37]: READY
- paragraph [ref=f10e38]: 사용 중인 문서가 없습니다.
- generic [ref=f10e39]:
- button "삭제" [ref=f10e40]
- button "닫기" [ref=f10e41]
- paragraph [ref=f10e42]
+2 -2
View File
@@ -5,8 +5,8 @@
"registrationAllowed": false, "registrationAllowed": false,
"loginTheme": "keycloak", "loginTheme": "keycloak",
"accessTokenLifespan": 300, "accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800, "ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 36000, "ssoSessionMaxLifespan": 86400,
"roles": { "roles": {
"realm": [ "realm": [
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" } { "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
+4 -4
View File
@@ -38,12 +38,12 @@
}, },
"contractSet": { "contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1", "setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087", "setDigest": "sha256:7a4e5ace1def727943ee39ab40904b8932db7d224f9a3fc0cc8e4c350819262b",
"packages": [ "packages": [
{ {
"packageId": "@tech-log/management-contract", "packageId": "@tech-log/management-contract",
"version": "1.0.0", "version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878", "digest": "sha256:446b14486291ab89b5b42d86aea1c1c7007ac40583aa98fe50d6e741d61aa1b3",
"runtimeProtocolVersion": 1, "runtimeProtocolVersion": 1,
"sourceRevision": "b195b29" "sourceRevision": "b195b29"
}, },
@@ -56,8 +56,8 @@
}, },
{ {
"packageId": "@tech-log/studio-contract", "packageId": "@tech-log/studio-contract",
"version": "3.0.0", "version": "3.1.0",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326", "digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"runtimeProtocolVersion": 1, "runtimeProtocolVersion": 1,
"sourceRevision": "b195b29" "sourceRevision": "b195b29"
} }
@@ -1,5 +1,7 @@
import type { import type {
CreateDraftResponse, CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectEditResponse, ProjectEditResponse,
ProjectIndexPage, ProjectIndexPage,
ProjectUpdateRequest, ProjectUpdateRequest,
@@ -85,6 +87,16 @@ export function createHttpManagementGateway(
deleteRelease: async (id: string, expectedVersion: number) => { deleteRelease: async (id: string, expectedVersion: number) => {
await run<void>("deleteRelease", { id, expectedVersion }); await run<void>("deleteRelease", { id, 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) => publishRelease: (id: string, expectedVersion: number) =>
run<PublishResponse>("publishRelease", { id, expectedVersion }), run<PublishResponse>("publishRelease", { id, expectedVersion }),
archiveRelease: (id: string, expectedVersion: number) => archiveRelease: (id: string, expectedVersion: number) =>
@@ -1,5 +1,6 @@
import type { import type {
HomeFocusItem, HomeFocusItem,
LatestRecordEntry,
ProjectActivity, ProjectActivity,
ProjectDecision, ProjectDecision,
Project, Project,
@@ -320,6 +321,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[]> { async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>( const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
"getPublicHome", "getPublicHome",
@@ -463,6 +491,7 @@ export function createHttpPublicContentGateway(
getProjectDecisions, getProjectDecisions,
getProjectActivity, getProjectActivity,
getHomeFocusItems, getHomeFocusItems,
getLatestEntries,
searchPublicContent, searchPublicContent,
}); });
} }
@@ -11,6 +11,7 @@ import {
type HomeFocusItem, type HomeFocusItem,
} from "./public-content.ts"; } from "./public-content.ts";
import type { import type {
LatestRecordEntry,
PublicContentQueries, PublicContentQueries,
PublicTopic, PublicTopic,
} from "../../application/ports/public-content-queries.ts"; } from "../../application/ports/public-content-queries.ts";
@@ -119,6 +120,43 @@ export function listTopics(): PublicTopic[] {
.map((entry) => Object.freeze(entry)); .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,
};
}),
);
const releaseEntries = releases.map((release) => ({
id: `release-${release.version}`,
entryType: "RELEASE" as const,
title: release.title,
summary: release.summary,
path: release.path,
publishedAt: release.publishedAt,
topic: "TechLog",
project: "TechLog",
}));
return [...activities, ...releaseEntries].sort((left, right) =>
right.publishedAt.localeCompare(left.publishedAt),
);
}
export function getHomeFocusItems(): HomeFocusItem[] { export function getHomeFocusItems(): HomeFocusItem[] {
const project = getProject("backend-skeleton"); const project = getProject("backend-skeleton");
const question = getRecord("QUESTION", "validate-edge-token-again"); const question = getRecord("QUESTION", "validate-edge-token-again");
@@ -264,6 +302,9 @@ export const publicContentQueries = Object.freeze({
async listTopics() { async listTopics() {
return listTopics(); return listTopics();
}, },
async getLatestEntries() {
return getLatestEntries();
},
async getHomeFocusItems() { async getHomeFocusItems() {
return getHomeFocusItems(); return getHomeFocusItems();
}, },
@@ -1,5 +1,7 @@
import type { import type {
CreateDraftResponse, CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectEditResponse, ProjectEditResponse,
ProjectIndexPage, ProjectIndexPage,
ProjectUpdateRequest, ProjectUpdateRequest,
@@ -27,6 +29,21 @@ export type ManagementGateway = Readonly<{
createProject(title: string): Promise<CreateDraftResponse>; createProject(title: string): Promise<CreateDraftResponse>;
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>; updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
deleteProject(id: string, expectedVersion: number): Promise<void>; 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>;
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
getHomeFocus(): Promise<HomeFocusResponse>;
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>; listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
getRelease(id: string): Promise<ReleaseEditResponse>; getRelease(id: string): Promise<ReleaseEditResponse>;
createRelease(title: string): Promise<CreateDraftResponse>; createRelease(title: string): Promise<CreateDraftResponse>;
@@ -164,6 +164,24 @@ export type PublicTopic = {
recordCount: number; recordCount: number;
}; };
/**
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
*
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
*/
export type LatestRecordEntry = {
id: string;
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
title: string;
summary: string;
path: string;
publishedAt: string;
topic: string;
project: string;
};
export type FocusKey = "current" | "question" | "decision"; export type FocusKey = "current" | "question" | "decision";
export type HomeFocusItem = { export type HomeFocusItem = {
@@ -227,6 +245,7 @@ export type PublicContentQueries = Readonly<{
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다. * 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
*/ */
listTopics(): Promise<PublicTopic[]>; listTopics(): Promise<PublicTopic[]>;
getLatestEntries(): Promise<LatestRecordEntry[]>;
getHomeFocusItems(): Promise<HomeFocusItem[]>; getHomeFocusItems(): Promise<HomeFocusItem[]>;
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>; searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
}>; }>;
@@ -1,7 +1,7 @@
{ {
"packageId": "@tech-log/management-contract", "packageId": "@tech-log/management-contract",
"version": "1.0.0", "version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878", "digest": "sha256:446b14486291ab89b5b42d86aea1c1c7007ac40583aa98fe50d6e741d61aa1b3",
"sourceRevision": "b195b29", "sourceRevision": "b195b29",
"operationIds": [ "operationIds": [
"createCaseDraft", "createCaseDraft",
@@ -15,3 +15,5 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"]; export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"]; export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
export type PublishResponse = Schemas["PublishResponse"]; export type PublishResponse = Schemas["PublishResponse"];
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
@@ -963,6 +963,12 @@ export interface components {
data: components["schemas"]["ReleaseEditResponse"]; data: components["schemas"]["ReleaseEditResponse"];
meta: components["schemas"]["ResponseMeta"]; meta: components["schemas"]["ResponseMeta"];
}; };
HomeFocusResponseEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["HomeFocusResponse"];
meta: components["schemas"]["ResponseMeta"];
};
PublishResponseEnvelope: { PublishResponseEnvelope: {
/** @constant */ /** @constant */
success: true; success: true;
@@ -5519,7 +5525,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["PublishResponse"]; "application/json": components["schemas"]["PublishResponseEnvelope"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -5528,7 +5534,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -5537,7 +5543,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description Forbidden */
@@ -5546,7 +5552,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Not Found */ /** @description Not Found */
@@ -5555,7 +5561,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Conflict */ /** @description Conflict */
@@ -5564,7 +5570,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unprocessable Content */ /** @description Unprocessable Content */
@@ -5573,7 +5579,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Internal Server Error */ /** @description Internal Server Error */
@@ -5582,7 +5588,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -5610,7 +5616,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["ProjectEditResponse"]; "application/json": components["schemas"]["ProjectEditResponseEnvelope"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -5619,7 +5625,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -5628,7 +5634,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description Forbidden */
@@ -5637,7 +5643,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Not Found */ /** @description Not Found */
@@ -5646,7 +5652,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Conflict */ /** @description Conflict */
@@ -5655,7 +5661,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unprocessable Content */ /** @description Unprocessable Content */
@@ -5664,7 +5670,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Internal Server Error */ /** @description Internal Server Error */
@@ -5673,7 +5679,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -8117,7 +8123,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["HomeFocusResponse"]; "application/json": components["schemas"]["HomeFocusResponseEnvelope"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -8126,7 +8132,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -8135,7 +8141,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description Forbidden */
@@ -8144,7 +8150,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Not Found */ /** @description Not Found */
@@ -8153,7 +8159,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Internal Server Error */ /** @description Internal Server Error */
@@ -8162,7 +8168,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -8188,7 +8194,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["HomeFocusResponse"]; "application/json": components["schemas"]["HomeFocusResponseEnvelope"];
}; };
}; };
/** @description Bad Request */ /** @description Bad Request */
@@ -8197,7 +8203,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -8206,7 +8212,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Forbidden */ /** @description Forbidden */
@@ -8215,7 +8221,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Not Found */ /** @description Not Found */
@@ -8224,7 +8230,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Conflict */ /** @description Conflict */
@@ -8233,7 +8239,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Unprocessable Content */ /** @description Unprocessable Content */
@@ -8242,7 +8248,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
/** @description Internal Server Error */ /** @description Internal Server Error */
@@ -8251,7 +8257,7 @@ export interface operations {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/problem+json": components["schemas"]["ProblemDetails"]; "application/json": components["schemas"]["ErrorEnvelope"];
}; };
}; };
}; };
@@ -3015,49 +3015,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PublishResponse' $ref: '#/components/schemas/PublishResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -3085,49 +3085,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ProjectEditResponse' $ref: '#/components/schemas/ProjectEditResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -4985,37 +4985,37 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HomeFocusResponse' $ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
security: security:
- sessionCookie: [] - sessionCookie: []
put: put:
@@ -5030,49 +5030,49 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HomeFocusResponse' $ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400': '400':
description: Bad Request description: Bad Request
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'401': '401':
description: Unauthorized description: Unauthorized
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'403': '403':
description: Forbidden description: Forbidden
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'404': '404':
description: Not Found description: Not Found
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'409': '409':
description: Conflict description: Conflict
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'422': '422':
description: Unprocessable Content description: Unprocessable Content
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
'500': '500':
description: Internal Server Error description: Internal Server Error
content: content:
application/problem+json: application/json:
schema: schema:
$ref: '#/components/schemas/ProblemDetails' $ref: '#/components/schemas/ErrorEnvelope'
requestBody: requestBody:
required: true required: true
content: content:
@@ -5510,6 +5510,21 @@ components:
$ref: '#/components/schemas/ReleaseEditResponse' $ref: '#/components/schemas/ReleaseEditResponse'
meta: meta:
$ref: '#/components/schemas/ResponseMeta' $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'
PublishResponseEnvelope: PublishResponseEnvelope:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -1,7 +1,7 @@
{ {
"packageId": "@tech-log/studio-contract", "packageId": "@tech-log/studio-contract",
"version": "3.0.0", "version": "3.1.0",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326", "digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"sourceRevision": "b195b29", "sourceRevision": "b195b29",
"operationIds": [ "operationIds": [
"getStudioSession", "getStudioSession",
@@ -1074,7 +1074,32 @@ export interface components {
height: number | null; height: number | null;
decorative: boolean; 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"] & { CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @enum {string} */ /** @enum {string} */
kind: "CASE"; kind: "CASE";
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: Tech Log Studio API title: Tech Log Studio API
version: 3.0.0 version: 3.1.0
description: | description: |
Tech Log Studio orchestration 계약이다. Tech Log Studio orchestration 계약이다.
@@ -1321,7 +1321,10 @@ components:
properties: properties:
type: { type: string, enum: [HEADING] } type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 } 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" } } content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ParagraphBlock: ParagraphBlock:
type: object type: object
@@ -1452,6 +1455,29 @@ components:
width: { type: [integer, "null"], minimum: 1 } width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 } height: { type: [integer, "null"], minimum: 1 }
decorative: { type: boolean } 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: CaseRenderBlock:
oneOf: oneOf:
- { $ref: "#/components/schemas/HeadingBlock" } - { $ref: "#/components/schemas/HeadingBlock" }
@@ -1463,6 +1489,8 @@ components:
- { $ref: "#/components/schemas/DataTableBlock" } - { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" } - { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" } - { $ref: "#/components/schemas/EvidenceFigureBlock" }
- { $ref: "#/components/schemas/ThematicBreakBlock" }
- { $ref: "#/components/schemas/ImageBlock" }
discriminator: discriminator:
propertyName: type propertyName: type
mapping: mapping:
@@ -1475,6 +1503,8 @@ components:
DATA_TABLE: "#/components/schemas/DataTableBlock" DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock" CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock" EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
IMAGE: "#/components/schemas/ImageBlock"
CasePublicRenderModel: CasePublicRenderModel:
unevaluatedProperties: false unevaluatedProperties: false
allOf: allOf:
@@ -310,6 +310,47 @@ const HTTP_CONTRACTS = Object.freeze([
}); });
}, },
), ),
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( writeOperation(
"publishRelease", "publishRelease",
"POST", "POST",
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
return line; 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( return line.replace(
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i, /^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
( (
_match, _match,
marker: string, marker: string,
attributes: string, attributes: string,
newline: string, newline: string,
) => `${marker}{${attributes.trim()}}${newline}`, ) => {
const trimmed = attributes.trim();
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
},
); );
}) })
.join(""); .join("");
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
const trustedRelativeLinkOrigin = "https://techlog.invalid"; const trustedRelativeLinkOrigin = "https://techlog.invalid";
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
note: "참고",
tip: "도움말",
warning: "주의",
danger: "위험",
};
function hasAsciiControlCharacter(value: string): boolean { function hasAsciiControlCharacter(value: string): boolean {
return Array.from(value).some((character) => { return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0; const codePoint = character.codePointAt(0) ?? 0;
@@ -326,8 +347,8 @@ function headingBlock(
node: Heading, node: Heading,
usedIds: Set<string>, usedIds: Set<string>,
): components["schemas"]["HeadingBlock"] { ): components["schemas"]["HeadingBlock"] {
if (node.depth < 2 || node.depth > 4) { if (node.depth < 1 || node.depth > 6) {
invalid(node, "only heading levels 2 through 4 are supported"); invalid(node, "only heading levels 1 through 6 are supported");
} }
const children = [...node.children]; const children = [...node.children];
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
return inlineFromNodes(cell.children); 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( function tableBlock(
node: ContainerDirective, node: ContainerDirective | Table,
usedIds: Set<string>, usedIds: Set<string>,
bareIndex?: number,
): components["schemas"]["DataTableBlock"] { ): components["schemas"]["DataTableBlock"] {
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]); const bare = node.type === "table";
if (node.children.length !== 1 || node.children[0].type !== "table") { const attributes = bare
invalid(node, "table directive must contain exactly one GFM table"); ? 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"); if (table.children.length === 0) invalid(table, "table header is required");
const id = attributes.id; const id = attributes.id;
@@ -506,6 +546,25 @@ function directiveBlock(
content: inlineFromNodes((node.children[0] as Paragraph).children), 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": { case "evidence": {
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]); const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
if (node.children.length !== 0) { 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( function paragraphBlock(
node: Paragraph, node: Paragraph,
): components["schemas"]["ParagraphBlock"] { ): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) }; return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
} }
export function parseCaseContent(source: string): CaseAuthoringBlock[] { export function parseCaseContent(source: string): CaseAuthoringBlock[] {
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
listItemCount += 1; listItemCount += 1;
return `list-item-${listItemCount}`; return `list-item-${listItemCount}`;
}; };
let bareTableCount = 0;
return tree.children.map((node: Content): CaseAuthoringBlock => { return tree.children.map((node: Content): CaseAuthoringBlock => {
switch (node.type) { switch (node.type) {
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
return codeBlock(node); return codeBlock(node);
case "containerDirective": case "containerDirective":
return directiveBlock(node, usedIds); return directiveBlock(node, usedIds);
case "html": case "table": {
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
// 공개 화면에서 다르게 읽힌다.
bareTableCount += 1;
return tableBlock(node, usedIds, bareTableCount);
}
case "thematicBreak": case "thematicBreak":
return { type: "THEMATIC_BREAK" };
case "html":
case "definition": case "definition":
case "yaml": case "yaml":
case "table":
case "footnoteDefinition": case "footnoteDefinition":
case "leafDirective": case "leafDirective":
return invalid(node, `unsupported block syntax: ${node.type}`); return invalid(node, `unsupported block syntax: ${node.type}`);
@@ -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))}`, `:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
":::", ":::",
].join("\n"); ].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: default:
return assertNever(block); return assertNever(block);
} }
@@ -39,56 +39,37 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined; return typeof value === "string" ? value : undefined;
} }
/**
* 서버가 고른 최근 기록에 릴리스를 얹는다.
*
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
*
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
* 시간순으로 합친다.
*/
async function getLatestEntries( async function getLatestEntries(
publicContent: PublicContentQueries, publicContent: PublicContentQueries,
): Promise<LatestEntry[]> { ): Promise<LatestEntry[]> {
const publicRecords = await publicContent.listRecords(); const [records, searchableEntities] = await Promise.all([
const publicRecordByPath = new Map( publicContent.getLatestEntries(),
publicRecords.map((record) => [record.path, record]), publicContent.searchPublicContent(""),
); ]);
const searchableEntities = await publicContent.searchPublicContent("");
const projectPrefix = "/projects/"; const recordTimeline: LatestEntry[] = records.map((entry) => ({
const projectSlugs = searchableEntities id: entry.id,
.filter((entity) => entity.contentType === "PROJECT") typeLabel: entry.entryType === "PROJECT_ACTIVITY" ? "PROJECT ACTIVITY" : entry.entryType,
.flatMap((entity) => title: entry.title,
entity.path.startsWith(projectPrefix) summary: entry.summary,
? [decodeURIComponent(entity.path.slice(projectPrefix.length))] date: dateLabel(entry.publishedAt),
: [], dateTime: entry.publishedAt,
); topic: entry.topic,
// One project at a time would serialise a request per project; issuing them project: entry.project,
// together keeps the timeline's cost at its slowest project rather than their path: entry.path,
// 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 releaseTimeline = ( const releaseTimeline = (
await Promise.all( await Promise.all(
searchableEntities searchableEntities
@@ -102,14 +83,14 @@ async function getLatestEntries(
if (!release) return []; if (!release) return [];
return [ return [
{ {
id: `release-${release.version}`, id: `release-${release.version}`,
typeLabel: "RELEASE", typeLabel: "RELEASE",
title: release.title, title: release.title,
summary: release.summary, summary: release.summary,
date: release.publishedLabel, date: release.publishedLabel,
dateTime: release.publishedAt, dateTime: release.publishedAt,
topic: "TechLog", topic: "TechLog",
project: "TechLog", project: "TechLog",
path: release.path, path: release.path,
}, },
]; ];
@@ -117,11 +98,20 @@ async function getLatestEntries(
) )
).flat(); ).flat();
return [...projectTimeline, ...releaseTimeline].sort((left, right) => return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime), 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() { export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">(); const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus); const requestedKey = optionalString(search.focus);
@@ -99,6 +99,19 @@ function renderBlock(
resolveEvidenceAsset={resolveEvidenceAsset} 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: default:
return assertNever(block); return assertNever(block);
} }
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import type { CatalogEntry } from "../../../contracts/studio/contract.ts";
import type { HomeFocusResponse } from "../../../contracts/management/contract.ts";
import type { ProjectIndexItem } from "../../../contracts/management/contract.ts";
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
import { useStudio } from "../use-studio.ts";
/**
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
*
* <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;
void Promise.all([
managementGateway.getHomeFocus(),
managementGateway.listProjects(0, 50),
gateway.getCatalog({ type: "RELATION", limit: 100 }),
]).then(
([current, projectPage, catalog]) => {
if (cancelled) return;
setFocus(current);
setProjects(projectPage.items ?? []);
setRelations(catalog.items ?? []);
setProjectId(current.currentProjectId ?? "");
setQuestionId(current.openQuestionId ?? "");
setDecisionId(current.recentDecisionId ?? "");
setError("");
},
() => {
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-work-section" aria-labelledby="studio-home-focus-title">
<div className="studio-section-title">
<h2 id="studio-home-focus-title"> </h2>
</div>
{error ? (
<p className="studio-screen-error" role="alert">
{error}
</p>
) : null}
{!focus && !error ? (
<p className="studio-loading" role="status">
.
</p>
) : null}
{focus ? (
<form className="studio-home-focus-form" onSubmit={save}>
<p className="studio-empty-inline">
&ldquo; &rdquo; .
.
</p>
<div className="studio-field">
<label htmlFor="home-focus-project"> ()</label>
<select
className="studio-control"
id="home-focus-project"
value={projectId}
onChange={(event) => setProjectId(event.target.value)}
>
<option value=""> </option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
{project.targetVisibility === "PRIVATE" ? " (비공개)" : ""}
</option>
))}
</select>
{projectHidden ? (
<p className="studio-field-note">
. · .
</p>
) : null}
</div>
<div className="studio-field">
<label htmlFor="home-focus-question"> </label>
<select
className="studio-control"
id="home-focus-question"
value={questionId}
onChange={(event) => setQuestionId(event.target.value)}
>
<option value=""> </option>
{questions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
{questions.length === 0 ? (
<p className="studio-field-note"> Question .</p>
) : null}
</div>
<div className="studio-field">
<label htmlFor="home-focus-decision"> </label>
<select
className="studio-control"
id="home-focus-decision"
value={decisionId}
onChange={(event) => setDecisionId(event.target.value)}
>
<option value=""> </option>
{decisions.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.label}
</option>
))}
</select>
{decisions.length === 0 ? (
<p className="studio-field-note"> Decision .</p>
) : null}
</div>
{nothingChosen ? (
<p className="studio-field-note">
.
</p>
) : null}
<div className="studio-row-actions">
<button className="studio-primary-button" type="submit" disabled={pending}>
{pending ? "저장하는 중" : "홈 설정 저장"}
</button>
</div>
</form>
) : null}
</section>
);
}
@@ -158,26 +158,28 @@ export function ReleaseManager() {
} }
}; };
const requestOf = (current: NonNullable<typeof 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 () => { const save = async () => {
if (pending || draft === null || selectedId === null) return; if (pending || draft === null || selectedId === null) return;
setPending(true); setPending(true);
setError(""); setError("");
try { try {
const body: ReleaseUpdateRequest = { const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
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)); setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`); setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload(); reload();
@@ -193,14 +195,24 @@ export function ReleaseManager() {
setPending(true); setPending(true);
setError(""); setError("");
try { try {
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion); /*
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
*/
const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
setDraft(toDraft(saved));
const published = await managementGateway.publishRelease(selectedId, saved.version);
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`); setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
reload(); reload();
} catch { } catch (error) {
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로 // 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다. // 그러면 이미 채운 칸까지 비었다고 말하게 된다.
setError( setError(
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.", managementFailureMessage(
error,
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
),
); );
} finally { } finally {
setPending(false); setPending(false);
@@ -341,6 +353,16 @@ export function ReleaseManager() {
placeholder="0.1.0" placeholder="0.1.0"
onChange={(event) => update({ versionLabel: event.currentTarget.value })} 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>
<label className="studio-field"> <label className="studio-field">
<span></span> <span></span>
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
import type { components } from "../../../contracts/studio/generated.ts"; import type { components } from "../../../contracts/studio/generated.ts";
import { useStudio } from "../use-studio.ts"; import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx"; import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { HomeFocusEditor } from "./home-focus-editor.tsx";
type DocumentSummary = components["schemas"]["DocumentSummary"]; type DocumentSummary = components["schemas"]["DocumentSummary"];
@@ -158,6 +159,7 @@ export function StudioDashboard() {
href="/studio/documents" href="/studio/documents"
empty="게시 준비가 끝난 문서가 없습니다." empty="게시 준비가 끝난 문서가 없습니다."
/> />
<HomeFocusEditor />
<section className="studio-work-section"> <section className="studio-work-section">
<div className="studio-section-title"> <div className="studio-section-title">
<h2> </h2> <h2> </h2>
@@ -159,6 +159,37 @@ export function TaxonomyManager() {
} }
}; };
/*
프로젝트 게시는 문서 게시와 별개다. 문서를 게시해도 그 문서가 속한 프로젝트는 비공개로 남고,
공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈의 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);
}
};
return ( return (
<div className="studio-page studio-documents-page"> <div className="studio-page studio-documents-page">
<header className="studio-page-top"> <header className="studio-page-top">
@@ -279,17 +310,29 @@ export function TaxonomyManager() {
</div> </div>
<div> <div>
<dt></dt> <dt></dt>
<dd>{project.targetVisibility}</dd> <dd>
{project.targetVisibility === "PRIVATE" ? "비공개" : "공개됨"}
</dd>
</div> </div>
</dl> </dl>
<button <div className="studio-row-actions">
className="studio-secondary-button" <button
type="button" className="studio-primary-button"
disabled={pending} type="button"
onClick={() => void removeProject(project)} disabled={pending}
> onClick={() => void togglePublish(project)}
>
</button> {project.targetVisibility === "PRIVATE" ? "게시하기" : "게시 취소"}
</button>
<button
className="studio-secondary-button"
type="button"
disabled={pending}
onClick={() => void removeProject(project)}
>
</button>
</div>
</article> </article>
))} ))}
</div> </div>
@@ -17,7 +17,12 @@
--warning-soft: #fff7ed; --warning-soft: #fff7ed;
--code-canvas: #15181d; --code-canvas: #15181d;
--shell: 1180px; --shell: 1180px;
--body-copy: 42rem; /*
읽는 단의 폭. 본문·코드블록·표·콜아웃·그림이 모두 이 값을 쓰므로 한 곳만 바꾸면 단 전체가 함께
움직인다. 42rem(672px)은 영문 기준 측정값이었고, 한글 본문에서는 좁아 보이는 데다 바로 위의
유형/프로젝트/게시 줄이 shell 전체(1180px)를 쓰고 있어 대비가 더 심했다.
*/
--body-copy: 56rem;
} }
* { * {
@@ -1329,11 +1334,18 @@ dialog::backdrop {
text-align: center; text-align: center;
} }
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는 좌상단에
붙는다 — .search-dialog 와 Studio 의 .studio-unsaved-dialog 가 같은 이유로 position/inset/
margin 을 명시한다. 여기만 빠져 있어 "크게 보기" 가 왼쪽 위에 열렸다. */
.figure-dialog { .figure-dialog {
position: fixed;
inset: 0;
margin: auto;
width: min(1180px, calc(100% - 48px)); width: min(1180px, calc(100% - 48px));
max-width: none; max-width: none;
max-height: calc(100dvh - 48px);
padding: 0; padding: 0;
overflow: hidden; overflow: auto;
border: 1px solid var(--line-strong); border: 1px solid var(--line-strong);
border-radius: 9px; border-radius: 9px;
background: var(--paper); background: var(--paper);
@@ -2174,6 +2186,20 @@ dialog::backdrop {
max-width: 920px; 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-page-header h1,
.public-document-header h1 { .public-document-header h1 {
margin: 15px 0 18px; margin: 15px 0 18px;
@@ -2297,7 +2323,7 @@ dialog::backdrop {
.reference-purpose, .reference-purpose,
.document-relations { .document-relations {
width: min(100%, var(--body-copy)); 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 > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); }
.public-document-body h2, .public-document-body h2,
@@ -2613,3 +2639,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, .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); } .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;
}
@@ -107,6 +107,17 @@
.studio-app .studio-document-row dt { color: var(--muted); font-size: 11px; } .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-document-row dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.studio-app .studio-secondary-button { margin-top: 24px; } .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; }
/* 홈 focus 설정. 편집기의 `.studio-field` 를 그대로 쓰되 한 단으로 세운다. */
.studio-app .studio-home-focus-form { display: grid; gap: 4px; max-width: 560px; margin-top: 8px; }
.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 { 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 h2 { margin: 0; font-size: 25px; }
.studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); } .studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); }
+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", () => { it("rejects unsafe, raw HTML, and unsupported input with source positions", () => {
const rejected = [ const rejected = [
"<script>alert(1)</script>", "<script>alert(1)</script>",
@@ -74,11 +104,12 @@ describe("Content Format v1", () => {
"[x](</safe\u001fpath>)", "[x](</safe\u001fpath>)",
"[x](</safe\u007fpath>)", "[x](</safe\u007fpath>)",
"- outer\n - nested", "- outer\n - nested",
"# level one",
"- [ ] task", "- [ ] task",
"> > nested", "> > nested",
':::unknown key="value"\ntext\n:::', ':::unknown key="value"\ntext\n:::',
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\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) { 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", () => { test("canonical source records the pinned revision and version", () => {
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract"); assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
assert.equal(canonicalSource.version, "3.0.0"); assert.equal(canonicalSource.version, "3.1.0");
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로 // revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다. // 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/); assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
+40 -3
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 // shorter than one `systemctl show`, so the tree has to be sampled, not
// sampled once at whatever moment the assertions happen to arrive. // sampled once at whatever moment the assertions happen to arrive.
const tree = recordProviderProcessTree(execution.child.pid!); 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); const properties = showProviderUnit(unit);
tree.sample();
expect(properties).toMatchObject({ expect(properties).toMatchObject({
ActiveState: "active", ActiveState: "active",
CPUQuotaPerSecUSec: "1s", CPUQuotaPerSecUSec: "1s",
@@ -827,7 +829,22 @@ describe("candidate archive and provider upload boundaries", () => {
const supervisorArguments = showProcessArguments(execution.child.pid); const supervisorArguments = showProcessArguments(execution.child.pid);
expect(supervisorArguments).not.toMatch(new RegExp(`${command}|${credential}`, "u")); expect(supervisorArguments).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
const reportIdentity = await lstat(fixture.reportPath); 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(); tree.stop();
expect(result.code, result.stderr).toBe(0); expect(result.code, result.stderr).toBe(0);
expect(result.stdout).not.toContain(credential); expect(result.stdout).not.toContain(credential);
@@ -849,7 +866,16 @@ describe("candidate archive and provider upload boundaries", () => {
expect(observedArguments.join("\n")).not.toContain(credential); 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("/usr/bin/systemd-run"))).toHaveLength(1);
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).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( expect(
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length, processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`, `sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
@@ -1722,11 +1748,15 @@ function providerSupervisorEnvironment(
async function waitForProviderUnit( async function waitForProviderUnit(
kind: "vulnerability" | "provenance", kind: "vulnerability" | "provenance",
supervisorPid: number | undefined, supervisorPid: number | undefined,
/** 블로킹 조회 앞뒤로 부른다 — 그 사이에만 살아 있는 프로세스를 놓치지 않기 위한 것이다. */
onPoll?: () => void,
): Promise<string> { ): Promise<string> {
if (!supervisorPid) throw new Error("provider supervisor did not expose its PID"); if (!supervisorPid) throw new Error("provider supervisor did not expose its PID");
const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`; const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`;
for (let attempt = 0; attempt < 120; attempt += 1) { for (let attempt = 0; attempt < 120; attempt += 1) {
onPoll?.();
const units = listProviderUnits(pattern); const units = listProviderUnits(pattern);
onPoll?.();
if (units.length === 1) return units[0]!; if (units.length === 1) return units[0]!;
if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`); if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`);
await delay(25); await delay(25);
@@ -2025,6 +2055,13 @@ function recordProviderProcessTree(supervisorPid: number) {
timer.unref(); timer.unref();
sample(); sample();
return Object.freeze({ return Object.freeze({
/*
. `systemctl` `spawnSync` ,
5ms bubblewrap ,
.
.
*/
sample,
stop() { stop() {
stopped = true; stopped = true;
clearInterval(timer); clearInterval(timer);