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
# source: committing one would nest a second working copy inside this one.
.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,
"loginTheme": "keycloak",
"accessTokenLifespan": 300,
"ssoSessionIdleTimeout": 1800,
"ssoSessionMaxLifespan": 36000,
"ssoSessionIdleTimeout": 28800,
"ssoSessionMaxLifespan": 86400,
"roles": {
"realm": [
{ "name": "studio-author", "description": "Tech Log Studio 편집 권한 (studio:read + studio:write)" }
+4 -4
View File
@@ -38,12 +38,12 @@
},
"contractSet": {
"setAlgorithm": "CA_CONTRACT_SET_V1",
"setDigest": "sha256:7ee35548d2a84b744f8c17c7b785a79b328dcf756f5c9df8d77d37ff1535b087",
"setDigest": "sha256:7a4e5ace1def727943ee39ab40904b8932db7d224f9a3fc0cc8e4c350819262b",
"packages": [
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
"digest": "sha256:446b14486291ab89b5b42d86aea1c1c7007ac40583aa98fe50d6e741d61aa1b3",
"runtimeProtocolVersion": 1,
"sourceRevision": "b195b29"
},
@@ -56,8 +56,8 @@
},
{
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"runtimeProtocolVersion": 1,
"sourceRevision": "b195b29"
}
@@ -1,5 +1,7 @@
import type {
CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
@@ -85,6 +87,16 @@ export function createHttpManagementGateway(
deleteRelease: async (id: string, expectedVersion: number) => {
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) =>
run<PublishResponse>("publishRelease", { id, expectedVersion }),
archiveRelease: (id: string, expectedVersion: number) =>
@@ -1,5 +1,6 @@
import type {
HomeFocusItem,
LatestRecordEntry,
ProjectActivity,
ProjectDecision,
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[]> {
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
"getPublicHome",
@@ -463,6 +491,7 @@ export function createHttpPublicContentGateway(
getProjectDecisions,
getProjectActivity,
getHomeFocusItems,
getLatestEntries,
searchPublicContent,
});
}
@@ -11,6 +11,7 @@ import {
type HomeFocusItem,
} from "./public-content.ts";
import type {
LatestRecordEntry,
PublicContentQueries,
PublicTopic,
} from "../../application/ports/public-content-queries.ts";
@@ -119,6 +120,43 @@ export function listTopics(): PublicTopic[] {
.map((entry) => Object.freeze(entry));
}
/**
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
* 프로젝트 활동과 릴리스가 원천이다.
*/
export function getLatestEntries(): LatestRecordEntry[] {
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
const activities = projects.flatMap((project) =>
project.activity.map((activity) => {
const record = recordByPath.get(activity.recordPath ?? activity.path);
const published = activity.type === "PUBLICATION" && record;
return {
id: activity.id,
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
title: published ? record.title : activity.title,
summary: activity.summary,
path: activity.path,
publishedAt: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
};
}),
);
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[] {
const project = getProject("backend-skeleton");
const question = getRecord("QUESTION", "validate-edge-token-again");
@@ -264,6 +302,9 @@ export const publicContentQueries = Object.freeze({
async listTopics() {
return listTopics();
},
async getLatestEntries() {
return getLatestEntries();
},
async getHomeFocusItems() {
return getHomeFocusItems();
},
@@ -1,5 +1,7 @@
import type {
CreateDraftResponse,
HomeFocusRequest,
HomeFocusResponse,
ProjectEditResponse,
ProjectIndexPage,
ProjectUpdateRequest,
@@ -27,6 +29,21 @@ export type ManagementGateway = Readonly<{
createProject(title: string): Promise<CreateDraftResponse>;
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
deleteProject(id: string, expectedVersion: number): Promise<void>;
/**
* 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를
* 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의
* "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도
* 나타나지 않는다.
*/
publishProject(
id: string,
expectedVersion: number,
visibility?: "PUBLIC" | "UNLISTED",
): Promise<PublishResponse>;
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
getHomeFocus(): Promise<HomeFocusResponse>;
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
getRelease(id: string): Promise<ReleaseEditResponse>;
createRelease(title: string): Promise<CreateDraftResponse>;
@@ -164,6 +164,24 @@ export type PublicTopic = {
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 HomeFocusItem = {
@@ -227,6 +245,7 @@ export type PublicContentQueries = Readonly<{
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
*/
listTopics(): Promise<PublicTopic[]>;
getLatestEntries(): Promise<LatestRecordEntry[]>;
getHomeFocusItems(): Promise<HomeFocusItem[]>;
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
}>;
@@ -1,7 +1,7 @@
{
"packageId": "@tech-log/management-contract",
"version": "1.0.0",
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
"digest": "sha256:446b14486291ab89b5b42d86aea1c1c7007ac40583aa98fe50d6e741d61aa1b3",
"sourceRevision": "b195b29",
"operationIds": [
"createCaseDraft",
@@ -15,3 +15,5 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
export type PublishResponse = Schemas["PublishResponse"];
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
@@ -963,6 +963,12 @@ export interface components {
data: components["schemas"]["ReleaseEditResponse"];
meta: components["schemas"]["ResponseMeta"];
};
HomeFocusResponseEnvelope: {
/** @constant */
success: true;
data: components["schemas"]["HomeFocusResponse"];
meta: components["schemas"]["ResponseMeta"];
};
PublishResponseEnvelope: {
/** @constant */
success: true;
@@ -5519,7 +5525,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PublishResponse"];
"application/json": components["schemas"]["PublishResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -5528,7 +5534,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -5537,7 +5543,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -5546,7 +5552,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -5555,7 +5561,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -5564,7 +5570,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -5573,7 +5579,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -5582,7 +5588,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -5610,7 +5616,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ProjectEditResponse"];
"application/json": components["schemas"]["ProjectEditResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -5619,7 +5625,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -5628,7 +5634,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -5637,7 +5643,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -5646,7 +5652,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -5655,7 +5661,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -5664,7 +5670,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -5673,7 +5679,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8117,7 +8123,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HomeFocusResponse"];
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -8126,7 +8132,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -8135,7 +8141,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -8144,7 +8150,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -8153,7 +8159,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -8162,7 +8168,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -8188,7 +8194,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HomeFocusResponse"];
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
};
};
/** @description Bad Request */
@@ -8197,7 +8203,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unauthorized */
@@ -8206,7 +8212,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Forbidden */
@@ -8215,7 +8221,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Not Found */
@@ -8224,7 +8230,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Conflict */
@@ -8233,7 +8239,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Unprocessable Content */
@@ -8242,7 +8248,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Internal Server Error */
@@ -8251,7 +8257,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDetails"];
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
@@ -3015,49 +3015,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/PublishResponse'
$ref: '#/components/schemas/PublishResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -3085,49 +3085,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ProjectEditResponse'
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -4985,37 +4985,37 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/HomeFocusResponse'
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
security:
- sessionCookie: []
put:
@@ -5030,49 +5030,49 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/HomeFocusResponse'
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
'400':
description: Bad Request
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'401':
description: Unauthorized
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'403':
description: Forbidden
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'404':
description: Not Found
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'409':
description: Conflict
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'422':
description: Unprocessable Content
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
'500':
description: Internal Server Error
content:
application/problem+json:
application/json:
schema:
$ref: '#/components/schemas/ProblemDetails'
$ref: '#/components/schemas/ErrorEnvelope'
requestBody:
required: true
content:
@@ -5510,6 +5510,21 @@ components:
$ref: '#/components/schemas/ReleaseEditResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
HomeFocusResponseEnvelope:
type: object
additionalProperties: false
required:
- success
- data
- meta
properties:
success:
type: boolean
const: true
data:
$ref: '#/components/schemas/HomeFocusResponse'
meta:
$ref: '#/components/schemas/ResponseMeta'
PublishResponseEnvelope:
type: object
additionalProperties: false
@@ -1,7 +1,7 @@
{
"packageId": "@tech-log/studio-contract",
"version": "3.0.0",
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
"version": "3.1.0",
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
"sourceRevision": "b195b29",
"operationIds": [
"getStudioSession",
@@ -1074,7 +1074,32 @@ export interface components {
height: number | null;
decorative: boolean;
};
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"];
/** @description `---` . `type` .
* */
ThematicBreakBlock: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "THEMATIC_BREAK";
};
/** @description `![alt](/media/...)` .
*
* `EvidenceFigureBlock` . evidence assetKey
* , .
* `javascript:` .
* */
ImageBlock: {
/**
* @description discriminator enum property added by openapi-typescript
* @enum {string}
*/
type: "IMAGE";
src: string;
alt: string;
title: string | null;
};
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"];
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
/** @enum {string} */
kind: "CASE";
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: Tech Log Studio API
version: 3.0.0
version: 3.1.0
description: |
Tech Log Studio orchestration 계약이다.
@@ -1321,7 +1321,10 @@ components:
properties:
type: { type: string, enum: [HEADING] }
id: { type: string, minLength: 1, maxLength: 200 }
level: { type: integer, minimum: 2, maximum: 4 }
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
level: { type: integer, minimum: 1, maximum: 6 }
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
ParagraphBlock:
type: object
@@ -1452,6 +1455,29 @@ components:
width: { type: [integer, "null"], minimum: 1 }
height: { type: [integer, "null"], minimum: 1 }
decorative: { type: boolean }
ThematicBreakBlock:
type: object
additionalProperties: false
description: |
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
required: [type]
properties:
type: { type: string, enum: [THEMATIC_BREAK] }
ImageBlock:
type: object
additionalProperties: false
description: |
`![alt](/media/...)` 로 쓴 그림이다.
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
required: [type, src, alt, title]
properties:
type: { type: string, enum: [IMAGE] }
src: { type: string, minLength: 1, maxLength: 500 }
alt: { type: string, maxLength: 300 }
title: { type: [string, "null"], maxLength: 300 }
CaseRenderBlock:
oneOf:
- { $ref: "#/components/schemas/HeadingBlock" }
@@ -1463,6 +1489,8 @@ components:
- { $ref: "#/components/schemas/DataTableBlock" }
- { $ref: "#/components/schemas/CalloutBlock" }
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
- { $ref: "#/components/schemas/ThematicBreakBlock" }
- { $ref: "#/components/schemas/ImageBlock" }
discriminator:
propertyName: type
mapping:
@@ -1475,6 +1503,8 @@ components:
DATA_TABLE: "#/components/schemas/DataTableBlock"
CALLOUT: "#/components/schemas/CalloutBlock"
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
IMAGE: "#/components/schemas/ImageBlock"
CasePublicRenderModel:
unevaluatedProperties: false
allOf:
@@ -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(
"publishRelease",
"POST",
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
return line;
}
/*
`:::name key="value"` remark-directive `:::name{key="value"}` .
`\s` .
`[a-z0-9-]*` `{` ,
`:::note` `not`
`e` `:::not{e}` , `:::table{id="t"}`
`:::tabl{e{id="t"}}` . ,
.
*/
return line.replace(
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
(
_match,
marker: string,
attributes: string,
newline: string,
) => `${marker}{${attributes.trim()}}${newline}`,
) => {
const trimmed = attributes.trim();
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
},
);
})
.join("");
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
const trustedRelativeLinkOrigin = "https://techlog.invalid";
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
note: "참고",
tip: "도움말",
warning: "주의",
danger: "위험",
};
function hasAsciiControlCharacter(value: string): boolean {
return Array.from(value).some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
@@ -326,8 +347,8 @@ function headingBlock(
node: Heading,
usedIds: Set<string>,
): components["schemas"]["HeadingBlock"] {
if (node.depth < 2 || node.depth > 4) {
invalid(node, "only heading levels 2 through 4 are supported");
if (node.depth < 1 || node.depth > 6) {
invalid(node, "only heading levels 1 through 6 are supported");
}
const children = [...node.children];
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
return inlineFromNodes(cell.children);
}
/**
* `:::table` GFM .
*
* <p> `:::table` directive .
* Studio ,
* . `id` `caption` . `:::table`
* `caption` .
*/
function bareTableAttributes(index: number): Record<string, string> {
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
}
function tableBlock(
node: ContainerDirective,
node: ContainerDirective | Table,
usedIds: Set<string>,
bareIndex?: number,
): components["schemas"]["DataTableBlock"] {
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
if (node.children.length !== 1 || node.children[0].type !== "table") {
const bare = node.type === "table";
const attributes = bare
? bareTableAttributes(bareIndex!)
: attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]);
if (!bare) {
const container = node as ContainerDirective;
if (container.children.length !== 1 || container.children[0].type !== "table") {
invalid(node, "table directive must contain exactly one GFM table");
}
const table = node.children[0] as Table;
}
const table = (bare ? node : (node as ContainerDirective).children[0]) as Table;
if (table.children.length === 0) invalid(table, "table header is required");
const id = attributes.id;
@@ -506,6 +546,25 @@ function directiveBlock(
content: inlineFromNodes((node.children[0] as Paragraph).children),
};
}
/*
callout (`note`/`tip` , `warning`/`danger` ).
`:::callout tone="..."`
. tone , .
*/
case "note":
case "tip":
case "warning":
case "danger": {
if (node.children.length !== 1 || node.children[0].type !== "paragraph") {
invalid(node, `${node.name} directive must contain exactly one paragraph`);
}
return {
type: "CALLOUT",
tone: node.name === "note" || node.name === "tip" ? "info" : "warning",
label: CALLOUT_LABELS[node.name],
content: inlineFromNodes((node.children[0] as Paragraph).children),
};
}
case "evidence": {
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
if (node.children.length !== 0) {
@@ -533,10 +592,34 @@ function directiveBlock(
}
}
/**
* .
*
* <p>Markdown `![alt](/media/...)` inline .
* .
* "문단이 그림 하나로만 이루어진 경우" .
*/
function imageBlockOf(node: Paragraph): components["schemas"]["ImageBlock"] | null {
const visible = node.children.filter(
(child) => !(child.type === "text" && child.value.trim() === ""),
);
if (visible.length !== 1) return null;
const only = visible[0]!;
if (only.type !== "image") return null;
const image = only as unknown as { url: string; alt?: string | null; title?: string | null };
if (!isSafeLink(image.url)) invalid(node, `unsafe image URL: ${image.url}`);
return {
type: "IMAGE",
src: image.url,
alt: image.alt ?? "",
title: image.title ?? null,
};
}
function paragraphBlock(
node: Paragraph,
): components["schemas"]["ParagraphBlock"] {
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
}
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
listItemCount += 1;
return `list-item-${listItemCount}`;
};
let bareTableCount = 0;
return tree.children.map((node: Content): CaseAuthoringBlock => {
switch (node.type) {
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
return codeBlock(node);
case "containerDirective":
return directiveBlock(node, usedIds);
case "html":
case "table": {
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
// 공개 화면에서 다르게 읽힌다.
bareTableCount += 1;
return tableBlock(node, usedIds, bareTableCount);
}
case "thematicBreak":
return { type: "THEMATIC_BREAK" };
case "html":
case "definition":
case "yaml":
case "table":
case "footnoteDefinition":
case "leafDirective":
return invalid(node, `unsupported block syntax: ${node.type}`);
@@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string {
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
":::",
].join("\n");
case "THEMATIC_BREAK":
return "---";
case "IMAGE":
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
// 않는다.
return block.title === null
? `![${escapeText(block.alt)}](${block.src})`
: `![${escapeText(block.alt)}](${block.src} "${block.title.replaceAll('"', '\\"')}")`;
default:
return assertNever(block);
}
@@ -39,56 +39,37 @@ function optionalString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
/**
* .
*
* .
* Case
* .
* . N+1 .
*
* Publication .
* .
*/
async function getLatestEntries(
publicContent: PublicContentQueries,
): Promise<LatestEntry[]> {
const publicRecords = await publicContent.listRecords();
const publicRecordByPath = new Map(
publicRecords.map((record) => [record.path, record]),
);
const searchableEntities = await publicContent.searchPublicContent("");
const projectPrefix = "/projects/";
const projectSlugs = searchableEntities
.filter((entity) => entity.contentType === "PROJECT")
.flatMap((entity) =>
entity.path.startsWith(projectPrefix)
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
: [],
);
// One project at a time would serialise a request per project; issuing them
// together keeps the timeline's cost at its slowest project rather than their
// sum. The flatten below restores the original single-list shape.
const projectTimeline = (
await Promise.all(
projectSlugs.map(async (projectSlug) => {
const project = await publicContent.getProject(projectSlug);
if (!project) return [];
const activities = await publicContent.getProjectActivity(projectSlug);
return activities.map((activity) => {
const record = publicRecordByPath.get(
activity.recordPath ?? activity.path,
);
return {
id: activity.id,
typeLabel:
activity.type === "PUBLICATION" && record
? record.kind
: "PROJECT ACTIVITY",
title:
activity.type === "PUBLICATION" && record
? record.title
: activity.title,
summary: activity.summary,
date: activity.date,
dateTime: activity.dateTime,
topic: record?.topic ?? project.topics[0] ?? "",
project: project.title,
path: activity.path,
};
});
}),
)
).flat();
const [records, searchableEntities] = await Promise.all([
publicContent.getLatestEntries(),
publicContent.searchPublicContent(""),
]);
const recordTimeline: LatestEntry[] = records.map((entry) => ({
id: entry.id,
typeLabel: entry.entryType === "PROJECT_ACTIVITY" ? "PROJECT ACTIVITY" : entry.entryType,
title: entry.title,
summary: entry.summary,
date: dateLabel(entry.publishedAt),
dateTime: entry.publishedAt,
topic: entry.topic,
project: entry.project,
path: entry.path,
}));
const releaseTimeline = (
await Promise.all(
searchableEntities
@@ -117,11 +98,20 @@ async function getLatestEntries(
)
).flat();
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
right.dateTime.localeCompare(left.dateTime),
);
}
/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */
function dateLabel(isoTimestamp: string): string {
const parsed = new Date(isoTimestamp);
if (Number.isNaN(parsed.getTime())) return "";
return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String(
parsed.getDate(),
).padStart(2, "0")}`;
}
export function HomePage() {
const { search } = useRouteInput<"TECH_LOG_HOME">();
const requestedKey = optionalString(search.focus);
@@ -99,6 +99,19 @@ function renderBlock(
resolveEvidenceAsset={resolveEvidenceAsset}
/>
);
case "THEMATIC_BREAK":
return <hr key={key} className="document-rule" />;
case "IMAGE":
/*
. (`isSafeLink`).
`title` Markdown .
*/
return (
<figure key={key} className="document-image">
<img src={block.src} alt={block.alt} loading="lazy" />
{block.title ? <figcaption>{block.title}</figcaption> : null}
</figure>
);
default:
return assertNever(block);
}
@@ -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 () => {
if (pending || draft === null || selectedId === null) return;
setPending(true);
setError("");
try {
const body: ReleaseUpdateRequest = {
expectedVersion: draft.expectedVersion,
versionLabel: draft.versionLabel.trim(),
title: draft.title.trim(),
summary: draft.summary,
changeTypes: [...draft.changeTypes],
changesMarkdown: draft.changesMarkdown,
verificationMarkdown: draft.verificationMarkdown,
...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}),
reasonMarkdown: draft.reasonMarkdown,
userImpactMarkdown: draft.userImpactMarkdown,
implementationImpactMarkdown: draft.implementationImpactMarkdown,
knownLimitationsMarkdown: draft.knownLimitationsMarkdown,
} as ReleaseUpdateRequest;
const saved = await managementGateway.updateRelease(selectedId, body);
const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
setDraft(toDraft(saved));
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
reload();
@@ -193,14 +195,24 @@ export function ReleaseManager() {
setPending(true);
setError("");
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}`);
reload();
} catch {
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
} catch (error) {
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
setError(
managementFailureMessage(
error,
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
),
);
} finally {
setPending(false);
@@ -341,6 +353,16 @@ export function ReleaseManager() {
placeholder="0.1.0"
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
/>
{/*
`draft-…` ,
(`ReleaseDrafts.isPlaceholder`).
.
*/}
{draft.versionLabel.startsWith("draft-") ? (
<span className="studio-field-notice studio-field-notice--error" role="alert">
. (: 0.2.0).
</span>
) : null}
</label>
<label className="studio-field">
<span></span>
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
import type { components } from "../../../contracts/studio/generated.ts";
import { useStudio } from "../use-studio.ts";
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
import { HomeFocusEditor } from "./home-focus-editor.tsx";
type DocumentSummary = components["schemas"]["DocumentSummary"];
@@ -158,6 +159,7 @@ export function StudioDashboard() {
href="/studio/documents"
empty="게시 준비가 끝난 문서가 없습니다."
/>
<HomeFocusEditor />
<section className="studio-work-section">
<div className="studio-section-title">
<h2> </h2>
@@ -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 (
<div className="studio-page studio-documents-page">
<header className="studio-page-top">
@@ -279,9 +310,20 @@ export function TaxonomyManager() {
</div>
<div>
<dt></dt>
<dd>{project.targetVisibility}</dd>
<dd>
{project.targetVisibility === "PRIVATE" ? "비공개" : "공개됨"}
</dd>
</div>
</dl>
<div className="studio-row-actions">
<button
className="studio-primary-button"
type="button"
disabled={pending}
onClick={() => void togglePublish(project)}
>
{project.targetVisibility === "PRIVATE" ? "게시하기" : "게시 취소"}
</button>
<button
className="studio-secondary-button"
type="button"
@@ -290,6 +332,7 @@ export function TaxonomyManager() {
>
</button>
</div>
</article>
))}
</div>
@@ -17,7 +17,12 @@
--warning-soft: #fff7ed;
--code-canvas: #15181d;
--shell: 1180px;
--body-copy: 42rem;
/*
읽는 단의 . 본문·코드블록··콜아웃·그림이 모두 값을 쓰므로 곳만 바꾸면 전체가 함께
움직인다. 42rem(672px) 영문 기준 측정값이었고, 한글 본문에서는 좁아 보이는 데다 바로 위의
유형/프로젝트/게시 줄이 shell 전체(1180px) 쓰고 있어 대비가 심했다.
*/
--body-copy: 56rem;
}
* {
@@ -1329,11 +1334,18 @@ dialog::backdrop {
text-align: center;
}
/* 모달 dialog 화면 가운데에 둔다. UA 기본값(margin:auto) 맡기면 빌드에서는 좌상단에
붙는다 .search-dialog Studio .studio-unsaved-dialog 같은 이유로 position/inset/
margin 명시한다. 여기만 빠져 있어 "크게 보기" 왼쪽 위에 열렸다. */
.figure-dialog {
position: fixed;
inset: 0;
margin: auto;
width: min(1180px, calc(100% - 48px));
max-width: none;
max-height: calc(100dvh - 48px);
padding: 0;
overflow: hidden;
overflow: auto;
border: 1px solid var(--line-strong);
border-radius: 9px;
background: var(--paper);
@@ -2174,6 +2186,20 @@ dialog::backdrop {
max-width: 920px;
}
/*
문서 편은 하나의 단으로 읽힌다.
예전에는 조각이 저마다 다른 폭이었다 머리말 920px, 사실 줄은 shell 전체 1180px, 본문은
672px 가운데 정렬. 그래서 눈이 왼쪽 끝을 다시 찾아야 했고, 가장 좁은 본문이 가장 넓은
사실 안에 갇힌 것처럼 보였다. 셋을 같은 ·같은 왼쪽 끝에 세운다.
*/
.public-document-header,
.document-snapshot,
.document-facts {
width: min(100%, var(--body-copy));
max-width: var(--body-copy);
}
.public-page-header h1,
.public-document-header h1 {
margin: 15px 0 18px;
@@ -2297,7 +2323,7 @@ dialog::backdrop {
.reference-purpose,
.document-relations {
width: min(100%, var(--body-copy));
margin: 82px auto 0;
margin: 82px 0 0;
}
.public-document-body > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); }
.public-document-body h2,
@@ -2613,3 +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 + 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 dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; }
.studio-app .studio-secondary-button { margin-top: 24px; }
/*
행에 버튼이 이상일 . `.studio-secondary-button` 위쪽 여백은 버튼이 하나뿐이던 때의
값이라, 묶음 안에서는 묶음이 여백을 갖고 버튼은 나란히 선다.
*/
.studio-app .studio-row-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
/* 홈 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 h2 { margin: 0; font-size: 25px; }
.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", () => {
const rejected = [
"<script>alert(1)</script>",
@@ -74,11 +104,12 @@ describe("Content Format v1", () => {
"[x](</safe\u001fpath>)",
"[x](</safe\u007fpath>)",
"- outer\n - nested",
"# level one",
"- [ ] task",
"> > nested",
':::unknown key="value"\ntext\n:::',
':::evidence key="https://example.com/x.png" alt="x" caption="x" zoom="true"\n:::',
"![x](javascript:alert(1))",
"![x](//evil.example/x.png)",
];
for (const source of rejected) {
@@ -18,7 +18,7 @@ test("vendored contract matches the recorded canonical digest", () => {
test("canonical source records the pinned revision and version", () => {
assert.equal(canonicalSource.packageId, "@tech-log/studio-contract");
assert.equal(canonicalSource.version, "3.0.0");
assert.equal(canonicalSource.version, "3.1.0");
// revision은 생성 시점에 기록된다. canonical 저장소는 활발히 편집 중이므로
// 특정 값을 박아두면 계약이 그대로인데도 테스트가 깨진다. 형식만 고정한다.
assert.match(canonicalSource.sourceRevision, /^[0-9a-f]{7,64}$/);
+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
// sampled once at whatever moment the assertions happen to arrive.
const tree = recordProviderProcessTree(execution.child.pid!);
const unit = await waitForProviderUnit("vulnerability", execution.child.pid);
const unit = await waitForProviderUnit("vulnerability", execution.child.pid, tree.sample);
tree.sample();
const properties = showProviderUnit(unit);
tree.sample();
expect(properties).toMatchObject({
ActiveState: "active",
CPUQuotaPerSecUSec: "1s",
@@ -827,7 +829,22 @@ describe("candidate archive and provider upload boundaries", () => {
const supervisorArguments = showProcessArguments(execution.child.pid);
expect(supervisorArguments).not.toMatch(new RegExp(`${command}|${credential}`, "u"));
const reportIdentity = await lstat(fixture.reportPath);
const result = await execution.completion;
/*
. 5ms
1.5
`directChildArguments` .
.
*/
const result = await (async () => {
let settled: Awaited<typeof execution.completion> | undefined;
const completion = execution.completion.then((value) => (settled = value));
while (settled === undefined) {
tree.sample();
await Promise.race([completion, delay(5)]);
}
tree.sample();
return settled;
})();
tree.stop();
expect(result.code, result.stderr).toBe(0);
expect(result.stdout).not.toContain(credential);
@@ -849,7 +866,16 @@ describe("candidate archive and provider upload boundaries", () => {
expect(observedArguments.join("\n")).not.toContain(credential);
expect(directChildArguments.filter((arguments_) => arguments_.includes("/usr/bin/systemd-run"))).toHaveLength(1);
expect(directChildArguments.filter((arguments_) => arguments_.includes("provider-raw-guardian.ts"))).toHaveLength(1);
expect(processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts"))).toHaveLength(1);
/*
scope "몇 개인가" argv pid fork
, bwrap/prlimit .
(systemd-run 1, raw-guardian 1)
. wrapper scope .
*/
expect(
processArguments.filter((arguments_) => arguments_.includes("provider-scope-wrapper.ts")).length,
`scope wrapper never observed in the scope; recorded: ${processArguments.join(" | ")}`,
).toBeGreaterThan(0);
expect(
processArguments.filter((arguments_) => /\/usr\/bin\/(?:bwrap|prlimit)/u.test(arguments_)).length,
`sandbox never observed in the scope; recorded: ${processArguments.join(" | ")}`,
@@ -1722,11 +1748,15 @@ function providerSupervisorEnvironment(
async function waitForProviderUnit(
kind: "vulnerability" | "provenance",
supervisorPid: number | undefined,
/** 블로킹 조회 앞뒤로 부른다 — 그 사이에만 살아 있는 프로세스를 놓치지 않기 위한 것이다. */
onPoll?: () => void,
): Promise<string> {
if (!supervisorPid) throw new Error("provider supervisor did not expose its PID");
const pattern = `ca-provider-${kind}-${supervisorPid}-*.scope`;
for (let attempt = 0; attempt < 120; attempt += 1) {
onPoll?.();
const units = listProviderUnits(pattern);
onPoll?.();
if (units.length === 1) return units[0]!;
if (units.length > 1) throw new Error(`provider unit identity is ambiguous: ${units.join(", ")}`);
await delay(25);
@@ -2025,6 +2055,13 @@ function recordProviderProcessTree(supervisorPid: number) {
timer.unref();
sample();
return Object.freeze({
/*
. `systemctl` `spawnSync` ,
5ms bubblewrap ,
.
.
*/
sample,
stop() {
stopped = true;
clearInterval(timer);