for-humanity 0.4.4 → 0.5.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +5 -5
  3. package/dist/browser.js +39 -0
  4. package/dist/cli.css +350 -262
  5. package/dist/cli.js +1123 -363
  6. package/package.json +8 -11
  7. package/src/component/widget/{shell → cursor-face}/_constant/cursor-face.ts +5 -0
  8. package/src/component/widget/cursor-face/wg-cursor-face.css +15 -0
  9. package/src/component/widget/cursor-face/wg-cursor-face.tsx +153 -0
  10. package/src/component/widget/navigation/_constant/navigation-data-schema.ts +35 -0
  11. package/src/component/widget/navigation/_constant/navigation.ts +9 -0
  12. package/src/component/widget/navigation/_function/restore-navigation-scroll.ts +59 -0
  13. package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-branches.ts +2 -2
  14. package/src/{content → component/widget/navigation/_function}/to-doc-groups/_to-doc-group-branches.ts +3 -3
  15. package/src/{content → component/widget/navigation/_function}/to-doc-groups/to-doc-groups.ts +1 -1
  16. package/src/component/widget/navigation/_function/to-navigation-branch-id.ts +6 -0
  17. package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-document.ts +14 -0
  18. package/src/component/widget/navigation/_function/to-navigation-data/_to-navigation-group.ts +14 -0
  19. package/src/component/widget/navigation/_function/to-navigation-data/to-navigation-data.ts +25 -0
  20. package/src/component/widget/navigation/_function/to-navigation-json.ts +8 -0
  21. package/src/component/widget/{shell → navigation}/_function/to-toc-groups.ts +2 -2
  22. package/src/{type → component/widget/navigation/_type}/doc-group.ts +1 -1
  23. package/src/component/widget/navigation/_type/navigation-data.ts +83 -0
  24. package/src/component/widget/navigation/_type/navigation-focus-snapshot.ts +21 -0
  25. package/src/component/widget/navigation/_wg-navigation-branch-toggle.css +28 -0
  26. package/src/component/widget/navigation/_wg-navigation-branch-toggle.tsx +48 -0
  27. package/src/component/widget/{shell/_wg-shell-nav-list.css → navigation/_wg-navigation-nav-list.css} +24 -16
  28. package/src/component/widget/navigation/_wg-navigation-nav-list.tsx +106 -0
  29. package/src/component/widget/{shell/_wg-shell-nav.css → navigation/_wg-navigation-nav.css} +9 -3
  30. package/src/component/widget/navigation/_wg-navigation-nav.tsx +43 -0
  31. package/src/component/widget/navigation/_wg-navigation-scroll.tsx +117 -0
  32. package/src/component/widget/{shell/_wg-shell-toc.css → navigation/_wg-navigation-toc.css} +39 -24
  33. package/src/component/widget/navigation/_wg-navigation-toc.tsx +155 -0
  34. package/src/component/widget/navigation/wg-navigation.css +282 -0
  35. package/src/component/widget/navigation/wg-navigation.tsx +479 -0
  36. package/src/component/widget/prose/_function/rehype-sections/rehype-sections.ts +1 -1
  37. package/src/component/widget/shell/wg-shell.css +13 -269
  38. package/src/component/widget/shell/wg-shell.tsx +16 -97
  39. package/src/component/widget/shell-controls/wg-shell-controls.css +4 -0
  40. package/src/component/widget/shell-controls/wg-shell-controls.tsx +170 -0
  41. package/src/constant/asset.ts +0 -5
  42. package/src/entry/browser/browser.tsx +22 -0
  43. package/src/{app.tsx → entry/cli/_function/create-app.tsx} +6 -6
  44. package/src/{content → entry/cli/_function/create-processor}/create-processor.ts +3 -3
  45. package/src/{content → entry/cli/_function}/read-docs.ts +1 -1
  46. package/src/{content → entry/cli/_function}/read-home.ts +1 -1
  47. package/src/{content/read-docs.test.ts → entry/cli/cli.test.ts} +105 -30
  48. package/src/{cli.ts → entry/cli/cli.ts} +10 -11
  49. package/src/store/navigation/_constant/tree.ts +6 -0
  50. package/src/store/navigation/create-navigation-stores.test.ts +92 -0
  51. package/src/store/navigation/create-navigation-stores.ts +73 -0
  52. package/src/store/navigation/navigation-state.ts +46 -0
  53. package/src/store/navigation/navigation-storage.ts +14 -0
  54. package/src/store/navigation/to-navigation-scroll-state.ts +42 -0
  55. package/src/store/navigation/to-navigation-tree-state.ts +19 -0
  56. package/src/store/navigation/use-navigation-tree-store.ts +12 -0
  57. package/src/style/token.css +3 -2
  58. package/src/type/doc-content.ts +1 -1
  59. package/src/type/doc-file-data.ts +1 -1
  60. package/src/{component/widget/shell/_type → type}/doc-outline.ts +1 -1
  61. package/src/type/site-assets.ts +2 -2
  62. package/dist/client.js +0 -7
  63. package/src/client.ts +0 -335
  64. package/src/component/widget/shell/_constant/navigation.ts +0 -9
  65. package/src/component/widget/shell/_function/bind-mobile-navigation.ts +0 -147
  66. package/src/component/widget/shell/_function/bind-navigation-overflow.ts +0 -47
  67. package/src/component/widget/shell/_function/bind-navigation-scroll.ts +0 -84
  68. package/src/component/widget/shell/_function/restore-navigation-scroll.ts +0 -47
  69. package/src/component/widget/shell/_wg-shell-nav-list.tsx +0 -87
  70. package/src/component/widget/shell/_wg-shell-nav.tsx +0 -49
  71. package/src/component/widget/shell/_wg-shell-toc.tsx +0 -57
  72. /package/src/{constant → component/widget/navigation/_constant}/locale.ts +0 -0
  73. /package/src/component/widget/{shell → navigation}/_constant/reading-line.ts +0 -0
  74. /package/src/{type → component/widget/navigation/_type}/doc-branch.ts +0 -0
  75. /package/src/component/widget/{shell → navigation}/_type/toc-group.ts +0 -0
  76. /package/src/{constant → entry/cli/_constant}/cli.ts +0 -0
  77. /package/src/{dev.ts → entry/cli/_function/create-dev-app.ts} +0 -0
  78. /package/src/{content/rehype-assets.ts → entry/cli/_function/create-processor/_rehype-assets.ts} +0 -0
  79. /package/src/{content/rehype-heading-ids.ts → entry/cli/_function/create-processor/_rehype-heading-ids.ts} +0 -0
  80. /package/src/{content/remark-report.ts → entry/cli/_function/create-processor/_remark-report.ts} +0 -0
  81. /package/src/{component/widget/prose/_type → type}/doc-section.ts +0 -0
@@ -1,12 +1,15 @@
1
1
  /*
2
- * 넓은 화면은 본문 중앙 정렬 · 폭이 줄면 오른쪽 여백부터 축소
2
+ * 넓은 화면은 같은 폭의 양쪽 탐색 · 좁은 화면은 문서 탐색만 표시
3
3
  */
4
4
  .wg_shell__root {
5
5
  display: grid;
6
6
  max-width: var(--app-size-page);
7
7
  margin: 0 auto;
8
8
  padding: 0 var(--app-space-gutter);
9
- grid-template: "nav main ." 1fr / calc(var(--app-size-sidebar) + var(--app-space-sidebar-gap)) minmax(0, var(--app-size-main)) minmax(0, 1fr);
9
+ grid-template: "nav main outline" 1fr / calc(var(--app-size-sidebar) + var(--app-space-sidebar-gap)) minmax(0, var(--app-size-main)) calc(
10
+ var(--app-size-sidebar) +
11
+ var(--app-space-sidebar-gap)
12
+ );
10
13
  background: var(--app-color-ground);
11
14
  color: var(--app-color-text);
12
15
  font-family: var(--app-font-sans);
@@ -24,148 +27,9 @@
24
27
  overflow: hidden;
25
28
  }
26
29
 
27
- .wg_shell__brandRow {
28
- display: flex;
29
-
30
- /* 두 스크롤 영역 바깥 · 목록과 목차 위에서 이름 고정 */
31
- position: sticky;
32
- top: 0;
33
- z-index: var(--app-z-index-sticky);
34
- align-self: start;
35
- min-width: 0;
36
- height: var(--app-size-brand);
37
- padding-block: var(--app-space-page-top) var(--app-space-group);
38
- background: var(--app-color-ground);
39
- gap: var(--app-space-inline);
40
- align-items: start;
41
- }
42
-
43
- .wg_shell__brand {
44
- display: block;
45
- flex: 1;
46
- min-width: 0;
47
- overflow: hidden;
48
- color: var(--app-color-text-strong);
49
- font-family: var(--app-font-brand);
50
- font-size: var(--app-font-size-title);
51
- font-weight: var(--app-font-weight-mark);
52
- line-height: var(--app-line-height-title);
53
- letter-spacing: var(--app-letter-spacing-brand);
54
- text-decoration: none;
55
- text-overflow: ellipsis;
56
- white-space: nowrap;
57
-
58
- &:hover {
59
- text-decoration: underline;
60
- }
61
-
62
- &:focus-visible {
63
- /* 브랜드도 hover와 같은 밑줄로 키보드 위치 표시 */
64
- outline: none;
65
- text-decoration: underline;
66
- }
67
- }
68
-
69
- .wg_shell__menuIcon {
70
- width: 20px;
71
- height: 20px;
72
- fill: none;
73
- stroke: currentcolor;
74
- stroke-linecap: round;
75
- stroke-width: 1.5;
76
- }
77
-
78
- .wg_shell__menuToggle {
79
- display: none;
80
- align-items: center;
81
- justify-content: center;
82
- flex: none;
83
- width: var(--app-size-menu-button);
84
- height: var(--app-size-menu-button);
85
- padding: 0;
86
- border: 0;
87
- border-radius: var(--app-radius-box);
88
- background: transparent;
89
- color: var(--app-color-text-strong);
90
- cursor: pointer;
91
-
92
- &:hover {
93
- background: var(--app-color-accent-soft);
94
- }
95
-
96
- &:focus-visible {
97
- outline: var(--app-outline-focus);
98
- outline-offset: var(--app-outline-focus-offset);
99
- }
100
- }
101
-
102
- .wg_shell__menuToggle--visible {
103
- display: flex;
104
- }
105
-
106
- /* sticky 틀 안에서 문서 목록과 목차의 스크롤 높이를 독립 유지 */
107
- .wg_shell__sidebar {
108
- grid-area: nav;
109
- position: sticky;
110
- top: 0;
111
- align-self: start;
112
- min-width: 0;
113
- height: 100dvh;
114
- margin-inline-end: var(--app-space-sidebar-gap);
115
- }
116
-
117
- .wg_shell__navigation {
118
- display: flex;
119
- flex-direction: column;
120
- height: calc(100dvh - var(--app-size-brand));
121
- min-width: 0;
122
- min-height: 0;
123
- }
124
-
125
- .wg_shell__documents {
126
- /* TOC 길이가 바뀌어도 공통 목록의 스크롤 범위는 동일 */
127
- flex: none;
128
- max-height: 60%;
129
- }
130
-
131
- .wg_shell__outline {
132
- display: flex;
133
- flex-direction: column;
134
- min-height: 0;
135
- margin-top: var(--app-space-group);
136
- padding-top: var(--app-space-group);
137
- border-top: 1px solid var(--app-color-border-soft);
138
-
139
- &:has(.wg_shell__headings:empty) {
140
- display: none;
141
- }
142
- }
143
-
144
- .wg_shell__headings {
145
- min-height: 0;
146
- }
147
-
148
- .wg_shell__scroll {
149
- overflow-y: auto;
150
- overscroll-behavior-y: contain;
151
- scroll-padding-block: var(--app-size-navigation-fade);
152
- }
153
-
154
- .wg_shell__scroll--ready {
155
- /* 흐림으로 넘침을 알릴 때만 손잡이 숨김 · JS 없으면 네이티브 손잡이 유지 */
156
- scrollbar-width: none;
157
- }
158
-
159
- .wg_shell__scroll--above {
160
- mask-image: linear-gradient(to bottom, transparent, #000 var(--app-size-navigation-fade));
161
- }
162
-
163
- .wg_shell__scroll--below {
164
- mask-image: linear-gradient(to bottom, #000 calc(100% - var(--app-size-navigation-fade)), transparent);
165
- }
166
-
167
- .wg_shell__scroll--above.wg_shell__scroll--below {
168
- mask-image: linear-gradient(to bottom, transparent, #000 var(--app-size-navigation-fade), #000 calc(100% - var(--app-size-navigation-fade)), transparent);
30
+ .wg_shell__browserRoot {
31
+ /* 브라우저 셸의 마운트 경계 · 자식은 body의 기존 grid에 참여 */
32
+ display: contents;
169
33
  }
170
34
 
171
35
  .wg_shell__main {
@@ -179,103 +43,14 @@
179
43
  padding-inline: var(--app-space-column);
180
44
  }
181
45
 
182
- .wg_shell__drawer {
183
- /* 네이티브 모달의 top layer · 뷰포트 왼쪽에 고정 */
184
- position: fixed;
185
- inset: 0 auto 0 0;
186
- width: min(var(--app-size-drawer), calc(100% - var(--app-space-gutter) * 2));
187
- max-width: none;
188
- height: 100dvh;
189
- max-height: 100dvh;
190
- margin: 0;
191
- padding: 0 var(--app-space-gutter) var(--app-space-group);
192
- border: 0;
193
- border-right: 1px solid var(--app-color-border-layout);
194
- background: var(--app-color-ground);
195
- color: var(--app-color-text);
196
- overflow: hidden;
197
-
198
- &::backdrop {
199
- background: var(--app-color-backdrop);
200
- }
201
- }
202
-
203
- .wg_shell__drawerHeader {
204
- /* 두 스크롤 영역 바깥에서 닫기 버튼 유지 */
205
- display: flex;
206
- align-items: center;
207
- justify-content: space-between;
208
- gap: var(--app-space-inline);
209
- min-height: var(--app-size-mobile-header);
210
- margin-bottom: var(--app-space-block);
211
- background: var(--app-color-ground);
212
- }
213
-
214
- .wg_shell__drawerTitle {
215
- margin: 0;
216
- font-size: var(--app-font-size-part);
217
- font-weight: var(--app-font-weight-strong);
218
- }
219
-
220
- .wg_shell__drawerClose {
221
- display: flex;
222
- align-items: center;
223
- justify-content: center;
224
- flex: none;
225
- width: var(--app-size-menu-button);
226
- height: var(--app-size-menu-button);
227
- padding: 0;
228
- border: 0;
229
- border-radius: var(--app-radius-box);
230
- background: transparent;
231
- color: var(--app-color-text-strong);
232
- cursor: pointer;
233
-
234
- &:hover {
235
- background: var(--app-color-accent-soft);
236
- }
237
-
238
- &:focus-visible {
239
- outline: var(--app-outline-focus);
240
- outline-offset: var(--app-outline-focus-offset);
241
- }
242
- }
243
-
244
- .wg_shell__drawerContent {
245
- min-width: 0;
246
- height: calc(100% - var(--app-size-mobile-header) - var(--app-space-block));
247
-
248
- & > .wg_shell__navigation {
249
- height: 100%;
250
- }
251
- }
252
-
253
- .wg_shell__cursorFace {
254
- /* 뷰포트 기준 · 본문 흐름과 포인터 입력에 참여하지 않는 장식 */
255
- position: fixed;
256
- top: 0;
257
- left: 0;
258
- z-index: var(--app-z-index-popper);
259
- visibility: hidden;
260
- pointer-events: none;
261
- user-select: none;
262
- image-rendering: pixelated;
263
- }
264
-
265
- .wg_shell__cursorFace--visible {
266
- visibility: visible;
267
- }
268
-
269
- /* 강제 색상 환경은 흐림 대신 시스템 손잡이로 넘침 표시 */
270
- @media (forced-colors: active) {
271
- .wg_shell__scroll {
272
- mask-image: none;
273
- scrollbar-width: thin;
274
- scrollbar-color: auto;
46
+ /* 오른쪽 목차를 숨기는 화면에서 본문과 문서 목록의 간격 유지 */
47
+ @media (width < 1536px) {
48
+ .wg_shell__root {
49
+ grid-template: "nav main ." 1fr / calc(var(--app-size-sidebar) + var(--app-space-sidebar-gap)) minmax(0, var(--app-size-main)) minmax(0, 1fr);
275
50
  }
276
51
  }
277
52
 
278
- /* 좁은 화면 · 상단 브랜드와 메뉴 버튼 · 탐색 목록은 네이티브 모달 */
53
+ /* 모바일의 브랜드·본문은 한 열 배치 */
279
54
  @media (width < 1024px) {
280
55
  .wg_shell__root {
281
56
  max-width: calc(var(--app-size-main) + var(--app-space-gutter) * 2);
@@ -284,37 +59,6 @@
284
59
  "main" 1fr / minmax(0, 1fr);
285
60
  }
286
61
 
287
- .wg_shell__sidebar {
288
- position: static;
289
- height: auto;
290
- margin-block-end: var(--app-space-block);
291
- margin-inline-end: 0;
292
- border-bottom: 1px solid var(--app-color-border-layout);
293
- }
294
-
295
- .wg_shell__sidebar > .wg_shell__navigation {
296
- height: auto;
297
-
298
- & > .wg_shell__documents {
299
- max-height: none;
300
- }
301
- }
302
-
303
- .wg_shell__sidebar--navigationReady {
304
- /* 본문을 스크롤해도 메뉴 진입 유지 · 스크립트 없는 긴 목록에는 적용하지 않음 */
305
- position: sticky;
306
- top: 0;
307
- z-index: var(--app-z-index-sticky);
308
- background: var(--app-color-ground);
309
- }
310
-
311
- .wg_shell__brandRow {
312
- position: static;
313
- height: var(--app-size-mobile-header);
314
- padding-block: 0;
315
- align-items: center;
316
- }
317
-
318
62
  .wg_shell__main {
319
63
  padding-block-start: var(--app-space-page-top);
320
64
  padding-inline: 0;
@@ -1,32 +1,17 @@
1
1
  import clsx from "clsx";
2
- import type {ReactNode} from "react";
3
- import {navigation_mobile_query, navigation_scroll_storage_key} from "@/component/widget/shell/_constant/navigation";
4
- import {bindNavigationOverflow} from "@/component/widget/shell/_function/bind-navigation-overflow";
5
- import {restoreNavigationScroll} from "@/component/widget/shell/_function/restore-navigation-scroll";
6
- import {toTocGroups} from "@/component/widget/shell/_function/to-toc-groups";
7
- import type {DocOutline} from "@/component/widget/shell/_type/doc-outline";
8
- import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
9
- import {WgShellToc} from "@/component/widget/shell/_wg-shell-toc";
10
- import {asset_favicon_path, asset_reload_path} from "@/constant/asset";
11
- import {copy_nav_close, copy_nav_open, copy_nav_title} from "@/constant/copy";
2
+ import type {Child} from "hono/jsx";
3
+ import {toNavigationData} from "@/component/widget/navigation/_function/to-navigation-data/to-navigation-data";
4
+ import {toNavigationJson} from "@/component/widget/navigation/_function/to-navigation-json";
5
+ import {WgShellControls} from "@/component/widget/shell-controls/wg-shell-controls";
6
+ import {asset_favicon_path} from "@/constant/asset";
12
7
  import type {Doc} from "@/type/doc";
8
+ import type {DocOutline} from "@/type/doc-outline";
13
9
  import type {SiteAssets} from "@/type/site-assets";
14
10
  import type {SiteConfig} from "@/type/site-config";
15
11
  import "@/style/base.css";
16
12
  import "@/style/token.css";
17
13
  import "./wg-shell.css";
18
14
 
19
- /**
20
- * dev 문서 변경 시 SSE 새로고침
21
- */
22
- const reloadScript = `new EventSource(${JSON.stringify(asset_reload_path)}).onmessage=function(){location.reload()}`;
23
-
24
- /**
25
- * 탐색 DOM 직후 동기 실행 · 큰 본문과 module 다운로드를 기다리지 않고 첫 위치·흐림 적용
26
- * 손잡이 숨김으로 달라지는 목록 폭을 먼저 확정한 뒤 좌표 복원
27
- */
28
- const navigationScript = `(${bindNavigationOverflow.toString()})();if(!matchMedia(${JSON.stringify(navigation_mobile_query)}).matches){(${restoreNavigationScroll.toString()})(${JSON.stringify(`${navigation_scroll_storage_key}:desktop`)})}`;
29
-
30
15
  /**
31
16
  * 공통 HTML 틀의 입력 · 문서 탐색·현재 목차·본문
32
17
  */
@@ -58,16 +43,16 @@ export interface WgShellProps {
58
43
  /**
59
44
  * 현재 페이지 본문
60
45
  */
61
- children: ReactNode;
46
+ children: Child;
62
47
  }
63
48
 
64
49
  export const WgShell = (props: WgShellProps) => {
65
- const tocGroups = props.outline === undefined ? [] : toTocGroups(props.outline);
50
+ const navigationData = toNavigationData({site: props.site, docs: props.docs, current: props.current, outline: props.outline});
66
51
 
67
52
  return (
68
53
  <html lang="ko">
69
54
  {/**
70
- * 공통 자원 · 초기 smooth scroll은 client.ts의 hash 위치 보정 후 복원
55
+ * 공통 자원 · 브라우저 동작은 WgShellControls의 생명주기에서 연결
71
56
  */}
72
57
  <head>
73
58
  <meta charSet="utf-8" />
@@ -79,90 +64,24 @@ export const WgShell = (props: WgShellProps) => {
79
64
  {props.assets.preload.map((href) => (
80
65
  <link key={href} rel="preload" href={href} as="font" type="font/woff2" crossOrigin="" />
81
66
  ))}
82
- <script dangerouslySetInnerHTML={{__html: 'document.documentElement.style.scrollBehavior="auto"'}} />
83
67
  <link rel="stylesheet" href={props.assets.stylePath} />
84
- {props.assets.reload && <script dangerouslySetInnerHTML={{__html: reloadScript}} />}
85
68
  </head>
86
69
  {/**
87
70
  * 문서 탐색·현재 목차·본문 · 브라우저 동작 연결
88
71
  */}
89
72
  <body className={clsx("wg_shell__root")}>
90
73
  {/**
91
- * 데스크톱의 공유 사이드바 · 모바일에서는 브랜드와 메뉴 버튼
74
+ * 서버·브라우저의 공통 셸 조립 · 본문 HTML은 마운트 범위에서 제외
92
75
  */}
93
- <div className={clsx("wg_shell__sidebar")}>
94
- {/**
95
- * 브랜드와 모바일 메뉴 진입 · 버튼은 동작 연결 후 표시
96
- */}
97
- <div className={clsx("wg_shell__brandRow")}>
98
- {/**
99
- * 어느 문서에서도 README 홈으로 이동
100
- */}
101
- <a className={clsx("wg_shell__brand")} href="/" aria-current={props.current === undefined ? "page" : undefined}>
102
- {props.site.title}
103
- </a>
104
- {/**
105
- * 접힌 모바일 탐색의 진입점과 열림 상태 전달
106
- */}
107
- <button
108
- className={clsx("wg_shell__menuToggle")}
109
- type="button"
110
- aria-label={copy_nav_open}
111
- aria-controls="fh-navigation-drawer"
112
- aria-expanded="false"
113
- data-navigation-open=""
114
- >
115
- <svg className={clsx("wg_shell__menuIcon")} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
116
- <path d="M4 6h16M4 12h16M4 18h16" />
117
- </svg>
118
- </button>
119
- </div>
120
- {/**
121
- * 같은 탐색 DOM을 모바일 대화상자로 이동 · 스크립트 없이도 기본 목록 제공
122
- */}
123
- <div className={clsx("wg_shell__navigation")} data-navigation="">
124
- {/**
125
- * 모든 페이지에서 같은 높이의 문서 탐색 · 목차 길이와 스크롤 분리
126
- */}
127
- <div className={clsx("wg_shell__documents", "wg_shell__scroll")} data-navigation-scroll="documents">
128
- <WgShellNav site={props.site} docs={props.docs} current={props.current} />
129
- </div>
130
- {/**
131
- * 문서 목록 아래 현재 페이지 목차 · 남은 뷰포트 안에서 독립 스크롤
132
- */}
133
- <div className={clsx("wg_shell__outline")}>
134
- <div className={clsx("wg_shell__headings", "wg_shell__scroll")} data-navigation-scroll="outline">
135
- {tocGroups.length > 0 && <WgShellToc groups={tocGroups} />}
136
- </div>
137
- </div>
138
- </div>
76
+ <div className={clsx("wg_shell__browserRoot")} data-shell-browser-root="" data-shell-reload={props.assets.reload ? "" : undefined}>
77
+ <WgShellControls data={navigationData} reload={props.assets.reload} />
139
78
  </div>
140
- <script dangerouslySetInnerHTML={{__html: navigationScript}} />
141
- <main className={clsx("wg_shell__main")}>{props.children}</main>
142
79
  {/**
143
- * 브라우저의 모달 포커스·Escape 처리 · 목록은 한 번만 렌더링
80
+ * 최소 탐색 자료와 단일 JSX 번들 · 본문 전 상태·스크롤 복원
144
81
  */}
145
- <dialog className={clsx("wg_shell__drawer")} id="fh-navigation-drawer" aria-labelledby="fh-navigation-title" data-navigation-drawer="">
146
- <div className={clsx("wg_shell__drawerHeader")}>
147
- {/**
148
- * 대화상자의 접근 가능한 이름
149
- */}
150
- <h2 className={clsx("wg_shell__drawerTitle")} id="fh-navigation-title">
151
- {copy_nav_title}
152
- </h2>
153
- {/**
154
- * 목록을 스크롤해도 보이는 닫기 동작
155
- */}
156
- <button className={clsx("wg_shell__drawerClose")} type="button" aria-label={copy_nav_close} data-navigation-close="">
157
- <svg className={clsx("wg_shell__menuIcon")} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
158
- <path d="m6 6 12 12M18 6 6 18" />
159
- </svg>
160
- </button>
161
- </div>
162
- <div className={clsx("wg_shell__drawerContent")} data-navigation-content="" />
163
- </dialog>
164
- <img className={clsx("wg_shell__cursorFace")} src={asset_favicon_path} width="24" height="24" alt="" aria-hidden="true" draggable={false} data-cursor-face="" />
165
- <script type="module" src={props.assets.clientPath} />
82
+ <script type="application/json" id="fh-navigation-data" dangerouslySetInnerHTML={{__html: toNavigationJson(navigationData)}} />
83
+ <script dangerouslySetInnerHTML={{__html: props.assets.browserScript}} />
84
+ <main className={clsx("wg_shell__main")}>{props.children}</main>
166
85
  </body>
167
86
  </html>
168
87
  );
@@ -0,0 +1,4 @@
1
+ .wg_shellControls__navigation {
2
+ /* Hono 갱신의 DOM 경계 · 탐색 자식은 기존 body grid에 참여 */
3
+ display: contents;
4
+ }
@@ -0,0 +1,170 @@
1
+ import clsx from "clsx";
2
+ import {Fragment, useEffect, useLayoutEffect, useRef} from "hono/jsx";
3
+ import {WgCursorFace} from "@/component/widget/cursor-face/wg-cursor-face";
4
+ import {WgNavigation, type WgNavigationProps} from "@/component/widget/navigation/wg-navigation";
5
+ import {heading_highlight_hold_ms} from "@/component/widget/prose/_constant/heading-highlight";
6
+ import {asset_reload_path} from "@/constant/asset";
7
+ import {revealHashTarget} from "@/util/dom/reveal-hash-target";
8
+ import "./wg-shell-controls.css";
9
+
10
+ /**
11
+ * 셸의 서버·브라우저 공통 입력 · 탐색 계약을 그대로 사용하고 dev 갱신만 추가
12
+ */
13
+ export interface WgShellControlsProps extends WgNavigationProps {
14
+ /**
15
+ * 문서 변경 알림에 따른 dev 새로고침 활성화
16
+ */
17
+ reload: boolean;
18
+ }
19
+
20
+ export const WgShellControls = (props: WgShellControlsProps) => {
21
+ const headingRef = useRef<HTMLElement | null>(null);
22
+ const timerRef = useRef(0);
23
+ const arrivalRef = useRef<AbortController | null>(null);
24
+ const lifetimeRef = useRef<AbortController | null>(null);
25
+ const userScrollRef = useRef<AbortController | null>(null);
26
+
27
+ /**
28
+ * 다음 앵커 이동과 해제에서 이전 강조·도착 감시·타이머를 함께 정리
29
+ */
30
+ const clearHeadingHighlight = () => {
31
+ clearTimeout(timerRef.current);
32
+ timerRef.current = 0;
33
+ arrivalRef.current?.abort();
34
+ arrivalRef.current = null;
35
+ headingRef.current?.classList.remove("wg_prose__headingText--highlighted");
36
+ headingRef.current = null;
37
+ };
38
+
39
+ /**
40
+ * 본문 스크롤 도착 후 3초 확보 · 사이드바 자체의 scrollend는 제외
41
+ */
42
+ const handleHighlightArrival = (event: Event) => {
43
+ if (event.target !== document) return;
44
+ clearTimeout(timerRef.current);
45
+ arrivalRef.current?.abort();
46
+ arrivalRef.current = null;
47
+ timerRef.current = window.setTimeout(clearHeadingHighlight, heading_highlight_hold_ms);
48
+ };
49
+
50
+ /**
51
+ * 도착 제목만 강조 · 같은 hash 재클릭도 유지 시간 재시작
52
+ */
53
+ const highlightHeading = (target: HTMLElement | null) => {
54
+ clearHeadingHighlight();
55
+ headingRef.current = target === null ? null : target.querySelector<HTMLElement>(".wg_prose__headingText");
56
+ if (headingRef.current === null) return;
57
+ headingRef.current.classList.add("wg_prose__headingText--highlighted");
58
+ timerRef.current = window.setTimeout(clearHeadingHighlight, heading_highlight_hold_ms);
59
+ arrivalRef.current = new AbortController();
60
+ document.addEventListener("scrollend", handleHighlightArrival, {signal: arrivalRef.current.signal});
61
+ };
62
+
63
+ /**
64
+ * 탐색 DOM이 다시 만들어져도 같은 hash의 접힌 Details 공개를 유지
65
+ */
66
+ const handleHashClick = (event: MouseEvent) => {
67
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
68
+ const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
69
+ if (
70
+ link instanceof HTMLAnchorElement &&
71
+ (link.target === "" || link.target === "_self") &&
72
+ !link.hasAttribute("download") &&
73
+ link.origin === location.origin &&
74
+ link.pathname === location.pathname &&
75
+ link.search === location.search &&
76
+ link.hash
77
+ ) {
78
+ const target = revealHashTarget(link.hash);
79
+ if (link.hash === location.hash) highlightHeading(target);
80
+ }
81
+ };
82
+
83
+ /**
84
+ * hash 변경 시 접힌 대상을 공개하고 네이티브 스크롤·강조 적용
85
+ */
86
+ const handleHashChange = () => {
87
+ const target = revealHashTarget(location.hash);
88
+ target?.scrollIntoView();
89
+ highlightHeading(target);
90
+ };
91
+
92
+ const handleUserScroll = () => {
93
+ userScrollRef.current?.abort();
94
+ };
95
+
96
+ /**
97
+ * 초기 폰트 로드 후 앵커 보정 · 해제된 인스턴스와 사용자 이동에 개입하지 않음
98
+ */
99
+ const handlePageLoad = async () => {
100
+ const lifetime = lifetimeRef.current;
101
+ const hash = location.hash;
102
+ const target = revealHashTarget(hash);
103
+ await document.fonts.ready;
104
+ if (lifetime === null || lifetime.signal.aborted) return;
105
+ if (target !== null && !userScrollRef.current?.signal.aborted && hash === location.hash) {
106
+ target.scrollIntoView({behavior: "instant"});
107
+ highlightHeading(target);
108
+ }
109
+ document.documentElement.style.removeProperty("scroll-behavior");
110
+ };
111
+
112
+ /**
113
+ * 셸은 본문 앞에서 마운트 · 본문 파싱 이후에만 초기 접힘 대상 조회
114
+ */
115
+ const handleBodyReady = () => {
116
+ revealHashTarget(location.hash);
117
+ if (document.readyState === "complete") void handlePageLoad();
118
+ };
119
+
120
+ /**
121
+ * 본문 이벤트의 설치·정리는 셸이 소유 · SSR에서는 DOM에 접근하지 않음
122
+ */
123
+ useLayoutEffect(() => {
124
+ const lifetime = new AbortController();
125
+ lifetimeRef.current = lifetime;
126
+ userScrollRef.current = new AbortController();
127
+ document.documentElement.style.scrollBehavior = "auto";
128
+ document.addEventListener("click", handleHashClick, {signal: lifetime.signal});
129
+ addEventListener("hashchange", handleHashChange, {signal: lifetime.signal});
130
+ addEventListener("load", handlePageLoad, {once: true, signal: lifetime.signal});
131
+ for (const type of ["wheel", "touchmove", "keydown", "mousedown"]) {
132
+ addEventListener(type, handleUserScroll, {once: true, passive: true, signal: lifetime.signal});
133
+ }
134
+ if (document.readyState === "loading") {
135
+ document.addEventListener("DOMContentLoaded", handleBodyReady, {once: true, signal: lifetime.signal});
136
+ } else {
137
+ handleBodyReady();
138
+ }
139
+ return () => {
140
+ lifetime.abort();
141
+ lifetimeRef.current = null;
142
+ userScrollRef.current?.abort();
143
+ userScrollRef.current = null;
144
+ clearHeadingHighlight();
145
+ document.documentElement.style.removeProperty("scroll-behavior");
146
+ };
147
+ }, []);
148
+
149
+ /**
150
+ * dev 새로고침 연결도 브라우저 셸의 수명에 맞춰 닫음
151
+ */
152
+ useEffect(() => {
153
+ if (!props.reload || lifetimeRef.current === null) return;
154
+ const source = new EventSource(asset_reload_path);
155
+ source.onmessage = () => location.reload();
156
+ return () => source.close();
157
+ }, [props.reload]);
158
+
159
+ return (
160
+ <Fragment>
161
+ {/**
162
+ * 별도 DOM 경계로 탐색 갱신이 커서 형제 사이에서 dialog를 재삽입하지 않게 함
163
+ */}
164
+ <div className={clsx("wg_shellControls__navigation")}>
165
+ <WgNavigation data={props.data} stores={props.stores} />
166
+ </div>
167
+ <WgCursorFace ready={props.stores !== undefined} />
168
+ </Fragment>
169
+ );
170
+ };
@@ -12,11 +12,6 @@ export const asset_dir = "_fh";
12
12
  */
13
13
  export const asset_style_path = `/${asset_dir}/style.css`;
14
14
 
15
- /**
16
- * 내용 지문을 붙일 테마·목차·hash 보정 스크립트의 기본 URL
17
- */
18
- export const asset_client_path = `/${asset_dir}/client.js`;
19
-
20
15
  /**
21
16
  * 자체 호스팅 글꼴 폴더
22
17
  */
@@ -0,0 +1,22 @@
1
+ /*
2
+ * 브라우저 진입 · 서버 자료 검증과 저장소 생성 후 공통 셸 컨트롤을 한 번 마운트
3
+ */
4
+ import {createRoot} from "hono/jsx/dom/client";
5
+ import {navigationDataSchema} from "@/component/widget/navigation/_constant/navigation-data-schema";
6
+ import {WgShellControls} from "@/component/widget/shell-controls/wg-shell-controls";
7
+ import {createNavigationStores} from "@/store/navigation/create-navigation-stores";
8
+
9
+ const root = document.querySelector<HTMLElement>("[data-shell-browser-root]");
10
+ const payload = document.getElementById("fh-navigation-data");
11
+
12
+ if (root !== null && payload !== null) {
13
+ try {
14
+ const value: unknown = JSON.parse(payload.textContent);
15
+ const data = navigationDataSchema.parse(value);
16
+ const stores = createNavigationStores({local: () => localStorage, session: () => sessionStorage});
17
+ createRoot(root).render(<WgShellControls data={data} stores={stores} reload={root.hasAttribute("data-shell-reload")} />);
18
+ } catch (error) {
19
+ // 초기화 오류에서도 서버의 링크·본문과 네이티브 스크롤 유지
20
+ console.error("for-humanity browser initialization failed", error);
21
+ }
22
+ }