for-humanity 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -3
- package/dist/cli.css +66 -54
- package/dist/cli.js +99 -27
- package/package.json +1 -1
- package/src/cli.ts +6 -3
- package/src/component/widget/shell/_wg-shell-nav-list.css +70 -57
- package/src/component/widget/shell/_wg-shell-nav-list.tsx +25 -10
- package/src/component/widget/shell/_wg-shell-nav.css +3 -2
- package/src/component/widget/shell/_wg-shell-toc.css +6 -5
- package/src/component/widget/shell/wg-shell.tsx +3 -3
- package/src/constant/asset.ts +2 -2
- package/src/constant/copy.ts +16 -1
- package/src/content/read-docs.test.ts +142 -3
- package/src/content/read-docs.ts +39 -5
- package/src/content/to-doc-groups/_to-doc-branches.ts +15 -0
- package/src/content/to-doc-groups/_to-doc-group-branches.ts +17 -5
- package/src/content/to-doc-groups/to-doc-groups.ts +2 -1
- package/src/style/token.css +4 -3
- package/src/type/doc-branch.ts +11 -0
- package/src/type/doc-data.ts +4 -0
- package/src/type/doc-group.ts +3 -2
- package/src/type/doc.ts +4 -0
- package/src/type/site-assets.ts +8 -0
- package/src/util/file/to-asset-path.ts +26 -0
|
@@ -13,8 +13,30 @@
|
|
|
13
13
|
display: block;
|
|
14
14
|
padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
|
|
15
15
|
color: var(--app-color-text-muted);
|
|
16
|
-
font-size: var(--app-font-size-
|
|
16
|
+
font-size: var(--app-font-size-group);
|
|
17
|
+
line-height: calc(var(--app-font-size-dense) * var(--app-line-height-tight));
|
|
18
|
+
overflow-wrap: anywhere;
|
|
19
|
+
|
|
20
|
+
&::before {
|
|
21
|
+
position: absolute;
|
|
22
|
+
top: calc(0.5lh + var(--app-space-tree-row));
|
|
23
|
+
left: 0;
|
|
24
|
+
width: var(--app-space-tree-indent);
|
|
25
|
+
border-top: 1px solid var(--app-color-border-soft);
|
|
26
|
+
content: "";
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.wg_shellNavList__link {
|
|
32
|
+
/* 가로 가지는 링크 첫 줄 기준 · 여러 줄 제목에서도 연결 위치 유지 */
|
|
33
|
+
position: relative;
|
|
34
|
+
display: block;
|
|
35
|
+
padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
|
|
36
|
+
color: var(--app-color-link);
|
|
37
|
+
font-size: var(--app-font-size-dense);
|
|
17
38
|
line-height: var(--app-line-height-tight);
|
|
39
|
+
text-decoration: none;
|
|
18
40
|
overflow-wrap: anywhere;
|
|
19
41
|
|
|
20
42
|
&::before {
|
|
@@ -26,6 +48,33 @@
|
|
|
26
48
|
content: "";
|
|
27
49
|
pointer-events: none;
|
|
28
50
|
}
|
|
51
|
+
|
|
52
|
+
&:where(:hover) {
|
|
53
|
+
text-decoration: underline;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&:where(:hover)::before {
|
|
57
|
+
border-top-color: currentcolor;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* hover와 같은 밑줄·가지선으로 키보드 위치 표시 */
|
|
61
|
+
&:where(:focus-visible) {
|
|
62
|
+
outline: none;
|
|
63
|
+
text-decoration: underline;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
&:where(:focus-visible)::before {
|
|
67
|
+
border-top-color: currentcolor;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.wg_shellNavList__link--active {
|
|
72
|
+
color: var(--app-color-link);
|
|
73
|
+
font-weight: var(--app-font-weight-active);
|
|
74
|
+
|
|
75
|
+
&::before {
|
|
76
|
+
border-top-color: currentcolor;
|
|
77
|
+
}
|
|
29
78
|
}
|
|
30
79
|
|
|
31
80
|
.wg_shellNavList__item {
|
|
@@ -62,20 +111,20 @@
|
|
|
62
111
|
}
|
|
63
112
|
|
|
64
113
|
&:where(:hover)::after {
|
|
65
|
-
border-left-color: var(--app-color-
|
|
114
|
+
border-left-color: var(--app-color-link);
|
|
66
115
|
}
|
|
67
116
|
|
|
68
117
|
&:where(:has(:focus-visible))::after {
|
|
69
|
-
border-left-color: var(--app-color-
|
|
118
|
+
border-left-color: var(--app-color-link);
|
|
70
119
|
}
|
|
71
120
|
|
|
72
121
|
/* 뒤 항목까지의 세로 경로 · 같은 목록의 형제 안에서만 연결 */
|
|
73
122
|
&:where(:has(~ .wg_shellNavList__item:hover))::before {
|
|
74
|
-
border-left-color: var(--app-color-
|
|
123
|
+
border-left-color: var(--app-color-link);
|
|
75
124
|
}
|
|
76
125
|
|
|
77
126
|
&:where(:has(~ .wg_shellNavList__item :focus-visible))::before {
|
|
78
|
-
border-left-color: var(--app-color-
|
|
127
|
+
border-left-color: var(--app-color-link);
|
|
79
128
|
}
|
|
80
129
|
|
|
81
130
|
& > .wg_shellNavList__root {
|
|
@@ -84,75 +133,39 @@
|
|
|
84
133
|
|
|
85
134
|
/* 하위 문서를 탐색할 때 조상 묶음의 가지도 함께 연결 */
|
|
86
135
|
&:where(:hover) > .wg_shellNavList__branch::before {
|
|
87
|
-
border-top-color: var(--app-color-
|
|
136
|
+
border-top-color: var(--app-color-link);
|
|
88
137
|
}
|
|
89
138
|
|
|
90
139
|
&:where(:has(:focus-visible)) > .wg_shellNavList__branch::before {
|
|
91
|
-
border-top-color: var(--app-color-
|
|
140
|
+
border-top-color: var(--app-color-link);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* 자식 문서를 탐색할 때 클릭 가능한 부모 문서의 가지도 연결 */
|
|
144
|
+
&:where(:hover) > .wg_shellNavList__link::before {
|
|
145
|
+
border-top-color: var(--app-color-link);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
&:where(:has(:focus-visible)) > .wg_shellNavList__link::before {
|
|
149
|
+
border-top-color: var(--app-color-link);
|
|
92
150
|
}
|
|
93
151
|
}
|
|
94
152
|
|
|
95
153
|
.wg_shellNavList__item--trail {
|
|
96
154
|
&::before {
|
|
97
|
-
border-left-color: var(--app-color-
|
|
155
|
+
border-left-color: var(--app-color-link);
|
|
98
156
|
}
|
|
99
157
|
}
|
|
100
158
|
|
|
101
159
|
.wg_shellNavList__item--current {
|
|
102
160
|
&::after {
|
|
103
|
-
border-left-color: var(--app-color-
|
|
161
|
+
border-left-color: var(--app-color-link);
|
|
104
162
|
}
|
|
105
163
|
|
|
106
164
|
& > .wg_shellNavList__branch::before {
|
|
107
|
-
border-top-color: var(--app-color-
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
.wg_shellNavList__link {
|
|
112
|
-
/* 가로 가지는 링크 첫 줄 기준 · 여러 줄 제목에서도 연결 위치 유지 */
|
|
113
|
-
position: relative;
|
|
114
|
-
display: block;
|
|
115
|
-
padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
|
|
116
|
-
color: var(--app-color-text);
|
|
117
|
-
font-size: var(--app-font-size-dense);
|
|
118
|
-
line-height: var(--app-line-height-tight);
|
|
119
|
-
text-decoration: none;
|
|
120
|
-
overflow-wrap: anywhere;
|
|
121
|
-
|
|
122
|
-
&::before {
|
|
123
|
-
position: absolute;
|
|
124
|
-
top: calc(0.5lh + var(--app-space-tree-row));
|
|
125
|
-
left: 0;
|
|
126
|
-
width: var(--app-space-tree-indent);
|
|
127
|
-
border-top: 1px solid var(--app-color-border-soft);
|
|
128
|
-
content: "";
|
|
129
|
-
pointer-events: none;
|
|
165
|
+
border-top-color: var(--app-color-link);
|
|
130
166
|
}
|
|
131
167
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
&:where(:hover)::before {
|
|
137
|
-
border-top-color: currentcolor;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/* hover와 같은 밑줄·가지선으로 키보드 위치 표시 */
|
|
141
|
-
&:where(:focus-visible) {
|
|
142
|
-
outline: none;
|
|
143
|
-
text-decoration: underline;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
&:where(:focus-visible)::before {
|
|
147
|
-
border-top-color: currentcolor;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
.wg_shellNavList__link--active {
|
|
152
|
-
color: var(--app-color-text-strong);
|
|
153
|
-
font-weight: var(--app-font-weight-active);
|
|
154
|
-
|
|
155
|
-
&::before {
|
|
156
|
-
border-top-color: currentcolor;
|
|
168
|
+
& > .wg_shellNavList__link::before {
|
|
169
|
+
border-top-color: var(--app-color-link);
|
|
157
170
|
}
|
|
158
171
|
}
|
|
@@ -19,8 +19,12 @@ export interface WgShellNavListProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export const WgShellNavList = (props: WgShellNavListProps) => {
|
|
22
|
+
const currentAncestors = new Set(props.currentDoc?.ancestors);
|
|
22
23
|
const entries = [
|
|
23
|
-
...props.group.docs.map((doc) =>
|
|
24
|
+
...props.group.docs.map((doc) => {
|
|
25
|
+
const active = doc.id === props.currentDoc?.id;
|
|
26
|
+
return {kind: "document" as const, key: `doc:${doc.id}`, doc, active, current: active || currentAncestors.has(doc.id)};
|
|
27
|
+
}),
|
|
24
28
|
...props.group.groups.map((group) => ({
|
|
25
29
|
kind: "group" as const,
|
|
26
30
|
key: `group:${JSON.stringify(group.path)}`,
|
|
@@ -44,17 +48,28 @@ export const WgShellNavList = (props: WgShellNavListProps) => {
|
|
|
44
48
|
key={entry.key}
|
|
45
49
|
>
|
|
46
50
|
{/**
|
|
47
|
-
*
|
|
51
|
+
* 문서 링크 · 부모도 독립 페이지, 현재 한 문서에만 페이지 상태 부여
|
|
48
52
|
*/}
|
|
49
53
|
{entry.kind === "document" && (
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
<Fragment>
|
|
55
|
+
<a
|
|
56
|
+
className={clsx("wg_shellNavList__link", {"wg_shellNavList__link--active": entry.active})}
|
|
57
|
+
href={`/${entry.doc.id}/`}
|
|
58
|
+
title={entry.doc.data.label}
|
|
59
|
+
aria-current={entry.active ? "page" : undefined}
|
|
60
|
+
>
|
|
61
|
+
{entry.doc.data.name}
|
|
62
|
+
</a>
|
|
63
|
+
{/**
|
|
64
|
+
* 하위 문서 · 부모 아래에서도 기존 목록의 들여쓰기와 키보드 이동 유지
|
|
65
|
+
*/}
|
|
66
|
+
{entry.doc.children !== undefined && (
|
|
67
|
+
<WgShellNavList
|
|
68
|
+
group={{name: entry.doc.data.name, path: entry.doc.data.group, docs: entry.doc.children, groups: []}}
|
|
69
|
+
currentDoc={props.currentDoc}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
</Fragment>
|
|
58
73
|
)}
|
|
59
74
|
{/**
|
|
60
75
|
* 하위 묶음 · 같은 목록을 재귀로 이어 깊이에 따라 들여쓰기
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* 읽는 순서의 문서 목록 ·
|
|
2
|
+
* 읽는 순서의 문서 목록 · 작은 회색 묶음과 링크색 문서
|
|
3
3
|
*/
|
|
4
4
|
.wg_shellNav__root {
|
|
5
5
|
font-size: var(--app-font-size-dense);
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
.wg_shellNav__group {
|
|
16
16
|
margin: 0 0 4px;
|
|
17
17
|
color: var(--app-color-text-muted);
|
|
18
|
-
font-size: var(--app-font-size-
|
|
18
|
+
font-size: var(--app-font-size-group);
|
|
19
|
+
line-height: calc(var(--app-font-size-dense) * var(--app-line-height-tight));
|
|
19
20
|
text-box: trim-start cap alphabetic;
|
|
20
21
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* 문서 탐색 아래 목차 ·
|
|
2
|
+
* 문서 탐색 아래 목차 · 회색 제목·묶음과 본문 글색의 이동 대상 구분
|
|
3
3
|
*/
|
|
4
4
|
.wg_shellToc__root {
|
|
5
5
|
margin-top: var(--app-space-group);
|
|
@@ -13,14 +13,15 @@
|
|
|
13
13
|
margin-bottom: var(--app-space-inline);
|
|
14
14
|
color: var(--app-color-text-muted);
|
|
15
15
|
font-size: var(--app-font-size-label);
|
|
16
|
-
font-weight: var(--app-font-weight-
|
|
16
|
+
font-weight: var(--app-font-weight-strong);
|
|
17
17
|
letter-spacing: var(--app-letter-spacing-label);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
.wg_shellToc__group {
|
|
21
21
|
margin: var(--app-space-stack) 0 4px;
|
|
22
22
|
color: var(--app-color-text-muted);
|
|
23
|
-
font-size: var(--app-font-size-
|
|
23
|
+
font-size: var(--app-font-size-group);
|
|
24
|
+
line-height: calc(var(--app-font-size-dense) * var(--app-line-height-tight));
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
.wg_shellToc__list {
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
position: relative;
|
|
35
36
|
display: block;
|
|
36
37
|
padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
|
|
37
|
-
color: var(--app-color-text
|
|
38
|
+
color: var(--app-color-text);
|
|
38
39
|
font-size: var(--app-font-size-dense);
|
|
39
40
|
line-height: var(--app-line-height-tight);
|
|
40
41
|
text-decoration: none;
|
|
@@ -222,7 +223,7 @@
|
|
|
222
223
|
position: relative;
|
|
223
224
|
display: block;
|
|
224
225
|
padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
|
|
225
|
-
color: var(--app-color-text
|
|
226
|
+
color: var(--app-color-text);
|
|
226
227
|
font-size: var(--app-font-size-detail);
|
|
227
228
|
line-height: var(--app-line-height-tight);
|
|
228
229
|
text-decoration: none;
|
|
@@ -4,7 +4,7 @@ import {toTocGroups} from "@/component/widget/shell/_function/to-toc-groups";
|
|
|
4
4
|
import type {DocOutline} from "@/component/widget/shell/_type/doc-outline";
|
|
5
5
|
import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
|
|
6
6
|
import {WgShellToc} from "@/component/widget/shell/_wg-shell-toc";
|
|
7
|
-
import {
|
|
7
|
+
import {asset_favicon_path, asset_reload_path} from "@/constant/asset";
|
|
8
8
|
import {copy_nav_close, copy_nav_open, copy_nav_title} from "@/constant/copy";
|
|
9
9
|
import type {Doc} from "@/type/doc";
|
|
10
10
|
import type {SiteAssets} from "@/type/site-assets";
|
|
@@ -71,7 +71,7 @@ export const WgShell = (props: WgShellProps) => {
|
|
|
71
71
|
<link key={href} rel="preload" href={href} as="font" type="font/woff2" crossOrigin="" />
|
|
72
72
|
))}
|
|
73
73
|
<script dangerouslySetInnerHTML={{__html: 'document.documentElement.style.scrollBehavior="auto"'}} />
|
|
74
|
-
<link rel="stylesheet" href={
|
|
74
|
+
<link rel="stylesheet" href={props.assets.stylePath} />
|
|
75
75
|
{props.assets.reload && <script dangerouslySetInnerHTML={{__html: reloadScript}} />}
|
|
76
76
|
</head>
|
|
77
77
|
{/**
|
|
@@ -140,7 +140,7 @@ export const WgShell = (props: WgShellProps) => {
|
|
|
140
140
|
<div className={clsx("wg_shell__drawerContent")} data-navigation-content="" />
|
|
141
141
|
</dialog>
|
|
142
142
|
<img className={clsx("wg_shell__cursorFace")} src={asset_favicon_path} width="24" height="24" alt="" aria-hidden="true" draggable={false} data-cursor-face="" />
|
|
143
|
-
<script type="module" src={
|
|
143
|
+
<script type="module" src={props.assets.clientPath} />
|
|
144
144
|
</body>
|
|
145
145
|
</html>
|
|
146
146
|
);
|
package/src/constant/asset.ts
CHANGED
|
@@ -8,12 +8,12 @@
|
|
|
8
8
|
export const asset_dir = "_fh";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* esbuild의
|
|
11
|
+
* 내용 지문을 붙일 esbuild CSS의 기본 URL
|
|
12
12
|
*/
|
|
13
13
|
export const asset_style_path = `/${asset_dir}/style.css`;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* 테마·목차·hash 보정
|
|
16
|
+
* 내용 지문을 붙일 테마·목차·hash 보정 스크립트의 기본 URL
|
|
17
17
|
*/
|
|
18
18
|
export const asset_client_path = `/${asset_dir}/client.js`;
|
|
19
19
|
|
package/src/constant/copy.ts
CHANGED
|
@@ -35,7 +35,7 @@ export const copy_home_missing = "문서 폴더에 README.md를 추가하면 이
|
|
|
35
35
|
/**
|
|
36
36
|
* 현재 문서 목차 라벨
|
|
37
37
|
*/
|
|
38
|
-
export const copy_nav_toc_label = "
|
|
38
|
+
export const copy_nav_toc_label = "On this page";
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* 대소문자만 다른 README 중복 오류
|
|
@@ -82,6 +82,21 @@ export const copy_error_doc_id = "문서 id에 사용할 수 없는 경로";
|
|
|
82
82
|
*/
|
|
83
83
|
export const copy_error_doc_id_clash = "문서 id가 겹친다";
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* 누락되거나 홈을 가리키는 부모 문서 참조
|
|
87
|
+
*/
|
|
88
|
+
export const copy_error_doc_parent_missing = "부모 문서가 없다";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 자기 참조 또는 순환하는 문서 부모 경로
|
|
92
|
+
*/
|
|
93
|
+
export const copy_error_doc_parent_cycle = "문서 parent가 순환한다";
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 분류 경로가 다른 문서 사이의 부모 참조
|
|
97
|
+
*/
|
|
98
|
+
export const copy_error_doc_parent_group = "부모 문서와 group이 다르다";
|
|
99
|
+
|
|
85
100
|
/**
|
|
86
101
|
* 흐름도 렌더링 실패
|
|
87
102
|
*/
|
|
@@ -6,6 +6,7 @@ import {test} from "node:test";
|
|
|
6
6
|
import {renderToStaticMarkup} from "react-dom/server";
|
|
7
7
|
import {createApp} from "@/app";
|
|
8
8
|
import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
|
|
9
|
+
import {asset_client_path, asset_style_path} from "@/constant/asset";
|
|
9
10
|
import {createProcessor} from "@/content/create-processor";
|
|
10
11
|
import {readDocs} from "@/content/read-docs";
|
|
11
12
|
import {readHome} from "@/content/read-home";
|
|
@@ -13,6 +14,17 @@ import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
|
|
|
13
14
|
import {docDataSchema} from "@/type/doc-data";
|
|
14
15
|
import {siteConfigSchema} from "@/type/site-config";
|
|
15
16
|
|
|
17
|
+
test("page resource URLs use the supplied content fingerprints instead of fixed cache keys", async () => {
|
|
18
|
+
const site = siteConfigSchema.parse({});
|
|
19
|
+
const assets = {fontCss: "", preload: [], reload: false, stylePath: "/_fh/style.abc123.css", clientPath: "/_fh/client.def456.js"};
|
|
20
|
+
const app = createApp({site, store: {docs: []}, assets});
|
|
21
|
+
const html = await (await app.request("/")).text();
|
|
22
|
+
|
|
23
|
+
assert.match(html, /rel="stylesheet" href="\/_fh\/style\.abc123\.css"/);
|
|
24
|
+
assert.match(html, /type="module" src="\/_fh\/client\.def456\.js"/);
|
|
25
|
+
assert.doesNotMatch(html, /(?:href|src)="\/_fh\/(?:style\.css|client\.js)"/);
|
|
26
|
+
});
|
|
27
|
+
|
|
16
28
|
test("documents with the same initial retain distinct URLs", async (t) => {
|
|
17
29
|
const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
|
|
18
30
|
const site = siteConfigSchema.parse({});
|
|
@@ -148,6 +160,133 @@ test("nested groups follow metadata paths and keep sibling branches and document
|
|
|
148
160
|
assert.doesNotMatch(html, /<details\b/);
|
|
149
161
|
});
|
|
150
162
|
|
|
163
|
+
test("parent is an optional nonempty document ID", () => {
|
|
164
|
+
const data = {name: "Child", label: "Child", group: "Guide"};
|
|
165
|
+
|
|
166
|
+
assert.equal(docDataSchema.parse(data).parent, undefined);
|
|
167
|
+
assert.equal(docDataSchema.parse({...data, parent: " guide/settings "}).parent, "guide/settings");
|
|
168
|
+
for (const parent of ["", " ", null, 1, [], ["guide"]]) {
|
|
169
|
+
assert.equal(docDataSchema.safeParse({...data, parent}).success, false);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("parent documents contain ordered descendants with independent URLs and one current page", async (t) => {
|
|
174
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
|
|
175
|
+
const site = siteConfigSchema.parse({navigation: [["Projects", "Atlas", "Guide"]]});
|
|
176
|
+
|
|
177
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
178
|
+
await mkdir(join(root, "guide"));
|
|
179
|
+
await Promise.all([
|
|
180
|
+
writeFile(join(root, "settings.md"), "---\nname: Settings\nlabel: Settings\ngroup: [Projects, Atlas, Guide]\norder: 10\n---\n\nSettings introduction.\n"),
|
|
181
|
+
writeFile(join(root, "guide/site.md"), "---\nname: Site\nlabel: Site\ngroup: [Projects, Atlas, Guide]\nparent: settings\norder: 20\n---\n\nSite introduction.\n"),
|
|
182
|
+
writeFile(join(root, "themes.md"), "---\nname: Themes\nlabel: Themes\ngroup: [Projects, Atlas, Guide]\nparent: settings\norder: 10\n---\n\nThemes introduction.\n"),
|
|
183
|
+
writeFile(join(root, "details.md"), "---\nname: Details\nlabel: Details\ngroup: [Projects, Atlas, Guide]\nparent: guide/site\n---\n\nDeep child.\n"),
|
|
184
|
+
writeFile(join(root, "writing.md"), "---\nname: Writing\nlabel: Writing\ngroup: [Projects, Atlas, Guide]\norder: 20\n---\n\nIndependent guide.\n"),
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
188
|
+
const groups = toDocGroups({docs, navigation: site.navigation});
|
|
189
|
+
const guide = groups[0].groups[0].groups[0];
|
|
190
|
+
|
|
191
|
+
assert.deepEqual(
|
|
192
|
+
guide.docs.map((doc) => doc.id),
|
|
193
|
+
["settings", "writing"],
|
|
194
|
+
);
|
|
195
|
+
assert.ok(guide.docs[0].children);
|
|
196
|
+
assert.deepEqual(
|
|
197
|
+
guide.docs[0].children.map((doc) => doc.id),
|
|
198
|
+
["themes", "guide/site"],
|
|
199
|
+
);
|
|
200
|
+
assert.ok(guide.docs[0].children[1].children);
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
guide.docs[0].children[1].children.map((doc) => doc.id),
|
|
203
|
+
["details"],
|
|
204
|
+
);
|
|
205
|
+
assert.deepEqual(docs.find((doc) => doc.id === "details")?.ancestors, ["settings", "guide/site"]);
|
|
206
|
+
assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
|
|
207
|
+
|
|
208
|
+
const html = renderToStaticMarkup(WgShellNav({site, docs, current: "details"}));
|
|
209
|
+
assert.match(html, /href="\/settings\/"[^>]*>Settings<\/a><ul\b[^>]*aria-label="Settings"/);
|
|
210
|
+
assert.match(html, /href="\/guide\/site\/"[^>]*>Site<\/a><ul\b[^>]*aria-label="Site"/);
|
|
211
|
+
assert.match(html, /href="\/details\/"[^>]*aria-current="page"/);
|
|
212
|
+
assert.equal([...html.matchAll(/aria-current="page"/g)].length, 1);
|
|
213
|
+
assert.equal([...html.matchAll(/wg_shellNavList__link--active/g)].length, 1);
|
|
214
|
+
assert.equal([...html.matchAll(/wg_shellNavList__item--current/g)].length, 5);
|
|
215
|
+
for (const doc of docs) {
|
|
216
|
+
assert.equal(html.split(`href="/${doc.id}/"`).length - 1, 1);
|
|
217
|
+
}
|
|
218
|
+
assert.ok(html.indexOf('href="/themes/"') < html.indexOf('href="/guide/site/"'));
|
|
219
|
+
assert.doesNotMatch(renderToStaticMarkup(WgShellNav({site, docs})), /aria-current="page"|wg_shellNavList__item--current/);
|
|
220
|
+
|
|
221
|
+
const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
|
|
222
|
+
for (const doc of docs) {
|
|
223
|
+
assert.equal((await app.request(`/${doc.id}/`)).status, 200);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("relative parent IDs work with both the whole collection and a project document root", async (t) => {
|
|
228
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
|
|
229
|
+
const site = siteConfigSchema.parse({});
|
|
230
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
231
|
+
|
|
232
|
+
await mkdir(join(root, "project/research"), {recursive: true});
|
|
233
|
+
await mkdir(join(root, "project/guide"));
|
|
234
|
+
await Promise.all([
|
|
235
|
+
writeFile(join(root, "project/research/README.md"), "---\nname: Research\nlabel: Research\ngroup: Guide\n---\n\nResearch introduction.\n"),
|
|
236
|
+
writeFile(join(root, "project/research/parts.md"), "---\nname: Parts\nlabel: Parts\ngroup: Guide\nparent: ./readme\n---\n\nText.\n"),
|
|
237
|
+
writeFile(join(root, "project/guide/authoring.md"), "---\nname: Authoring\nlabel: Authoring\ngroup: Guide\nparent: ../research/readme\n---\n\nText.\n"),
|
|
238
|
+
]);
|
|
239
|
+
for (const scope of [root, join(root, "project")]) {
|
|
240
|
+
const docs = await readDocs({root: scope, processor: createProcessor({site, root: scope})});
|
|
241
|
+
const parentId = scope === root ? "project/research/readme" : "research/readme";
|
|
242
|
+
const groups = toDocGroups({docs, navigation: site.navigation});
|
|
243
|
+
assert.deepEqual(
|
|
244
|
+
groups[0].docs.map((doc) => doc.id),
|
|
245
|
+
[parentId],
|
|
246
|
+
);
|
|
247
|
+
assert.ok(groups[0].docs[0].children);
|
|
248
|
+
assert.equal(groups[0].docs[0].children.length, 2);
|
|
249
|
+
assert.ok(groups[0].docs[0].children.every((doc) => doc.data.parent === parentId));
|
|
250
|
+
assert.ok(groups[0].docs[0].children.every((doc) => doc.ancestors.length === 1 && doc.ancestors[0] === parentId));
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("missing and home document parents fail when loading documents", async (t) => {
|
|
255
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
|
|
256
|
+
const site = siteConfigSchema.parse({});
|
|
257
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
258
|
+
|
|
259
|
+
await writeFile(join(root, "README.md"), "# Home\n");
|
|
260
|
+
for (const parent of ["missing", "readme", "guide.md", "/guide/"]) {
|
|
261
|
+
await writeFile(join(root, "child.md"), `---\nname: Child\nlabel: Child\ngroup: Guide\nparent: ${parent}\n---\n\nText.\n`);
|
|
262
|
+
await assert.rejects(readDocs({root, processor: createProcessor({site, root})}), /부모 문서가 없다: child/);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("self references and cycles fail when loading documents", async (t) => {
|
|
267
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
|
|
268
|
+
const site = siteConfigSchema.parse({});
|
|
269
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
270
|
+
|
|
271
|
+
await writeFile(join(root, "first.md"), "---\nname: First\nlabel: First\ngroup: Guide\nparent: first\n---\n\nText.\n");
|
|
272
|
+
await assert.rejects(readDocs({root, processor: createProcessor({site, root})}), /문서 parent가 순환한다/);
|
|
273
|
+
|
|
274
|
+
await writeFile(join(root, "first.md"), "---\nname: First\nlabel: First\ngroup: Guide\nparent: second\n---\n\nText.\n");
|
|
275
|
+
await writeFile(join(root, "second.md"), "---\nname: Second\nlabel: Second\ngroup: Guide\nparent: third\n---\n\nText.\n");
|
|
276
|
+
await writeFile(join(root, "third.md"), "---\nname: Third\nlabel: Third\ngroup: Guide\nparent: first\n---\n\nText.\n");
|
|
277
|
+
await assert.rejects(readDocs({root, processor: createProcessor({site, root})}), /문서 parent가 순환한다/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("parent and child must use the same complete group path", async (t) => {
|
|
281
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-parents-"));
|
|
282
|
+
const site = siteConfigSchema.parse({});
|
|
283
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
284
|
+
|
|
285
|
+
await writeFile(join(root, "parent.md"), "---\nname: Parent\nlabel: Parent\ngroup: [Projects, Atlas, Research]\n---\n\nText.\n");
|
|
286
|
+
await writeFile(join(root, "child.md"), "---\nname: Child\nlabel: Child\ngroup: [Projects, Beacon, Research]\nparent: parent\n---\n\nText.\n");
|
|
287
|
+
await assert.rejects(readDocs({root, processor: createProcessor({site, root})}), /부모 문서와 group이 다르다: child/);
|
|
288
|
+
});
|
|
289
|
+
|
|
151
290
|
test("frontmatter supports BOM, CRLF and a closing fence at EOF", async (t) => {
|
|
152
291
|
const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
|
|
153
292
|
const site = siteConfigSchema.parse({});
|
|
@@ -201,7 +340,7 @@ test("a plain root README renders at home and relative home links resolve from n
|
|
|
201
340
|
]);
|
|
202
341
|
|
|
203
342
|
const [docs, home] = await Promise.all([readDocs({root, processor}), readHome({root, processor})]);
|
|
204
|
-
const app = createApp({site, store: {docs, home}, assets: {fontCss: "", preload: [], reload: false}});
|
|
343
|
+
const app = createApp({site, store: {docs, home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
|
|
205
344
|
const response = await app.request("/");
|
|
206
345
|
const html = await response.text();
|
|
207
346
|
|
|
@@ -230,7 +369,7 @@ test("a missing README provides a creation hint without generated overview cards
|
|
|
230
369
|
|
|
231
370
|
t.after(() => rm(root, {recursive: true, force: true}));
|
|
232
371
|
const home = await readHome({root, processor: createProcessor({site, root})});
|
|
233
|
-
const app = createApp({site, store: {docs: [], home}, assets: {fontCss: "", preload: [], reload: false}});
|
|
372
|
+
const app = createApp({site, store: {docs: [], home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
|
|
234
373
|
const html = await (await app.request("/")).text();
|
|
235
374
|
|
|
236
375
|
assert.equal(home, undefined);
|
|
@@ -309,7 +448,7 @@ test("headings and contents preserve authored text without automatic numbering",
|
|
|
309
448
|
);
|
|
310
449
|
|
|
311
450
|
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
312
|
-
const app = createApp({site, store: {docs}, assets: {fontCss: "", preload: [], reload: false}});
|
|
451
|
+
const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
|
|
313
452
|
const html = await (await app.request("/guide/")).text();
|
|
314
453
|
|
|
315
454
|
assert.match(html, /<h2[^>]*id="overview"[^>]*><span class="wg_prose__headingText">Overview<\/span><\/h2>/);
|
package/src/content/read-docs.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import {readdir, readFile} from "node:fs/promises";
|
|
2
|
-
import {join, sep} from "node:path";
|
|
2
|
+
import {join, posix, sep} from "node:path";
|
|
3
3
|
import {VFile} from "vfile";
|
|
4
4
|
import {parse as parseYaml} from "yaml";
|
|
5
5
|
import {asset_dir, asset_favicon_path} from "@/constant/asset";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
copy_error_doc_id,
|
|
8
|
+
copy_error_doc_id_clash,
|
|
9
|
+
copy_error_doc_parent_cycle,
|
|
10
|
+
copy_error_doc_parent_group,
|
|
11
|
+
copy_error_doc_parent_missing,
|
|
12
|
+
copy_error_frontmatter,
|
|
13
|
+
} from "@/constant/copy";
|
|
7
14
|
import type {Processor} from "@/content/create-processor";
|
|
8
15
|
import type {Doc} from "@/type/doc";
|
|
9
16
|
import {docDataSchema} from "@/type/doc-data";
|
|
@@ -49,8 +56,14 @@ export const readDocs = async (options: {root: string; processor: Processor}): P
|
|
|
49
56
|
throw new Error(`${copy_error_frontmatter}: ${path}: ${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
// 상대 부모 id는 파일 위치 기준으로 한 번 해소 · 문서 루트를 바꿔도 같은 관계 유지
|
|
60
|
+
const data = {
|
|
61
|
+
...parsed.data,
|
|
62
|
+
parent: parsed.data.parent !== undefined && /^\.\.?\//.test(parsed.data.parent) ? posix.join(posix.dirname(id), parsed.data.parent) : parsed.data.parent,
|
|
63
|
+
};
|
|
64
|
+
|
|
52
65
|
// 플러그인의 제목·절 정보 공유
|
|
53
|
-
const fh: DocFileData = {frontmatter:
|
|
66
|
+
const fh: DocFileData = {frontmatter: data, headings: [], sections: []};
|
|
54
67
|
file.data.fh = fh;
|
|
55
68
|
|
|
56
69
|
// YAML 노드만 제외 · 본문 위치와 오류의 원본 줄·열·offset 유지
|
|
@@ -59,7 +72,8 @@ export const readDocs = async (options: {root: string; processor: Processor}): P
|
|
|
59
72
|
|
|
60
73
|
return {
|
|
61
74
|
id,
|
|
62
|
-
data
|
|
75
|
+
data,
|
|
76
|
+
ancestors: [],
|
|
63
77
|
html: options.processor.stringify(rendered, file),
|
|
64
78
|
outline: {headings: fh.headings, sections: fh.sections},
|
|
65
79
|
};
|
|
@@ -71,5 +85,25 @@ export const readDocs = async (options: {root: string; processor: Processor}): P
|
|
|
71
85
|
throw new Error(`${copy_error_doc_id_clash}: ${duplicateIds.map((entry) => entry[0]).join(", ")}`);
|
|
72
86
|
}
|
|
73
87
|
|
|
74
|
-
|
|
88
|
+
// 모든 파일을 수집한 뒤 부모 참조 검증 · 파일 읽기 순서와 무관한 문서 경로
|
|
89
|
+
const docsById = new Map(docs.map((doc) => [doc.id, doc]));
|
|
90
|
+
return docs.map((doc) => {
|
|
91
|
+
const ancestors = new Set([doc.id]);
|
|
92
|
+
for (let parentId = doc.data.parent; parentId !== undefined; ) {
|
|
93
|
+
const parent = docsById.get(parentId);
|
|
94
|
+
if (parent === undefined) {
|
|
95
|
+
throw new Error(`${copy_error_doc_parent_missing}: ${doc.id} → ${parentId}`);
|
|
96
|
+
}
|
|
97
|
+
if (ancestors.has(parent.id)) {
|
|
98
|
+
throw new Error(`${copy_error_doc_parent_cycle}: ${[...ancestors, parent.id].join(" → ")}`);
|
|
99
|
+
}
|
|
100
|
+
if (JSON.stringify(parent.data.group) !== JSON.stringify(doc.data.group)) {
|
|
101
|
+
throw new Error(`${copy_error_doc_parent_group}: ${doc.id} → ${parent.id}`);
|
|
102
|
+
}
|
|
103
|
+
ancestors.add(parent.id);
|
|
104
|
+
parentId = parent.data.parent;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {...doc, ancestors: [...ancestors].slice(1).toReversed()};
|
|
108
|
+
});
|
|
75
109
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {locale_doc_name} from "@/constant/locale";
|
|
2
|
+
import type {Doc} from "@/type/doc";
|
|
3
|
+
import type {DocBranch} from "@/type/doc-branch";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 검증된 부모 관계로 문서 가지를 재귀 구성 · 형제 순서와 URL 유지
|
|
7
|
+
*/
|
|
8
|
+
export const toDocBranches = (options: {docs: readonly Doc[]; children: ReadonlyMap<Doc["data"]["parent"], Doc[]>}): DocBranch[] => {
|
|
9
|
+
return options.docs
|
|
10
|
+
.toSorted((a, b) => a.data.order - b.data.order || a.data.name.localeCompare(b.data.name, locale_doc_name) || a.id.localeCompare(b.id, locale_doc_name))
|
|
11
|
+
.map((doc) => {
|
|
12
|
+
const children = options.children.get(doc.id);
|
|
13
|
+
return {...doc, children: children === undefined ? undefined : toDocBranches({docs: children, children: options.children})};
|
|
14
|
+
});
|
|
15
|
+
};
|