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
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
CreateDraftResponse,
|
||||
HomeFocusRequest,
|
||||
HomeFocusResponse,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
@@ -85,6 +87,16 @@ export function createHttpManagementGateway(
|
||||
deleteRelease: async (id: string, expectedVersion: number) => {
|
||||
await run<void>("deleteRelease", { id, expectedVersion });
|
||||
},
|
||||
publishProject: (
|
||||
id: string,
|
||||
expectedVersion: number,
|
||||
visibility: "PUBLIC" | "UNLISTED" = "PUBLIC",
|
||||
) => run<PublishResponse>("publishProject", { id, expectedVersion, visibility }),
|
||||
unpublishProject: (id: string, expectedVersion: number) =>
|
||||
run<ProjectEditResponse>("unpublishProject", { id, expectedVersion }),
|
||||
getHomeFocus: () => run<HomeFocusResponse>("getHomeFocus", {}),
|
||||
updateHomeFocus: (body: HomeFocusRequest) =>
|
||||
run<HomeFocusResponse>("updateHomeFocus", body),
|
||||
publishRelease: (id: string, expectedVersion: number) =>
|
||||
run<PublishResponse>("publishRelease", { id, expectedVersion }),
|
||||
archiveRelease: (id: string, expectedVersion: number) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
HomeFocusItem,
|
||||
LatestRecordEntry,
|
||||
ProjectActivity,
|
||||
ProjectDecision,
|
||||
Project,
|
||||
@@ -320,6 +321,33 @@ export function createHttpPublicContentGateway(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 투영이 고른 최근 기록. `entryType` 은 계약이 네 값만 허용하므로 그대로 믿고 쓴다 —
|
||||
* 서버가 이미 걸러 보낸다.
|
||||
*/
|
||||
async function getLatestEntries(): Promise<LatestRecordEntry[]> {
|
||||
const home = await read<Readonly<{ latestEntries?: readonly Readonly<Record<string, unknown>>[] }>>(
|
||||
"getPublicHome",
|
||||
{},
|
||||
);
|
||||
if (home === NOT_FOUND) return [];
|
||||
return (home.latestEntries ?? []).map((entry) => {
|
||||
const topic = entry.primaryTopic as Readonly<Record<string, unknown>> | null | undefined;
|
||||
const project = entry.primaryProject as Readonly<Record<string, unknown>> | null | undefined;
|
||||
const path = String(entry.path ?? "");
|
||||
return Object.freeze({
|
||||
id: `${String(entry.entryType ?? "")}:${path}`,
|
||||
entryType: String(entry.entryType ?? "CASE") as LatestRecordEntry["entryType"],
|
||||
title: String(entry.title ?? ""),
|
||||
summary: String(entry.summary ?? ""),
|
||||
path,
|
||||
publishedAt: String(entry.publishedAt ?? ""),
|
||||
topic: topic ? String(topic.name ?? "") : "",
|
||||
project: project ? String(project.name ?? "") : "",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getHomeFocusItems(): Promise<HomeFocusItem[]> {
|
||||
const home = await read<Readonly<{ focus?: Readonly<Record<string, never>> }>>(
|
||||
"getPublicHome",
|
||||
@@ -463,6 +491,7 @@ export function createHttpPublicContentGateway(
|
||||
getProjectDecisions,
|
||||
getProjectActivity,
|
||||
getHomeFocusItems,
|
||||
getLatestEntries,
|
||||
searchPublicContent,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type HomeFocusItem,
|
||||
} from "./public-content.ts";
|
||||
import type {
|
||||
LatestRecordEntry,
|
||||
PublicContentQueries,
|
||||
PublicTopic,
|
||||
} from "../../application/ports/public-content-queries.ts";
|
||||
@@ -119,6 +120,43 @@ export function listTopics(): PublicTopic[] {
|
||||
.map((entry) => Object.freeze(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 픽스처판 "최근 기록". HTTP 어댑터가 서버에서 읽어 오는 것과 같은 의미를 픽스처에서 만든다 —
|
||||
* 프로젝트 활동과 릴리스가 원천이다.
|
||||
*/
|
||||
export function getLatestEntries(): LatestRecordEntry[] {
|
||||
const recordByPath = new Map(publicRecords.map((record) => [record.path, record]));
|
||||
const activities = projects.flatMap((project) =>
|
||||
project.activity.map((activity) => {
|
||||
const record = recordByPath.get(activity.recordPath ?? activity.path);
|
||||
const published = activity.type === "PUBLICATION" && record;
|
||||
return {
|
||||
id: activity.id,
|
||||
entryType: (published ? record.kind : "PROJECT_ACTIVITY") as LatestRecordEntry["entryType"],
|
||||
title: published ? record.title : activity.title,
|
||||
summary: activity.summary,
|
||||
path: activity.path,
|
||||
publishedAt: activity.dateTime,
|
||||
topic: record?.topic ?? project.topics[0] ?? "",
|
||||
project: project.title,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const releaseEntries = releases.map((release) => ({
|
||||
id: `release-${release.version}`,
|
||||
entryType: "RELEASE" as const,
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
path: release.path,
|
||||
publishedAt: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
}));
|
||||
return [...activities, ...releaseEntries].sort((left, right) =>
|
||||
right.publishedAt.localeCompare(left.publishedAt),
|
||||
);
|
||||
}
|
||||
|
||||
export function getHomeFocusItems(): HomeFocusItem[] {
|
||||
const project = getProject("backend-skeleton");
|
||||
const question = getRecord("QUESTION", "validate-edge-token-again");
|
||||
@@ -264,6 +302,9 @@ export const publicContentQueries = Object.freeze({
|
||||
async listTopics() {
|
||||
return listTopics();
|
||||
},
|
||||
async getLatestEntries() {
|
||||
return getLatestEntries();
|
||||
},
|
||||
async getHomeFocusItems() {
|
||||
return getHomeFocusItems();
|
||||
},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type {
|
||||
CreateDraftResponse,
|
||||
HomeFocusRequest,
|
||||
HomeFocusResponse,
|
||||
ProjectEditResponse,
|
||||
ProjectIndexPage,
|
||||
ProjectUpdateRequest,
|
||||
@@ -27,6 +29,21 @@ export type ManagementGateway = Readonly<{
|
||||
createProject(title: string): Promise<CreateDraftResponse>;
|
||||
updateProject(id: string, body: ProjectUpdateRequest): Promise<ProjectEditResponse>;
|
||||
deleteProject(id: string, expectedVersion: number): Promise<void>;
|
||||
/**
|
||||
* 프로젝트 게시. 프로젝트는 Studio 문서가 아니라 게시 파이프라인 밖에 있고, 그래서 문서를
|
||||
* 게시해도 그 문서가 속한 프로젝트는 비공개로 남는다 — 공개 화면(프로젝트 목록·프로필의
|
||||
* "현재 프로젝트"·홈의 focus)은 모두 게시된 프로젝트만 읽으므로, 이 호출 없이는 어디에도
|
||||
* 나타나지 않는다.
|
||||
*/
|
||||
publishProject(
|
||||
id: string,
|
||||
expectedVersion: number,
|
||||
visibility?: "PUBLIC" | "UNLISTED",
|
||||
): Promise<PublishResponse>;
|
||||
unpublishProject(id: string, expectedVersion: number): Promise<ProjectEditResponse>;
|
||||
/** 공개 홈이 무엇을 앞에 둘지. 세 슬롯이 모두 비면 홈은 그 영역을 아예 그리지 않는다. */
|
||||
getHomeFocus(): Promise<HomeFocusResponse>;
|
||||
updateHomeFocus(body: HomeFocusRequest): Promise<HomeFocusResponse>;
|
||||
listReleases(page?: number, size?: number): Promise<ReleaseIndexPage>;
|
||||
getRelease(id: string): Promise<ReleaseEditResponse>;
|
||||
createRelease(title: string): Promise<CreateDraftResponse>;
|
||||
|
||||
@@ -164,6 +164,24 @@ export type PublicTopic = {
|
||||
recordCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 홈의 "최근 기록" 한 줄. 서버가 공개 투영에서 직접 고른다.
|
||||
*
|
||||
* 화면이 프로젝트를 하나씩 돌며 조립하던 때에는, 게시된 문서라도 그 문서가 매달린 프로젝트가
|
||||
* 공개되어 있지 않으면 목록에서 통째로 빠졌다 — 실제로 게시한 Case 는 안 보이고 릴리스만
|
||||
* 남았다. 무엇이 최근인지는 공개 투영 하나가 알고 있으므로 거기서 그대로 읽는다.
|
||||
*/
|
||||
export type LatestRecordEntry = {
|
||||
id: string;
|
||||
entryType: "CASE" | "REFERENCE" | "PROJECT_ACTIVITY" | "RELEASE";
|
||||
title: string;
|
||||
summary: string;
|
||||
path: string;
|
||||
publishedAt: string;
|
||||
topic: string;
|
||||
project: string;
|
||||
};
|
||||
|
||||
export type FocusKey = "current" | "question" | "decision";
|
||||
|
||||
export type HomeFocusItem = {
|
||||
@@ -227,6 +245,7 @@ export type PublicContentQueries = Readonly<{
|
||||
* 있었고, Studio 에서 주제를 만들어도 바뀌지 않았다.
|
||||
*/
|
||||
listTopics(): Promise<PublicTopic[]>;
|
||||
getLatestEntries(): Promise<LatestRecordEntry[]>;
|
||||
getHomeFocusItems(): Promise<HomeFocusItem[]>;
|
||||
searchPublicContent(query: string): Promise<SearchablePublicEntity[]>;
|
||||
}>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"packageId": "@tech-log/management-contract",
|
||||
"version": "1.0.0",
|
||||
"digest": "sha256:d19ae7c4fbcac924a356bbb0cc1a46a4046ecec701158ca0a9b7cc089bbaf878",
|
||||
"digest": "sha256:446b14486291ab89b5b42d86aea1c1c7007ac40583aa98fe50d6e741d61aa1b3",
|
||||
"sourceRevision": "b195b29",
|
||||
"operationIds": [
|
||||
"createCaseDraft",
|
||||
|
||||
@@ -15,3 +15,5 @@ export type ReleaseIndexItem = Schemas["ReleaseIndexItem"];
|
||||
export type ReleaseIndexPage = Schemas["ReleaseIndexPage"];
|
||||
export type ReleaseUpdateRequest = Schemas["ReleaseUpdateRequest"];
|
||||
export type PublishResponse = Schemas["PublishResponse"];
|
||||
export type HomeFocusRequest = Schemas["HomeFocusRequest"];
|
||||
export type HomeFocusResponse = Schemas["HomeFocusResponse"];
|
||||
|
||||
@@ -963,6 +963,12 @@ export interface components {
|
||||
data: components["schemas"]["ReleaseEditResponse"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
HomeFocusResponseEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
data: components["schemas"]["HomeFocusResponse"];
|
||||
meta: components["schemas"]["ResponseMeta"];
|
||||
};
|
||||
PublishResponseEnvelope: {
|
||||
/** @constant */
|
||||
success: true;
|
||||
@@ -5519,7 +5525,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PublishResponse"];
|
||||
"application/json": components["schemas"]["PublishResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -5528,7 +5534,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -5537,7 +5543,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -5546,7 +5552,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -5555,7 +5561,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -5564,7 +5570,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -5573,7 +5579,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -5582,7 +5588,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -5610,7 +5616,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectEditResponse"];
|
||||
"application/json": components["schemas"]["ProjectEditResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -5619,7 +5625,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -5628,7 +5634,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -5637,7 +5643,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -5646,7 +5652,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -5655,7 +5661,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -5664,7 +5670,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -5673,7 +5679,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8117,7 +8123,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HomeFocusResponse"];
|
||||
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -8126,7 +8132,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -8135,7 +8141,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -8144,7 +8150,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -8153,7 +8159,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -8162,7 +8168,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -8188,7 +8194,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HomeFocusResponse"];
|
||||
"application/json": components["schemas"]["HomeFocusResponseEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Bad Request */
|
||||
@@ -8197,7 +8203,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unauthorized */
|
||||
@@ -8206,7 +8212,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Forbidden */
|
||||
@@ -8215,7 +8221,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Not Found */
|
||||
@@ -8224,7 +8230,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Conflict */
|
||||
@@ -8233,7 +8239,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Unprocessable Content */
|
||||
@@ -8242,7 +8248,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Internal Server Error */
|
||||
@@ -8251,7 +8257,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/problem+json": components["schemas"]["ProblemDetails"];
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3015,49 +3015,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PublishResponse'
|
||||
$ref: '#/components/schemas/PublishResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -3085,49 +3085,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProjectEditResponse'
|
||||
$ref: '#/components/schemas/ProjectEditResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -4985,37 +4985,37 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
security:
|
||||
- sessionCookie: []
|
||||
put:
|
||||
@@ -5030,49 +5030,49 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
$ref: '#/components/schemas/HomeFocusResponseEnvelope'
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'422':
|
||||
description: Unprocessable Content
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ProblemDetails'
|
||||
$ref: '#/components/schemas/ErrorEnvelope'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -5510,6 +5510,21 @@ components:
|
||||
$ref: '#/components/schemas/ReleaseEditResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
HomeFocusResponseEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- success
|
||||
- data
|
||||
- meta
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
const: true
|
||||
data:
|
||||
$ref: '#/components/schemas/HomeFocusResponse'
|
||||
meta:
|
||||
$ref: '#/components/schemas/ResponseMeta'
|
||||
PublishResponseEnvelope:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"packageId": "@tech-log/studio-contract",
|
||||
"version": "3.0.0",
|
||||
"digest": "sha256:674327a82951fd4a1bc2594c858072dfcd0b9abe7c63198283da5d8c92a04326",
|
||||
"version": "3.1.0",
|
||||
"digest": "sha256:6fc015ca6727af88b7fb0088e02ba97846e1dd79fb0d4fc593cc79f2a3b9795f",
|
||||
"sourceRevision": "b195b29",
|
||||
"operationIds": [
|
||||
"getStudioSession",
|
||||
|
||||
@@ -1074,7 +1074,32 @@ export interface components {
|
||||
height: number | null;
|
||||
decorative: boolean;
|
||||
};
|
||||
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"];
|
||||
/** @description `---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||
* */
|
||||
ThematicBreakBlock: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "THEMATIC_BREAK";
|
||||
};
|
||||
/** @description `` 로 쓴 그림이다.
|
||||
*
|
||||
* `EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||
* 시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||
* 링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||
* */
|
||||
ImageBlock: {
|
||||
/**
|
||||
* @description discriminator enum property added by openapi-typescript
|
||||
* @enum {string}
|
||||
*/
|
||||
type: "IMAGE";
|
||||
src: string;
|
||||
alt: string;
|
||||
title: string | null;
|
||||
};
|
||||
CaseRenderBlock: components["schemas"]["HeadingBlock"] | components["schemas"]["ParagraphBlock"] | components["schemas"]["BlockquoteBlock"] | components["schemas"]["UnorderedListBlock"] | components["schemas"]["OrderedListBlock"] | components["schemas"]["CodeBlock"] | components["schemas"]["DataTableBlock"] | components["schemas"]["CalloutBlock"] | components["schemas"]["EvidenceFigureBlock"] | components["schemas"]["ThematicBreakBlock"] | components["schemas"]["ImageBlock"];
|
||||
CasePublicRenderModel: components["schemas"]["PublicRenderModelBase"] & {
|
||||
/** @enum {string} */
|
||||
kind: "CASE";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Tech Log Studio API
|
||||
version: 3.0.0
|
||||
version: 3.1.0
|
||||
description: |
|
||||
Tech Log Studio orchestration 계약이다.
|
||||
|
||||
@@ -1321,7 +1321,10 @@ components:
|
||||
properties:
|
||||
type: { type: string, enum: [HEADING] }
|
||||
id: { type: string, minLength: 1, maxLength: 200 }
|
||||
level: { type: integer, minimum: 2, maximum: 4 }
|
||||
# 작성자가 쓴 그대로 담는다. 서버 렌더러는 이 값을 2..4 로 좁혀 문서 안 제목 위계를
|
||||
# 지키므로(BlockRenderer), 계약이 1..6 을 거절할 이유가 없다 — 거절하면 `#` 로 시작한
|
||||
# 평범한 Markdown 이 통째로 렌더링되지 않는다.
|
||||
level: { type: integer, minimum: 1, maximum: 6 }
|
||||
content: { type: array, maxItems: 1000, items: { $ref: "#/components/schemas/Inline" } }
|
||||
ParagraphBlock:
|
||||
type: object
|
||||
@@ -1452,6 +1455,29 @@ components:
|
||||
width: { type: [integer, "null"], minimum: 1 }
|
||||
height: { type: [integer, "null"], minimum: 1 }
|
||||
decorative: { type: boolean }
|
||||
ThematicBreakBlock:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: |
|
||||
`---` 로 쓴 구분선이다. 담을 내용이 없으므로 `type` 뿐이다.
|
||||
required: [type]
|
||||
properties:
|
||||
type: { type: string, enum: [THEMATIC_BREAK] }
|
||||
ImageBlock:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
description: |
|
||||
`` 로 쓴 그림이다.
|
||||
|
||||
`EvidenceFigureBlock` 과 나누는 기준은 출처다. evidence 는 assetKey 로 가리켜 게시
|
||||
시점에 고정되고 확대 보기를 갖지만, 이쪽은 작성자가 적은 경로를 그대로 쓴다. 경로 규칙은
|
||||
링크와 같다 — 외부 스킴과 `javascript:` 는 거절한다.
|
||||
required: [type, src, alt, title]
|
||||
properties:
|
||||
type: { type: string, enum: [IMAGE] }
|
||||
src: { type: string, minLength: 1, maxLength: 500 }
|
||||
alt: { type: string, maxLength: 300 }
|
||||
title: { type: [string, "null"], maxLength: 300 }
|
||||
CaseRenderBlock:
|
||||
oneOf:
|
||||
- { $ref: "#/components/schemas/HeadingBlock" }
|
||||
@@ -1463,6 +1489,8 @@ components:
|
||||
- { $ref: "#/components/schemas/DataTableBlock" }
|
||||
- { $ref: "#/components/schemas/CalloutBlock" }
|
||||
- { $ref: "#/components/schemas/EvidenceFigureBlock" }
|
||||
- { $ref: "#/components/schemas/ThematicBreakBlock" }
|
||||
- { $ref: "#/components/schemas/ImageBlock" }
|
||||
discriminator:
|
||||
propertyName: type
|
||||
mapping:
|
||||
@@ -1475,6 +1503,8 @@ components:
|
||||
DATA_TABLE: "#/components/schemas/DataTableBlock"
|
||||
CALLOUT: "#/components/schemas/CalloutBlock"
|
||||
EVIDENCE_FIGURE: "#/components/schemas/EvidenceFigureBlock"
|
||||
THEMATIC_BREAK: "#/components/schemas/ThematicBreakBlock"
|
||||
IMAGE: "#/components/schemas/ImageBlock"
|
||||
CasePublicRenderModel:
|
||||
unevaluatedProperties: false
|
||||
allOf:
|
||||
|
||||
@@ -310,6 +310,47 @@ const HTTP_CONTRACTS = Object.freeze([
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"publishProject",
|
||||
"POST",
|
||||
`${P}/{id}/publish`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 8_192 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{
|
||||
id: string;
|
||||
expectedVersion: number;
|
||||
visibility: "PUBLIC" | "UNLISTED";
|
||||
}>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion, visibility: value.visibility },
|
||||
});
|
||||
},
|
||||
),
|
||||
writeOperation(
|
||||
"unpublishProject",
|
||||
"POST",
|
||||
`${P}/{id}/unpublish`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 1_024, responseByteLimit: 262_144 },
|
||||
(input: never) => {
|
||||
const value = input as unknown as Readonly<{ id: string; expectedVersion: number }>;
|
||||
return Object.freeze({
|
||||
pathValues: Object.freeze({ id: value.id }),
|
||||
queryEntries: NO_QUERY,
|
||||
body: { expectedVersion: value.expectedVersion },
|
||||
});
|
||||
},
|
||||
),
|
||||
readOperation("getHomeFocus", `${D}/home-focus`, 8_192),
|
||||
writeOperation(
|
||||
"updateHomeFocus",
|
||||
"PUT",
|
||||
`${D}/home-focus`,
|
||||
{ acceptedStatuses: [200], requestByteLimit: 4_096, responseByteLimit: 8_192 },
|
||||
(input: never) =>
|
||||
Object.freeze({ pathValues: NO_PATH, queryEntries: NO_QUERY, body: input }),
|
||||
),
|
||||
writeOperation(
|
||||
"publishRelease",
|
||||
"POST",
|
||||
|
||||
@@ -180,14 +180,27 @@ function normalizeDirectives(source: string): string {
|
||||
return line;
|
||||
}
|
||||
|
||||
/*
|
||||
`:::name key="value"` 를 remark-directive 가 읽는 `:::name{key="value"}` 로 바꾼다.
|
||||
|
||||
이름과 나머지 사이의 경계를 `\s` 로 못 박는 것이 중요하다. 예전에는 이름을
|
||||
`[a-z0-9-]*` 로 두고 나머지가 `{` 로 시작하지 않기만 요구했는데, 정규식이 되돌아가며
|
||||
이름의 마지막 글자를 나머지 쪽으로 넘겨 그 조건을 피해 갔다 — `:::note` 는 이름 `not`
|
||||
에 본문 `e` 가 되어 `:::not{e}` 로, 이미 중괄호를 쓴 `:::table{id="t"}` 는
|
||||
`:::tabl{e{id="t"}}` 로 망가졌다. 그래서 속성 없는 디렉티브는 이름이 통째로 바뀌고,
|
||||
중괄호 형태는 아예 해석되지 않았다.
|
||||
*/
|
||||
return line.replace(
|
||||
/^(:::[a-z][a-z0-9-]*)([^\n{][^\n]*)(\n?)$/i,
|
||||
/^(:::[a-z][a-z0-9-]*)(\s+[^\n]*?)(\n?)$/i,
|
||||
(
|
||||
_match,
|
||||
marker: string,
|
||||
attributes: string,
|
||||
newline: string,
|
||||
) => `${marker}{${attributes.trim()}}${newline}`,
|
||||
) => {
|
||||
const trimmed = attributes.trim();
|
||||
return trimmed ? `${marker}{${trimmed}}${newline}` : `${marker}${newline}`;
|
||||
},
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
@@ -199,6 +212,14 @@ function assertNever(value: never): never {
|
||||
|
||||
const trustedRelativeLinkOrigin = "https://techlog.invalid";
|
||||
|
||||
/** 서버가 아는 callout 이름과 화면에 붙일 말. 이름이 tone 을 겸하므로 속성을 받지 않는다. */
|
||||
const CALLOUT_LABELS: Readonly<Record<string, string>> = {
|
||||
note: "참고",
|
||||
tip: "도움말",
|
||||
warning: "주의",
|
||||
danger: "위험",
|
||||
};
|
||||
|
||||
function hasAsciiControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
@@ -326,8 +347,8 @@ function headingBlock(
|
||||
node: Heading,
|
||||
usedIds: Set<string>,
|
||||
): components["schemas"]["HeadingBlock"] {
|
||||
if (node.depth < 2 || node.depth > 4) {
|
||||
invalid(node, "only heading levels 2 through 4 are supported");
|
||||
if (node.depth < 1 || node.depth > 6) {
|
||||
invalid(node, "only heading levels 1 through 6 are supported");
|
||||
}
|
||||
|
||||
const children = [...node.children];
|
||||
@@ -417,15 +438,34 @@ function tableCellContent(cell: TableCell): Inline[] {
|
||||
return inlineFromNodes(cell.children);
|
||||
}
|
||||
|
||||
/**
|
||||
* `:::table` 없이 쓴 GFM 표에 붙일 값.
|
||||
*
|
||||
* <p>서버 렌더러는 파이프 표를 그대로 읽는다 — `:::table` 이라는 directive 자체를 모른다.
|
||||
* 여기서만 감싸기를 요구하면 같은 본문이 Studio 와 공개 화면에서 다르게 읽히므로, 감싸지 않은
|
||||
* 표도 받는다. `id` 는 자리 순서로 만들고 `caption` 은 비운다. 설명이 필요하면 `:::table` 로
|
||||
* 감싸 `caption` 을 주면 된다.
|
||||
*/
|
||||
function bareTableAttributes(index: number): Record<string, string> {
|
||||
return { id: `table-${index}`, caption: "", rowHeaderColumn: "none" };
|
||||
}
|
||||
|
||||
function tableBlock(
|
||||
node: ContainerDirective,
|
||||
node: ContainerDirective | Table,
|
||||
usedIds: Set<string>,
|
||||
bareIndex?: number,
|
||||
): components["schemas"]["DataTableBlock"] {
|
||||
const attributes = attributesOf(node, ["id", "caption", "rowHeaderColumn"]);
|
||||
if (node.children.length !== 1 || node.children[0].type !== "table") {
|
||||
invalid(node, "table directive must contain exactly one GFM table");
|
||||
const bare = node.type === "table";
|
||||
const attributes = bare
|
||||
? bareTableAttributes(bareIndex!)
|
||||
: attributesOf(node as ContainerDirective, ["id", "caption", "rowHeaderColumn"]);
|
||||
if (!bare) {
|
||||
const container = node as ContainerDirective;
|
||||
if (container.children.length !== 1 || container.children[0].type !== "table") {
|
||||
invalid(node, "table directive must contain exactly one GFM table");
|
||||
}
|
||||
}
|
||||
const table = node.children[0] as Table;
|
||||
const table = (bare ? node : (node as ContainerDirective).children[0]) as Table;
|
||||
if (table.children.length === 0) invalid(table, "table header is required");
|
||||
|
||||
const id = attributes.id;
|
||||
@@ -506,6 +546,25 @@ function directiveBlock(
|
||||
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
||||
};
|
||||
}
|
||||
/*
|
||||
서버 렌더러가 아는 callout 이름이다(`note`/`tip` 은 정보, `warning`/`danger` 는 경고).
|
||||
`:::callout tone="..."` 만 받으면 서버가 정상으로 읽는 본문을 여기서 거절하게 되므로 둘 다
|
||||
받는다. 이름이 곧 tone 이라 속성이 없고, 라벨은 이름에서 만든다.
|
||||
*/
|
||||
case "note":
|
||||
case "tip":
|
||||
case "warning":
|
||||
case "danger": {
|
||||
if (node.children.length !== 1 || node.children[0].type !== "paragraph") {
|
||||
invalid(node, `${node.name} directive must contain exactly one paragraph`);
|
||||
}
|
||||
return {
|
||||
type: "CALLOUT",
|
||||
tone: node.name === "note" || node.name === "tip" ? "info" : "warning",
|
||||
label: CALLOUT_LABELS[node.name],
|
||||
content: inlineFromNodes((node.children[0] as Paragraph).children),
|
||||
};
|
||||
}
|
||||
case "evidence": {
|
||||
const attributes = attributesOf(node, ["key", "alt", "caption", "zoom"]);
|
||||
if (node.children.length !== 0) {
|
||||
@@ -533,10 +592,34 @@ function directiveBlock(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 문단 하나에 그림만 있으면 그림 블록으로 읽는다.
|
||||
*
|
||||
* <p>Markdown 에서 `` 는 문단 안의 inline 이다. 인라인 유니온에는 그림이 없고
|
||||
* 앞으로도 둘 이유가 없다 — 글 가운데 끼워 넣는 그림은 이 문서 형식이 다루는 대상이 아니다.
|
||||
* 그래서 "문단이 그림 하나로만 이루어진 경우"만 블록으로 올린다.
|
||||
*/
|
||||
function imageBlockOf(node: Paragraph): components["schemas"]["ImageBlock"] | null {
|
||||
const visible = node.children.filter(
|
||||
(child) => !(child.type === "text" && child.value.trim() === ""),
|
||||
);
|
||||
if (visible.length !== 1) return null;
|
||||
const only = visible[0]!;
|
||||
if (only.type !== "image") return null;
|
||||
const image = only as unknown as { url: string; alt?: string | null; title?: string | null };
|
||||
if (!isSafeLink(image.url)) invalid(node, `unsafe image URL: ${image.url}`);
|
||||
return {
|
||||
type: "IMAGE",
|
||||
src: image.url,
|
||||
alt: image.alt ?? "",
|
||||
title: image.title ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function paragraphBlock(
|
||||
node: Paragraph,
|
||||
): components["schemas"]["ParagraphBlock"] {
|
||||
return { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||
): components["schemas"]["ParagraphBlock"] | components["schemas"]["ImageBlock"] {
|
||||
return imageBlockOf(node) ?? { type: "PARAGRAPH", content: inlineFromNodes(node.children) };
|
||||
}
|
||||
|
||||
export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
@@ -564,6 +647,7 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
listItemCount += 1;
|
||||
return `list-item-${listItemCount}`;
|
||||
};
|
||||
let bareTableCount = 0;
|
||||
|
||||
return tree.children.map((node: Content): CaseAuthoringBlock => {
|
||||
switch (node.type) {
|
||||
@@ -585,11 +669,17 @@ export function parseCaseContent(source: string): CaseAuthoringBlock[] {
|
||||
return codeBlock(node);
|
||||
case "containerDirective":
|
||||
return directiveBlock(node, usedIds);
|
||||
case "html":
|
||||
case "table": {
|
||||
// 서버 렌더러는 파이프 표를 그대로 읽는다. 여기서 거절하면 같은 본문이 Studio 와
|
||||
// 공개 화면에서 다르게 읽힌다.
|
||||
bareTableCount += 1;
|
||||
return tableBlock(node, usedIds, bareTableCount);
|
||||
}
|
||||
case "thematicBreak":
|
||||
return { type: "THEMATIC_BREAK" };
|
||||
case "html":
|
||||
case "definition":
|
||||
case "yaml":
|
||||
case "table":
|
||||
case "footnoteDefinition":
|
||||
case "leafDirective":
|
||||
return invalid(node, `unsupported block syntax: ${node.type}`);
|
||||
|
||||
@@ -188,6 +188,14 @@ function serializeBlock(block: CaseRenderBlock): string {
|
||||
`:::evidence key=${quoteAttribute(block.key)} alt=${quoteAttribute(block.alt)} caption=${quoteAttribute(block.caption)} zoom=${quoteAttribute(String(block.zoom))}`,
|
||||
":::",
|
||||
].join("\n");
|
||||
case "THEMATIC_BREAK":
|
||||
return "---";
|
||||
case "IMAGE":
|
||||
// 제목은 Markdown 이 따옴표로 감싼다. 없으면 붙이지 않아야 다시 읽었을 때 빈 제목이 되지
|
||||
// 않는다.
|
||||
return block.title === null
|
||||
? ``
|
||||
: `}")`;
|
||||
default:
|
||||
return assertNever(block);
|
||||
}
|
||||
|
||||
@@ -39,56 +39,37 @@ function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 고른 최근 기록에 릴리스를 얹는다.
|
||||
*
|
||||
* 예전에는 이 함수가 공개된 프로젝트를 하나씩 돌며 활동을 모아 타임라인을 만들었다. 그래서 게시된
|
||||
* 문서라도 그 문서가 매달린 프로젝트가 공개되어 있지 않으면 홈에서 통째로 사라졌다 — 실제로 Case 를
|
||||
* 게시했는데 홈에는 릴리스 한 줄만 남았다. 무엇이 최근인지는 공개 투영이 이미 알고 있으므로 그것을
|
||||
* 그대로 읽는다. 프로젝트마다 요청을 하나씩 보내던 N+1 도 같이 사라진다.
|
||||
*
|
||||
* 릴리스는 Publication 파이프라인을 거치지 않아 그 투영에 행이 없다. 그래서 릴리스만 따로 읽어
|
||||
* 시간순으로 합친다.
|
||||
*/
|
||||
async function getLatestEntries(
|
||||
publicContent: PublicContentQueries,
|
||||
): Promise<LatestEntry[]> {
|
||||
const publicRecords = await publicContent.listRecords();
|
||||
const publicRecordByPath = new Map(
|
||||
publicRecords.map((record) => [record.path, record]),
|
||||
);
|
||||
const searchableEntities = await publicContent.searchPublicContent("");
|
||||
const projectPrefix = "/projects/";
|
||||
const projectSlugs = searchableEntities
|
||||
.filter((entity) => entity.contentType === "PROJECT")
|
||||
.flatMap((entity) =>
|
||||
entity.path.startsWith(projectPrefix)
|
||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
||||
: [],
|
||||
);
|
||||
// One project at a time would serialise a request per project; issuing them
|
||||
// together keeps the timeline's cost at its slowest project rather than their
|
||||
// sum. The flatten below restores the original single-list shape.
|
||||
const projectTimeline = (
|
||||
await Promise.all(
|
||||
projectSlugs.map(async (projectSlug) => {
|
||||
const project = await publicContent.getProject(projectSlug);
|
||||
if (!project) return [];
|
||||
const activities = await publicContent.getProjectActivity(projectSlug);
|
||||
return activities.map((activity) => {
|
||||
const record = publicRecordByPath.get(
|
||||
activity.recordPath ?? activity.path,
|
||||
);
|
||||
return {
|
||||
id: activity.id,
|
||||
typeLabel:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.kind
|
||||
: "PROJECT ACTIVITY",
|
||||
title:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.title
|
||||
: activity.title,
|
||||
summary: activity.summary,
|
||||
date: activity.date,
|
||||
dateTime: activity.dateTime,
|
||||
topic: record?.topic ?? project.topics[0] ?? "",
|
||||
project: project.title,
|
||||
path: activity.path,
|
||||
};
|
||||
});
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
const [records, searchableEntities] = await Promise.all([
|
||||
publicContent.getLatestEntries(),
|
||||
publicContent.searchPublicContent(""),
|
||||
]);
|
||||
|
||||
const recordTimeline: LatestEntry[] = records.map((entry) => ({
|
||||
id: entry.id,
|
||||
typeLabel: entry.entryType === "PROJECT_ACTIVITY" ? "PROJECT ACTIVITY" : entry.entryType,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
date: dateLabel(entry.publishedAt),
|
||||
dateTime: entry.publishedAt,
|
||||
topic: entry.topic,
|
||||
project: entry.project,
|
||||
path: entry.path,
|
||||
}));
|
||||
|
||||
const releaseTimeline = (
|
||||
await Promise.all(
|
||||
searchableEntities
|
||||
@@ -102,14 +83,14 @@ async function getLatestEntries(
|
||||
if (!release) return [];
|
||||
return [
|
||||
{
|
||||
id: `release-${release.version}`,
|
||||
typeLabel: "RELEASE",
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
date: release.publishedLabel,
|
||||
dateTime: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
id: `release-${release.version}`,
|
||||
typeLabel: "RELEASE",
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
date: release.publishedLabel,
|
||||
dateTime: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
path: release.path,
|
||||
},
|
||||
];
|
||||
@@ -117,11 +98,20 @@ async function getLatestEntries(
|
||||
)
|
||||
).flat();
|
||||
|
||||
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
||||
return [...recordTimeline, ...releaseTimeline].sort((left, right) =>
|
||||
right.dateTime.localeCompare(left.dateTime),
|
||||
);
|
||||
}
|
||||
|
||||
/** 목록의 날짜 칸은 공개 화면 어디서나 같은 형식이다. */
|
||||
function dateLabel(isoTimestamp: string): string {
|
||||
const parsed = new Date(isoTimestamp);
|
||||
if (Number.isNaN(parsed.getTime())) return "";
|
||||
return `${parsed.getFullYear()}.${String(parsed.getMonth() + 1).padStart(2, "0")}.${String(
|
||||
parsed.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
||||
const requestedKey = optionalString(search.focus);
|
||||
|
||||
@@ -99,6 +99,19 @@ function renderBlock(
|
||||
resolveEvidenceAsset={resolveEvidenceAsset}
|
||||
/>
|
||||
);
|
||||
case "THEMATIC_BREAK":
|
||||
return <hr key={key} className="document-rule" />;
|
||||
case "IMAGE":
|
||||
/*
|
||||
작성자가 적은 경로를 그대로 쓴다. 경로 검증은 파싱할 때 끝났다(`isSafeLink`).
|
||||
`title` 이 있으면 그림 설명으로 보여 준다 — Markdown 이 제목을 그런 뜻으로 쓴다.
|
||||
*/
|
||||
return (
|
||||
<figure key={key} className="document-image">
|
||||
<img src={block.src} alt={block.alt} loading="lazy" />
|
||||
{block.title ? <figcaption>{block.title}</figcaption> : null}
|
||||
</figure>
|
||||
);
|
||||
default:
|
||||
return assertNever(block);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
|
||||
import type { CatalogEntry } from "../../../contracts/studio/contract.ts";
|
||||
import type { HomeFocusResponse } from "../../../contracts/management/contract.ts";
|
||||
import type { ProjectIndexItem } from "../../../contracts/management/contract.ts";
|
||||
import { managementFailureMessage } from "../../../application/ports/management-gateway-error.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
|
||||
/**
|
||||
* 공개 홈의 "지금 집중하는 것" 을 정하는 화면.
|
||||
*
|
||||
* <p>그 영역은 세 칸(현재 작업·열린 질문·최근 결정)을 가지며, 셋이 모두 비면 홈은 영역 자체를
|
||||
* 그리지 않는다. `home_focus_config` 는 마이그레이션이 빈 행 하나만 넣어 두었고 그 값을 채울
|
||||
* 화면이 없었으므로, 홈에서는 그 영역이 한 번도 나타난 적이 없었다.
|
||||
*
|
||||
* <p>고를 수 있는 것은 실제로 존재하는 기록뿐이다. 질문과 결정은 Studio catalog 의
|
||||
* `RELATION` 목록에서 가져온다 — 그 목록이 곧 "연결 가능한 대상" 의 정의이고, 여기서 다른
|
||||
* 기준을 쓰면 두 화면이 서로 다른 것을 보여 준다.
|
||||
*
|
||||
* <p>비공개 프로젝트도 고를 수 있게 둔다. 미리 지목해 두고 게시와 동시에 홈에 뜨게 하는 것이
|
||||
* 정상적인 순서이기 때문이다. 다만 게시되지 않은 동안에는 홈이 그 칸을 그리지 않으므로, 목록에
|
||||
* 그 사실을 적어 둔다.
|
||||
*/
|
||||
export function HomeFocusEditor() {
|
||||
const { gateway, managementGateway, setRequestAnnouncement } = useStudio();
|
||||
const [focus, setFocus] = useState<HomeFocusResponse | null>(null);
|
||||
const [projects, setProjects] = useState<ProjectIndexItem[]>([]);
|
||||
const [relations, setRelations] = useState<CatalogEntry[]>([]);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [questionId, setQuestionId] = useState("");
|
||||
const [decisionId, setDecisionId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const reload = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void Promise.all([
|
||||
managementGateway.getHomeFocus(),
|
||||
managementGateway.listProjects(0, 50),
|
||||
gateway.getCatalog({ type: "RELATION", limit: 100 }),
|
||||
]).then(
|
||||
([current, projectPage, catalog]) => {
|
||||
if (cancelled) return;
|
||||
setFocus(current);
|
||||
setProjects(projectPage.items ?? []);
|
||||
setRelations(catalog.items ?? []);
|
||||
setProjectId(current.currentProjectId ?? "");
|
||||
setQuestionId(current.openQuestionId ?? "");
|
||||
setDecisionId(current.recentDecisionId ?? "");
|
||||
setError("");
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) setError("홈 설정을 불러오지 못했습니다.");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [gateway, managementGateway, generation]);
|
||||
|
||||
const questions = relations.filter((entry) => entry.kind === "QUESTION");
|
||||
const decisions = relations.filter((entry) => entry.kind === "PROJECT_DECISION");
|
||||
|
||||
const save = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (pending || !focus) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const saved = await managementGateway.updateHomeFocus({
|
||||
expectedVersion: focus.version,
|
||||
// 빈 문자열은 "고르지 않음" 이다. 계약은 uuid 만 받으므로 보내지 않는다.
|
||||
currentProjectId: projectId || undefined,
|
||||
openQuestionId: questionId || undefined,
|
||||
recentDecisionId: decisionId || undefined,
|
||||
});
|
||||
setFocus(saved);
|
||||
setRequestAnnouncement("홈에 표시할 항목을 저장했습니다.");
|
||||
reload();
|
||||
} catch (failure) {
|
||||
setError(managementFailureMessage(failure, "홈 설정을 저장하지 못했습니다."));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const chosenProject = projects.find((project) => project.id === projectId);
|
||||
const projectHidden = chosenProject && chosenProject.targetVisibility === "PRIVATE";
|
||||
const nothingChosen = !projectId && !questionId && !decisionId;
|
||||
|
||||
return (
|
||||
<section className="studio-work-section" aria-labelledby="studio-home-focus-title">
|
||||
<div className="studio-section-title">
|
||||
<h2 id="studio-home-focus-title">홈에 무엇을 띄울까</h2>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="studio-screen-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{!focus && !error ? (
|
||||
<p className="studio-loading" role="status">
|
||||
홈 설정을 불러오는 중입니다.
|
||||
</p>
|
||||
) : null}
|
||||
{focus ? (
|
||||
<form className="studio-home-focus-form" onSubmit={save}>
|
||||
<p className="studio-empty-inline">
|
||||
공개 홈 맨 위 “지금 집중하는 것” 영역입니다. 셋 다 비워 두면 그 영역은
|
||||
나타나지 않습니다.
|
||||
</p>
|
||||
|
||||
<div className="studio-field">
|
||||
<label htmlFor="home-focus-project">현재 작업 (프로젝트)</label>
|
||||
<select
|
||||
className="studio-control"
|
||||
id="home-focus-project"
|
||||
value={projectId}
|
||||
onChange={(event) => setProjectId(event.target.value)}
|
||||
>
|
||||
<option value="">고르지 않음</option>
|
||||
{projects.map((project) => (
|
||||
<option key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
{project.targetVisibility === "PRIVATE" ? " (비공개)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{projectHidden ? (
|
||||
<p className="studio-field-note">
|
||||
이 프로젝트는 아직 비공개입니다. 주제·프로젝트 화면에서 게시해야 홈에 나타납니다.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="studio-field">
|
||||
<label htmlFor="home-focus-question">열린 질문</label>
|
||||
<select
|
||||
className="studio-control"
|
||||
id="home-focus-question"
|
||||
value={questionId}
|
||||
onChange={(event) => setQuestionId(event.target.value)}
|
||||
>
|
||||
<option value="">고르지 않음</option>
|
||||
{questions.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{questions.length === 0 ? (
|
||||
<p className="studio-field-note">아직 Question 문서가 없습니다.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="studio-field">
|
||||
<label htmlFor="home-focus-decision">최근 결정</label>
|
||||
<select
|
||||
className="studio-control"
|
||||
id="home-focus-decision"
|
||||
value={decisionId}
|
||||
onChange={(event) => setDecisionId(event.target.value)}
|
||||
>
|
||||
<option value="">고르지 않음</option>
|
||||
{decisions.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{decisions.length === 0 ? (
|
||||
<p className="studio-field-note">아직 Decision 문서가 없습니다.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{nothingChosen ? (
|
||||
<p className="studio-field-note">
|
||||
지금은 아무것도 고르지 않아 홈에 이 영역이 나타나지 않습니다.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="studio-row-actions">
|
||||
<button className="studio-primary-button" type="submit" disabled={pending}>
|
||||
{pending ? "저장하는 중" : "홈 설정 저장"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -158,26 +158,28 @@ export function ReleaseManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const requestOf = (current: NonNullable<typeof draft>): ReleaseUpdateRequest =>
|
||||
({
|
||||
expectedVersion: current.expectedVersion,
|
||||
versionLabel: current.versionLabel.trim(),
|
||||
title: current.title.trim(),
|
||||
summary: current.summary,
|
||||
changeTypes: [...current.changeTypes],
|
||||
changesMarkdown: current.changesMarkdown,
|
||||
verificationMarkdown: current.verificationMarkdown,
|
||||
...(current.releasedOn ? { releasedOn: current.releasedOn } : {}),
|
||||
reasonMarkdown: current.reasonMarkdown,
|
||||
userImpactMarkdown: current.userImpactMarkdown,
|
||||
implementationImpactMarkdown: current.implementationImpactMarkdown,
|
||||
knownLimitationsMarkdown: current.knownLimitationsMarkdown,
|
||||
}) as ReleaseUpdateRequest;
|
||||
|
||||
const save = async () => {
|
||||
if (pending || draft === null || selectedId === null) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const body: ReleaseUpdateRequest = {
|
||||
expectedVersion: draft.expectedVersion,
|
||||
versionLabel: draft.versionLabel.trim(),
|
||||
title: draft.title.trim(),
|
||||
summary: draft.summary,
|
||||
changeTypes: [...draft.changeTypes],
|
||||
changesMarkdown: draft.changesMarkdown,
|
||||
verificationMarkdown: draft.verificationMarkdown,
|
||||
...(draft.releasedOn ? { releasedOn: draft.releasedOn } : {}),
|
||||
reasonMarkdown: draft.reasonMarkdown,
|
||||
userImpactMarkdown: draft.userImpactMarkdown,
|
||||
implementationImpactMarkdown: draft.implementationImpactMarkdown,
|
||||
knownLimitationsMarkdown: draft.knownLimitationsMarkdown,
|
||||
} as ReleaseUpdateRequest;
|
||||
const saved = await managementGateway.updateRelease(selectedId, body);
|
||||
const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
|
||||
setDraft(toDraft(saved));
|
||||
setRequestAnnouncement(`릴리즈 ${saved.versionLabel} 을(를) 저장했습니다.`);
|
||||
reload();
|
||||
@@ -193,14 +195,24 @@ export function ReleaseManager() {
|
||||
setPending(true);
|
||||
setError("");
|
||||
try {
|
||||
const published = await managementGateway.publishRelease(selectedId, draft.expectedVersion);
|
||||
/*
|
||||
먼저 저장한다. 발행은 서버에 저장된 릴리즈를 검사하는데(`PublishReleaseUseCase`), 예전에는
|
||||
저장하지 않고 발행만 불렀다 — 화면의 칸을 다 채우고 공개를 눌러도 서버 쪽은 여전히 빈
|
||||
초안이라 "모두 채워져야 합니다" 가 떴다. 채웠는데 안 된다는 말이 나온 이유가 이것이다.
|
||||
*/
|
||||
const saved = await managementGateway.updateRelease(selectedId, requestOf(draft));
|
||||
setDraft(toDraft(saved));
|
||||
const published = await managementGateway.publishRelease(selectedId, saved.version);
|
||||
setRequestAnnouncement(`릴리즈를 공개했습니다: ${published.canonicalPath}`);
|
||||
reload();
|
||||
} catch {
|
||||
// 발행은 저장보다 요구가 많다. 무엇이 비었는지는 서버가 알고 있지만, 그 목록을 그대로
|
||||
// 옮기려면 오류 details 를 읽는 화면이 필요하다 — 여기서는 필수 항목을 그대로 안내한다.
|
||||
} catch (error) {
|
||||
// 무엇이 모자란지는 서버가 안다. 예전에는 그 답을 버리고 필수 항목을 전부 나열했는데,
|
||||
// 그러면 이미 채운 칸까지 비었다고 말하게 된다.
|
||||
setError(
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
managementFailureMessage(
|
||||
error,
|
||||
"공개하지 못했습니다. 버전, 제목, 한 줄 요약, 변경 유형, 변경 내용, 검증, 공개일이 모두 채워져야 합니다.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
@@ -341,6 +353,16 @@ export function ReleaseManager() {
|
||||
placeholder="0.1.0"
|
||||
onChange={(event) => update({ versionLabel: event.currentTarget.value })}
|
||||
/>
|
||||
{/*
|
||||
새 릴리즈는 `draft-…` 라는 자리표시자 버전으로 만들어지고, 서버는 그것이 남아
|
||||
있으면 공개를 거절한다(`ReleaseDrafts.isPlaceholder`). 화면에는 값이 채워져
|
||||
보이므로 왜 거절당하는지 알 길이 없었다.
|
||||
*/}
|
||||
{draft.versionLabel.startsWith("draft-") ? (
|
||||
<span className="studio-field-notice studio-field-notice--error" role="alert">
|
||||
자리표시자 버전입니다. 공개하려면 실제 버전으로 바꿔 주세요 (예: 0.2.0).
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>제목</span>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { StudioDashboard as StudioDashboardData } from "../../../contracts/
|
||||
import type { components } from "../../../contracts/studio/generated.ts";
|
||||
import { useStudio } from "../use-studio.ts";
|
||||
import { GuardedStudioLink } from "./guarded-studio-link.tsx";
|
||||
import { HomeFocusEditor } from "./home-focus-editor.tsx";
|
||||
|
||||
type DocumentSummary = components["schemas"]["DocumentSummary"];
|
||||
|
||||
@@ -158,6 +159,7 @@ export function StudioDashboard() {
|
||||
href="/studio/documents"
|
||||
empty="게시 준비가 끝난 문서가 없습니다."
|
||||
/>
|
||||
<HomeFocusEditor />
|
||||
<section className="studio-work-section">
|
||||
<div className="studio-section-title">
|
||||
<h2>최근 게시</h2>
|
||||
|
||||
@@ -159,6 +159,37 @@ export function TaxonomyManager() {
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
프로젝트 게시는 문서 게시와 별개다. 문서를 게시해도 그 문서가 속한 프로젝트는 비공개로 남고,
|
||||
공개 화면들(프로젝트 목록·프로필의 "현재 프로젝트"·홈의 focus)은 전부 게시된 프로젝트만 읽는다.
|
||||
그래서 그 화면들이 조용히 비어 있었다 — 게시할 방법 자체가 없었기 때문이다.
|
||||
*/
|
||||
const togglePublish = async (project: ProjectIndexItem) => {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
const published = project.targetVisibility !== "PRIVATE";
|
||||
try {
|
||||
if (published) {
|
||||
await managementGateway.unpublishProject(project.id, project.version);
|
||||
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 비공개로 되돌렸습니다.`);
|
||||
} else {
|
||||
await managementGateway.publishProject(project.id, project.version, "PUBLIC");
|
||||
setRequestAnnouncement(`프로젝트 ${project.name} 을(를) 공개했습니다.`);
|
||||
}
|
||||
reload();
|
||||
} catch (error) {
|
||||
setError(
|
||||
managementFailureMessage(
|
||||
error,
|
||||
published ? "프로젝트를 비공개로 되돌리지 못했습니다." : "프로젝트를 게시하지 못했습니다.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-page studio-documents-page">
|
||||
<header className="studio-page-top">
|
||||
@@ -279,17 +310,29 @@ export function TaxonomyManager() {
|
||||
</div>
|
||||
<div>
|
||||
<dt>공개</dt>
|
||||
<dd>{project.targetVisibility}</dd>
|
||||
<dd>
|
||||
{project.targetVisibility === "PRIVATE" ? "비공개" : "공개됨"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeProject(project)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<div className="studio-row-actions">
|
||||
<button
|
||||
className="studio-primary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void togglePublish(project)}
|
||||
>
|
||||
{project.targetVisibility === "PRIVATE" ? "게시하기" : "게시 취소"}
|
||||
</button>
|
||||
<button
|
||||
className="studio-secondary-button"
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeProject(project)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,12 @@
|
||||
--warning-soft: #fff7ed;
|
||||
--code-canvas: #15181d;
|
||||
--shell: 1180px;
|
||||
--body-copy: 42rem;
|
||||
/*
|
||||
읽는 단의 폭. 본문·코드블록·표·콜아웃·그림이 모두 이 값을 쓰므로 한 곳만 바꾸면 단 전체가 함께
|
||||
움직인다. 42rem(672px)은 영문 기준 측정값이었고, 한글 본문에서는 좁아 보이는 데다 바로 위의
|
||||
유형/프로젝트/게시 줄이 shell 전체(1180px)를 쓰고 있어 대비가 더 심했다.
|
||||
*/
|
||||
--body-copy: 56rem;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -1329,11 +1334,18 @@ dialog::backdrop {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 모달 dialog 를 화면 가운데에 둔다. UA 기본값(margin:auto)에 맡기면 이 빌드에서는 좌상단에
|
||||
붙는다 — .search-dialog 와 Studio 의 .studio-unsaved-dialog 가 같은 이유로 position/inset/
|
||||
margin 을 명시한다. 여기만 빠져 있어 "크게 보기" 가 왼쪽 위에 열렸다. */
|
||||
.figure-dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: min(1180px, calc(100% - 48px));
|
||||
max-width: none;
|
||||
max-height: calc(100dvh - 48px);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 9px;
|
||||
background: var(--paper);
|
||||
@@ -2174,6 +2186,20 @@ dialog::backdrop {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
/*
|
||||
문서 한 편은 하나의 단으로 읽힌다.
|
||||
|
||||
예전에는 세 조각이 저마다 다른 폭이었다 — 머리말 920px, 사실 줄은 shell 전체 1180px, 본문은
|
||||
672px 를 가운데 정렬. 그래서 눈이 왼쪽 끝을 세 번 다시 찾아야 했고, 가장 좁은 본문이 가장 넓은
|
||||
사실 줄 안에 갇힌 것처럼 보였다. 셋을 같은 폭·같은 왼쪽 끝에 세운다.
|
||||
*/
|
||||
.public-document-header,
|
||||
.document-snapshot,
|
||||
.document-facts {
|
||||
width: min(100%, var(--body-copy));
|
||||
max-width: var(--body-copy);
|
||||
}
|
||||
|
||||
.public-page-header h1,
|
||||
.public-document-header h1 {
|
||||
margin: 15px 0 18px;
|
||||
@@ -2297,7 +2323,7 @@ dialog::backdrop {
|
||||
.reference-purpose,
|
||||
.document-relations {
|
||||
width: min(100%, var(--body-copy));
|
||||
margin: 82px auto 0;
|
||||
margin: 82px 0 0;
|
||||
}
|
||||
.public-document-body > section + section { margin-top: 76px; padding-top: 70px; border-top: 1px solid var(--line); }
|
||||
.public-document-body h2,
|
||||
@@ -2613,3 +2639,29 @@ textarea.studio-control { min-height: 118px; resize: vertical; line-height: 1.65
|
||||
.studio-diff-grid section, .studio-diff-grid section + section { min-height: 0; padding: 23px 0; border-left: 0; }
|
||||
.studio-diff-grid section + section { border-top: 1px solid var(--line); }
|
||||
}
|
||||
|
||||
/* 본문 구분선과 그림. 문서 본문의 다른 블록과 같은 세로 리듬을 따른다. */
|
||||
.document-body > section > .document-rule {
|
||||
margin: 34px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.document-body > section > .document-image {
|
||||
margin: 26px 0;
|
||||
}
|
||||
|
||||
.document-body > section > .document-image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.document-body > section > .document-image figcaption {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,17 @@
|
||||
.studio-app .studio-document-row dt { color: var(--muted); font-size: 11px; }
|
||||
.studio-app .studio-document-row dd { margin: 5px 0 0; font-size: 13px; overflow-wrap: anywhere; }
|
||||
.studio-app .studio-secondary-button { margin-top: 24px; }
|
||||
/*
|
||||
한 행에 버튼이 둘 이상일 때. `.studio-secondary-button` 의 위쪽 여백은 버튼이 하나뿐이던 때의
|
||||
값이라, 묶음 안에서는 묶음이 여백을 갖고 버튼은 나란히 선다.
|
||||
*/
|
||||
.studio-app .studio-row-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 24px; }
|
||||
|
||||
/* 홈 focus 설정. 편집기의 `.studio-field` 를 그대로 쓰되 한 단으로 세운다. */
|
||||
.studio-app .studio-home-focus-form { display: grid; gap: 4px; max-width: 560px; margin-top: 8px; }
|
||||
.studio-app .studio-field-note { margin: 6px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||||
.studio-app .studio-row-actions .studio-primary-button,
|
||||
.studio-app .studio-row-actions .studio-secondary-button { margin: 0; }
|
||||
.studio-app .studio-empty-state { padding-block: 56px; border-bottom: 1px solid var(--line); }
|
||||
.studio-app .studio-empty-state h2 { margin: 0; font-size: 25px; }
|
||||
.studio-app .studio-empty-state p { margin: 12px 0 22px; color: var(--muted); }
|
||||
|
||||
Reference in New Issue
Block a user