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