for-humanity 0.1.2 → 0.2.1
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 +11 -0
- package/README.md +4 -3
- package/dist/cli.css +52 -18
- package/dist/cli.js +166 -109
- package/package.json +1 -1
- package/src/cli.ts +6 -3
- package/src/component/widget/shell/_wg-shell-nav-list.css +158 -0
- package/src/component/widget/shell/_wg-shell-nav-list.tsx +72 -0
- package/src/component/widget/shell/_wg-shell-nav.css +0 -114
- package/src/component/widget/shell/_wg-shell-nav.tsx +9 -35
- package/src/component/widget/shell/wg-shell.tsx +3 -3
- package/src/constant/asset.ts +2 -2
- package/src/content/read-docs.test.ts +96 -3
- package/src/content/to-doc-groups/_to-doc-group-branches.ts +58 -0
- package/src/content/to-doc-groups/to-doc-groups.ts +14 -0
- package/src/type/doc-data.ts +3 -2
- package/src/type/doc-group.ts +23 -0
- package/src/type/group-path.ts +7 -0
- package/src/type/site-assets.ts +8 -0
- package/src/type/site-config.ts +5 -4
- package/src/util/file/to-asset-path.ts +26 -0
- package/src/content/to-doc-groups.ts +0 -18
package/src/cli.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {readHome} from "@/content/read-home";
|
|
|
25
25
|
import {createDevApp} from "@/dev";
|
|
26
26
|
import {siteConfigSchema} from "@/type/site-config";
|
|
27
27
|
import {toErrorMessage} from "@/util/error/to-error-message";
|
|
28
|
+
import {toAssetPath} from "@/util/file/to-asset-path";
|
|
28
29
|
import {toFontCss} from "@/util/font/to-font-css";
|
|
29
30
|
|
|
30
31
|
const commands = ["dev", "build", "preview"];
|
|
@@ -82,9 +83,11 @@ const main = async () => {
|
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
const fontCss = [sans.css, mono.css, brand.css].join("\n");
|
|
86
|
+
const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
|
|
87
|
+
const clientPath = toAssetPath({path: asset_client_path, file: join(kitRoot, "dist/client.js")});
|
|
85
88
|
const files = new Map([
|
|
86
|
-
[
|
|
87
|
-
[
|
|
89
|
+
[stylePath, join(kitRoot, "dist/cli.css")],
|
|
90
|
+
[clientPath, join(kitRoot, "dist/client.js")],
|
|
88
91
|
[asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
|
|
89
92
|
...sans.files,
|
|
90
93
|
...mono.files,
|
|
@@ -96,7 +99,7 @@ const main = async () => {
|
|
|
96
99
|
const app = createApp({
|
|
97
100
|
site: siteConfig,
|
|
98
101
|
store,
|
|
99
|
-
assets: {fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev"},
|
|
102
|
+
assets: {stylePath, clientPath, fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev"},
|
|
100
103
|
});
|
|
101
104
|
|
|
102
105
|
if (command === "build") {
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 재귀 문서 목록 · 단계별 가지와 조상 경로의 1px 트리
|
|
3
|
+
*/
|
|
4
|
+
.wg_shellNavList__root {
|
|
5
|
+
margin: 0 0 0 var(--app-space-tree-inset);
|
|
6
|
+
padding: 0;
|
|
7
|
+
list-style: none;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.wg_shellNavList__branch {
|
|
11
|
+
/* 하위 묶음도 문서와 같은 첫 줄 기준으로 가지 연결 */
|
|
12
|
+
position: relative;
|
|
13
|
+
display: block;
|
|
14
|
+
padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
|
|
15
|
+
color: var(--app-color-text-muted);
|
|
16
|
+
font-size: var(--app-font-size-label);
|
|
17
|
+
line-height: 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__item {
|
|
32
|
+
/* 하위 목록을 포함해 다음 형제까지 줄기 연결 */
|
|
33
|
+
display: flow-root;
|
|
34
|
+
|
|
35
|
+
/* 세로 줄기는 각 항목 기준 · 마지막 항목의 첫 줄 가운데에서 종료 */
|
|
36
|
+
position: relative;
|
|
37
|
+
|
|
38
|
+
&::before {
|
|
39
|
+
position: absolute;
|
|
40
|
+
top: 0;
|
|
41
|
+
bottom: 0;
|
|
42
|
+
left: 0;
|
|
43
|
+
border-left: 1px solid var(--app-color-border-soft);
|
|
44
|
+
content: "";
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
&:where(:last-child)::before {
|
|
49
|
+
bottom: auto;
|
|
50
|
+
height: calc(0.5lh + var(--app-space-tree-row));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* hover·키보드 포커스의 도착점 · 첫 줄 아래는 기본 줄기 유지 */
|
|
54
|
+
&::after {
|
|
55
|
+
position: absolute;
|
|
56
|
+
top: 0;
|
|
57
|
+
left: 0;
|
|
58
|
+
height: calc(0.5lh + var(--app-space-tree-row));
|
|
59
|
+
border-left: 1px solid transparent;
|
|
60
|
+
content: "";
|
|
61
|
+
pointer-events: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
&:where(:hover)::after {
|
|
65
|
+
border-left-color: var(--app-color-text-strong);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&:where(:has(:focus-visible))::after {
|
|
69
|
+
border-left-color: var(--app-color-text-strong);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* 뒤 항목까지의 세로 경로 · 같은 목록의 형제 안에서만 연결 */
|
|
73
|
+
&:where(:has(~ .wg_shellNavList__item:hover))::before {
|
|
74
|
+
border-left-color: var(--app-color-text-strong);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
&:where(:has(~ .wg_shellNavList__item :focus-visible))::before {
|
|
78
|
+
border-left-color: var(--app-color-text-strong);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
& > .wg_shellNavList__root {
|
|
82
|
+
margin-left: var(--app-space-tree-indent);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* 하위 문서를 탐색할 때 조상 묶음의 가지도 함께 연결 */
|
|
86
|
+
&:where(:hover) > .wg_shellNavList__branch::before {
|
|
87
|
+
border-top-color: var(--app-color-text-strong);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&:where(:has(:focus-visible)) > .wg_shellNavList__branch::before {
|
|
91
|
+
border-top-color: var(--app-color-text-strong);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.wg_shellNavList__item--trail {
|
|
96
|
+
&::before {
|
|
97
|
+
border-left-color: var(--app-color-text-strong);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.wg_shellNavList__item--current {
|
|
102
|
+
&::after {
|
|
103
|
+
border-left-color: var(--app-color-text-strong);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
& > .wg_shellNavList__branch::before {
|
|
107
|
+
border-top-color: var(--app-color-text-strong);
|
|
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;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
&:where(:hover) {
|
|
133
|
+
text-decoration: underline;
|
|
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;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import clsx from "clsx";
|
|
2
|
+
import {Fragment} from "react";
|
|
3
|
+
import type {Doc} from "@/type/doc";
|
|
4
|
+
import type {DocGroup} from "@/type/doc-group";
|
|
5
|
+
import "./_wg-shell-nav-list.css";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 한 단계의 문서 목록과 하위 묶음 · 재귀에서도 같은 탐색 표현 유지
|
|
9
|
+
*/
|
|
10
|
+
export interface WgShellNavListProps {
|
|
11
|
+
/**
|
|
12
|
+
* 현재 단계의 문서와 하위 묶음
|
|
13
|
+
*/
|
|
14
|
+
group: DocGroup;
|
|
15
|
+
/**
|
|
16
|
+
* 현재 문서 · 조상 묶음의 경로까지 표시
|
|
17
|
+
*/
|
|
18
|
+
currentDoc?: Doc;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const WgShellNavList = (props: WgShellNavListProps) => {
|
|
22
|
+
const entries = [
|
|
23
|
+
...props.group.docs.map((doc) => ({kind: "document" as const, key: `doc:${doc.id}`, doc, current: doc.id === props.currentDoc?.id})),
|
|
24
|
+
...props.group.groups.map((group) => ({
|
|
25
|
+
kind: "group" as const,
|
|
26
|
+
key: `group:${JSON.stringify(group.path)}`,
|
|
27
|
+
group,
|
|
28
|
+
current: group.path.every((name, index) => name === props.currentDoc?.data.group[index]),
|
|
29
|
+
})),
|
|
30
|
+
];
|
|
31
|
+
const currentIndex = entries.findIndex((entry) => entry.current);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<ul className={clsx("wg_shellNavList__root")} aria-label={props.group.name}>
|
|
35
|
+
{/**
|
|
36
|
+
* 현재 경로 앞의 줄기와 도착 가지 · 문서 링크는 원래 파일 URL 사용
|
|
37
|
+
*/}
|
|
38
|
+
{entries.map((entry, index) => (
|
|
39
|
+
<li
|
|
40
|
+
className={clsx("wg_shellNavList__item", {
|
|
41
|
+
"wg_shellNavList__item--trail": index < currentIndex,
|
|
42
|
+
"wg_shellNavList__item--current": entry.current,
|
|
43
|
+
})}
|
|
44
|
+
key={entry.key}
|
|
45
|
+
>
|
|
46
|
+
{/**
|
|
47
|
+
* 독립 문서 · 키보드 포커스와 현재 페이지 표시는 링크가 담당
|
|
48
|
+
*/}
|
|
49
|
+
{entry.kind === "document" && (
|
|
50
|
+
<a
|
|
51
|
+
className={clsx("wg_shellNavList__link", {"wg_shellNavList__link--active": entry.current})}
|
|
52
|
+
href={`/${entry.doc.id}/`}
|
|
53
|
+
title={entry.doc.data.label}
|
|
54
|
+
aria-current={entry.current ? "page" : undefined}
|
|
55
|
+
>
|
|
56
|
+
{entry.doc.data.name}
|
|
57
|
+
</a>
|
|
58
|
+
)}
|
|
59
|
+
{/**
|
|
60
|
+
* 하위 묶음 · 같은 목록을 재귀로 이어 깊이에 따라 들여쓰기
|
|
61
|
+
*/}
|
|
62
|
+
{entry.kind === "group" && (
|
|
63
|
+
<Fragment>
|
|
64
|
+
<span className={clsx("wg_shellNavList__branch")}>{entry.group.name}</span>
|
|
65
|
+
<WgShellNavList group={entry.group} currentDoc={props.currentDoc} />
|
|
66
|
+
</Fragment>
|
|
67
|
+
)}
|
|
68
|
+
</li>
|
|
69
|
+
))}
|
|
70
|
+
</ul>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
@@ -12,120 +12,6 @@
|
|
|
12
12
|
gap: var(--app-space-stack);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
.wg_shellNav__list {
|
|
16
|
-
margin: 0 0 0 var(--app-space-tree-inset);
|
|
17
|
-
padding: 0;
|
|
18
|
-
list-style: none;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
.wg_shellNav__item {
|
|
22
|
-
/* 세로 줄기는 각 항목 기준 · 마지막 항목의 첫 줄 가운데에서 종료 */
|
|
23
|
-
position: relative;
|
|
24
|
-
|
|
25
|
-
&::before {
|
|
26
|
-
position: absolute;
|
|
27
|
-
top: 0;
|
|
28
|
-
bottom: 0;
|
|
29
|
-
left: 0;
|
|
30
|
-
border-left: 1px solid var(--app-color-border-soft);
|
|
31
|
-
content: "";
|
|
32
|
-
pointer-events: none;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
&:where(:last-child)::before {
|
|
36
|
-
bottom: auto;
|
|
37
|
-
height: calc(0.5lh + var(--app-space-tree-row));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/* hover·키보드 포커스의 도착점 · 첫 줄 아래는 기본 줄기 유지 */
|
|
41
|
-
&::after {
|
|
42
|
-
position: absolute;
|
|
43
|
-
top: 0;
|
|
44
|
-
left: 0;
|
|
45
|
-
height: calc(0.5lh + var(--app-space-tree-row));
|
|
46
|
-
border-left: 1px solid transparent;
|
|
47
|
-
content: "";
|
|
48
|
-
pointer-events: none;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
&:where(:hover)::after {
|
|
52
|
-
border-left-color: var(--app-color-text-strong);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
&:where(:has(:focus-visible))::after {
|
|
56
|
-
border-left-color: var(--app-color-text-strong);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/* 뒤 항목까지의 세로 경로 · 같은 목록의 형제 안에서만 연결 */
|
|
60
|
-
&:where(:has(~ .wg_shellNav__item:hover))::before {
|
|
61
|
-
border-left-color: var(--app-color-text-strong);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
&:where(:has(~ .wg_shellNav__item :focus-visible))::before {
|
|
65
|
-
border-left-color: var(--app-color-text-strong);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
.wg_shellNav__item--trail {
|
|
70
|
-
&::before {
|
|
71
|
-
border-left-color: var(--app-color-text-strong);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
.wg_shellNav__item--current {
|
|
76
|
-
&::after {
|
|
77
|
-
border-left-color: var(--app-color-text-strong);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
.wg_shellNav__link {
|
|
82
|
-
/* 가로 가지는 링크 첫 줄 기준 · 여러 줄 제목에서도 연결 위치 유지 */
|
|
83
|
-
position: relative;
|
|
84
|
-
display: block;
|
|
85
|
-
padding: var(--app-space-tree-row) 0 var(--app-space-tree-row) calc(var(--app-space-tree-indent) + var(--app-space-inline));
|
|
86
|
-
color: var(--app-color-text);
|
|
87
|
-
font-size: var(--app-font-size-dense);
|
|
88
|
-
line-height: var(--app-line-height-tight);
|
|
89
|
-
text-decoration: none;
|
|
90
|
-
|
|
91
|
-
&::before {
|
|
92
|
-
position: absolute;
|
|
93
|
-
top: calc(0.5lh + var(--app-space-tree-row));
|
|
94
|
-
left: 0;
|
|
95
|
-
width: var(--app-space-tree-indent);
|
|
96
|
-
border-top: 1px solid var(--app-color-border-soft);
|
|
97
|
-
content: "";
|
|
98
|
-
pointer-events: none;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
&:where(:hover) {
|
|
102
|
-
text-decoration: underline;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
&:where(:hover)::before {
|
|
106
|
-
border-top-color: currentcolor;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/* hover와 같은 밑줄·가지선으로 키보드 위치 표시 */
|
|
110
|
-
&:where(:focus-visible) {
|
|
111
|
-
outline: none;
|
|
112
|
-
text-decoration: underline;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
&:where(:focus-visible)::before {
|
|
116
|
-
border-top-color: currentcolor;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
.wg_shellNav__link--active {
|
|
121
|
-
color: var(--app-color-text-strong);
|
|
122
|
-
font-weight: var(--app-font-weight-active);
|
|
123
|
-
|
|
124
|
-
&::before {
|
|
125
|
-
border-top-color: currentcolor;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
15
|
.wg_shellNav__group {
|
|
130
16
|
margin: 0 0 4px;
|
|
131
17
|
color: var(--app-color-text-muted);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import clsx from "clsx";
|
|
2
|
+
import {WgShellNavList} from "@/component/widget/shell/_wg-shell-nav-list";
|
|
2
3
|
import {copy_nav_aria_label} from "@/constant/copy";
|
|
3
|
-
import {toDocGroups} from "@/content/to-doc-groups";
|
|
4
|
+
import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
|
|
4
5
|
import type {Doc} from "@/type/doc";
|
|
5
6
|
import type {SiteConfig} from "@/type/site-config";
|
|
6
7
|
import "./_wg-shell-nav.css";
|
|
@@ -25,6 +26,7 @@ export interface WgShellNavProps {
|
|
|
25
26
|
|
|
26
27
|
export const WgShellNav = (props: WgShellNavProps) => {
|
|
27
28
|
const docGroups = toDocGroups({docs: props.docs, navigation: props.site.navigation});
|
|
29
|
+
const currentDoc = props.docs.find((doc) => doc.id === props.current);
|
|
28
30
|
|
|
29
31
|
return (
|
|
30
32
|
<nav className={clsx("wg_shellNav__root")} aria-label={copy_nav_aria_label}>
|
|
@@ -35,40 +37,12 @@ export const WgShellNav = (props: WgShellNavProps) => {
|
|
|
35
37
|
{/**
|
|
36
38
|
* 묶음 이름과 독립 문서 · 제목이 링크의 접근 가능한 이름
|
|
37
39
|
*/}
|
|
38
|
-
{docGroups.map((group) =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
{/**
|
|
45
|
-
* 현재 문서와 묶음 시작부터 이어지는 세로 경로 표시
|
|
46
|
-
*/}
|
|
47
|
-
<ul className={clsx("wg_shellNav__list")}>
|
|
48
|
-
{group.docs.map((doc, index) => (
|
|
49
|
-
<li
|
|
50
|
-
className={clsx("wg_shellNav__item", {
|
|
51
|
-
"wg_shellNav__item--trail": index < currentIndex,
|
|
52
|
-
"wg_shellNav__item--current": index === currentIndex,
|
|
53
|
-
})}
|
|
54
|
-
key={doc.id}
|
|
55
|
-
>
|
|
56
|
-
<a
|
|
57
|
-
className={clsx("wg_shellNav__link", {
|
|
58
|
-
"wg_shellNav__link--active": index === currentIndex,
|
|
59
|
-
})}
|
|
60
|
-
href={`/${doc.id}/`}
|
|
61
|
-
title={doc.data.label}
|
|
62
|
-
aria-current={index === currentIndex ? "page" : undefined}
|
|
63
|
-
>
|
|
64
|
-
{doc.data.name}
|
|
65
|
-
</a>
|
|
66
|
-
</li>
|
|
67
|
-
))}
|
|
68
|
-
</ul>
|
|
69
|
-
</section>
|
|
70
|
-
);
|
|
71
|
-
})}
|
|
40
|
+
{docGroups.map((group) => (
|
|
41
|
+
<section className={clsx("wg_shellNav__docGroup")} key={group.name} aria-label={group.name}>
|
|
42
|
+
<div className={clsx("wg_shellNav__group")}>{group.name}</div>
|
|
43
|
+
<WgShellNavList group={group} currentDoc={currentDoc} />
|
|
44
|
+
</section>
|
|
45
|
+
))}
|
|
72
46
|
</div>
|
|
73
47
|
</nav>
|
|
74
48
|
);
|
|
@@ -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
|
|
|
@@ -6,11 +6,25 @@ 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
|
+
import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
|
|
14
|
+
import {docDataSchema} from "@/type/doc-data";
|
|
12
15
|
import {siteConfigSchema} from "@/type/site-config";
|
|
13
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
|
+
|
|
14
28
|
test("documents with the same initial retain distinct URLs", async (t) => {
|
|
15
29
|
const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
|
|
16
30
|
const site = siteConfigSchema.parse({});
|
|
@@ -67,6 +81,85 @@ test("navigation shows every document group in reading order without document ic
|
|
|
67
81
|
assert.match(html, /href="\/writing\/"[^>]*aria-current="page"/);
|
|
68
82
|
});
|
|
69
83
|
|
|
84
|
+
test("group paths preserve literal names and reject empty or invalid segments", () => {
|
|
85
|
+
const data = {name: "Guide", label: "Guide"};
|
|
86
|
+
|
|
87
|
+
assert.deepEqual(docDataSchema.parse({...data, group: " Guide "}).group, ["Guide"]);
|
|
88
|
+
assert.deepEqual(docDataSchema.parse({...data, group: [" Projects ", "Example project", "Research"]}).group, ["Projects", "Example project", "Research"]);
|
|
89
|
+
assert.deepEqual(docDataSchema.parse({...data, group: "Research, decisions / notes"}).group, ["Research, decisions / notes"]);
|
|
90
|
+
|
|
91
|
+
for (const group of ["", " ", [], ["Projects", " "], ["Projects", 1], [["Projects"]], null]) {
|
|
92
|
+
assert.equal(docDataSchema.safeParse({...data, group}).success, false);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assert.equal(siteConfigSchema.safeParse({navigation: ["Guide", [" Guide "]]}).success, false);
|
|
96
|
+
assert.equal(siteConfigSchema.safeParse({navigation: [["Projects", ""]]}).success, false);
|
|
97
|
+
assert.equal(siteConfigSchema.safeParse({navigation: []}).success, true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("nested groups follow metadata paths and keep sibling branches and document URLs distinct", async (t) => {
|
|
101
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-groups-"));
|
|
102
|
+
const site = siteConfigSchema.parse({navigation: ["Guide", ["Projects", "Atlas", "Research"], ["Projects", "Atlas", "Decisions"]]});
|
|
103
|
+
|
|
104
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
105
|
+
await Promise.all([
|
|
106
|
+
writeFile(join(root, "guide.md"), "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\nText.\n"),
|
|
107
|
+
writeFile(join(root, "guide-two.md"), "---\nname: More guide\nlabel: Guide\ngroup: [Guide]\n---\n\nText.\n"),
|
|
108
|
+
writeFile(join(root, "overview.md"), "---\nname: Overview\nlabel: Overview\ngroup: [Projects, Atlas]\n---\n\nText.\n"),
|
|
109
|
+
writeFile(join(root, "parts.md"), "---\nname: Parts\nlabel: Parts\ngroup: [Projects, Atlas, Research]\norder: 1\n---\n\nText.\n"),
|
|
110
|
+
writeFile(join(root, "authoring.md"), "---\nname: Authoring\nlabel: Authoring\ngroup: [Projects, Atlas, Research]\norder: 2\n---\n\nText.\n"),
|
|
111
|
+
writeFile(join(root, "decision.md"), "---\nname: Decision\nlabel: Decision\ngroup: [Projects, Atlas, Decisions]\n---\n\nText.\n"),
|
|
112
|
+
writeFile(join(root, "other.md"), "---\nname: Other research\nlabel: Other research\ngroup: [Projects, Beacon, Research]\n---\n\nText.\n"),
|
|
113
|
+
writeFile(join(root, "deep.md"), "---\nname: Deep research\nlabel: Deep research\ngroup: [Projects, Atlas, Research, Archive, Drafts]\n---\n\nText.\n"),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
117
|
+
const groups = toDocGroups({docs, navigation: site.navigation});
|
|
118
|
+
|
|
119
|
+
assert.deepEqual(
|
|
120
|
+
groups.map((group) => group.name),
|
|
121
|
+
["Guide", "Projects"],
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(
|
|
124
|
+
groups[0].docs.map((doc) => doc.id),
|
|
125
|
+
["guide", "guide-two"],
|
|
126
|
+
);
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
groups[1].groups.map((group) => group.name),
|
|
129
|
+
["Atlas", "Beacon"],
|
|
130
|
+
);
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
groups[1].groups[0].docs.map((doc) => doc.id),
|
|
133
|
+
["overview"],
|
|
134
|
+
);
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
groups[1].groups[0].groups.map((group) => group.name),
|
|
137
|
+
["Research", "Decisions"],
|
|
138
|
+
);
|
|
139
|
+
assert.deepEqual(
|
|
140
|
+
groups[1].groups[0].groups[0].docs.map((doc) => doc.id),
|
|
141
|
+
["parts", "authoring"],
|
|
142
|
+
);
|
|
143
|
+
assert.deepEqual(
|
|
144
|
+
groups[1].groups[1].groups[0].docs.map((doc) => doc.id),
|
|
145
|
+
["other"],
|
|
146
|
+
);
|
|
147
|
+
assert.equal(groups[1].groups[0].groups[0].groups[0].groups[0].name, "Drafts");
|
|
148
|
+
assert.deepEqual(toDocGroups({docs: docs.toReversed(), navigation: site.navigation}), groups);
|
|
149
|
+
|
|
150
|
+
const html = renderToStaticMarkup(WgShellNav({site, docs, current: "authoring"}));
|
|
151
|
+
|
|
152
|
+
assert.match(html, /href="\/authoring\/"[^>]*aria-current="page"/);
|
|
153
|
+
assert.match(html, /Atlas<\/span><ul\b/);
|
|
154
|
+
assert.match(html, /Research<\/span><ul\b/);
|
|
155
|
+
assert.equal([...html.matchAll(/href="\/authoring\/"/g)].length, 1);
|
|
156
|
+
assert.equal([...html.matchAll(/>Research<\/span>/g)].length, 2);
|
|
157
|
+
assert.equal([...html.matchAll(/wg_shellNavList__item--current/g)].length, 3);
|
|
158
|
+
assert.doesNotMatch(renderToStaticMarkup(WgShellNav({site, docs})), /aria-current="page"|wg_shellNavList__item--current/);
|
|
159
|
+
assert.ok(html.indexOf('href="/parts/"') < html.indexOf('href="/authoring/"'));
|
|
160
|
+
assert.doesNotMatch(html, /<details\b/);
|
|
161
|
+
});
|
|
162
|
+
|
|
70
163
|
test("frontmatter supports BOM, CRLF and a closing fence at EOF", async (t) => {
|
|
71
164
|
const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
|
|
72
165
|
const site = siteConfigSchema.parse({});
|
|
@@ -120,7 +213,7 @@ test("a plain root README renders at home and relative home links resolve from n
|
|
|
120
213
|
]);
|
|
121
214
|
|
|
122
215
|
const [docs, home] = await Promise.all([readDocs({root, processor}), readHome({root, processor})]);
|
|
123
|
-
const app = createApp({site, store: {docs, home}, assets: {fontCss: "", preload: [], reload: false}});
|
|
216
|
+
const app = createApp({site, store: {docs, home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
|
|
124
217
|
const response = await app.request("/");
|
|
125
218
|
const html = await response.text();
|
|
126
219
|
|
|
@@ -149,7 +242,7 @@ test("a missing README provides a creation hint without generated overview cards
|
|
|
149
242
|
|
|
150
243
|
t.after(() => rm(root, {recursive: true, force: true}));
|
|
151
244
|
const home = await readHome({root, processor: createProcessor({site, root})});
|
|
152
|
-
const app = createApp({site, store: {docs: [], home}, assets: {fontCss: "", preload: [], reload: false}});
|
|
245
|
+
const app = createApp({site, store: {docs: [], home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
|
|
153
246
|
const html = await (await app.request("/")).text();
|
|
154
247
|
|
|
155
248
|
assert.equal(home, undefined);
|
|
@@ -228,7 +321,7 @@ test("headings and contents preserve authored text without automatic numbering",
|
|
|
228
321
|
);
|
|
229
322
|
|
|
230
323
|
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
231
|
-
const app = createApp({site, store: {docs}, assets: {fontCss: "", preload: [], reload: false}});
|
|
324
|
+
const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
|
|
232
325
|
const html = await (await app.request("/guide/")).text();
|
|
233
326
|
|
|
234
327
|
assert.match(html, /<h2[^>]*id="overview"[^>]*><span class="wg_prose__headingText">Overview<\/span><\/h2>/);
|