for-humanity 0.4.3 → 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 (79) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +5 -5
  3. package/dist/browser.js +39 -0
  4. package/dist/cli.css +372 -299
  5. package/dist/cli.js +1125 -301
  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} +43 -26
  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 -309
  38. package/src/component/widget/shell/wg-shell.tsx +16 -75
  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 +4 -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 -6
  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 -140
  66. package/src/component/widget/shell/_function/bind-navigation-scroll.ts +0 -123
  67. package/src/component/widget/shell/_wg-shell-nav-list.tsx +0 -87
  68. package/src/component/widget/shell/_wg-shell-nav.tsx +0 -49
  69. package/src/component/widget/shell/_wg-shell-toc.tsx +0 -57
  70. /package/src/{constant → component/widget/navigation/_constant}/locale.ts +0 -0
  71. /package/src/component/widget/{shell → navigation}/_constant/reading-line.ts +0 -0
  72. /package/src/{type → component/widget/navigation/_type}/doc-branch.ts +0 -0
  73. /package/src/component/widget/{shell → navigation}/_type/toc-group.ts +0 -0
  74. /package/src/{constant → entry/cli/_constant}/cli.ts +0 -0
  75. /package/src/{dev.ts → entry/cli/_function/create-dev-app.ts} +0 -0
  76. /package/src/{content/rehype-assets.ts → entry/cli/_function/create-processor/_rehype-assets.ts} +0 -0
  77. /package/src/{content/rehype-heading-ids.ts → entry/cli/_function/create-processor/_rehype-heading-ids.ts} +0 -0
  78. /package/src/{content/remark-report.ts → entry/cli/_function/create-processor/_remark-report.ts} +0 -0
  79. /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,129 +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
- .wg_shell__sidebar {
107
- grid-area: nav;
108
-
109
- /* 뷰포트 sticky · 문서 탐색과 목차는 이 컨테이너에서 함께 스크롤 */
110
- position: sticky;
111
- top: 0;
112
- align-self: start;
113
- min-width: 0;
114
- min-height: 100vh;
115
- max-height: 100vh;
116
- margin-inline-end: var(--app-space-sidebar-gap);
117
- overflow-y: auto;
118
- overscroll-behavior-y: contain;
119
-
120
- /* 얇은 네이티브 손잡이 · 트랙 없이 기존 본문 간격 안쪽에 표시 */
121
- scrollbar-gutter: stable;
122
- scrollbar-width: auto;
123
- scrollbar-color: auto;
124
- scroll-padding-block: var(--app-size-brand) var(--app-space-block);
125
-
126
- &::-webkit-scrollbar {
127
- width: 3px;
128
- }
129
-
130
- &::-webkit-scrollbar-track {
131
- background: transparent;
132
- }
133
-
134
- &::-webkit-scrollbar-thumb {
135
- border-radius: 3px;
136
- background: transparent;
137
- }
138
-
139
- &:hover::-webkit-scrollbar-thumb {
140
- background: var(--app-color-border-soft);
141
- }
142
-
143
- &:focus-within::-webkit-scrollbar-thumb {
144
- background: var(--app-color-border-soft);
145
- }
146
- }
147
-
148
- .wg_shell__navigation {
149
- min-width: 0;
30
+ .wg_shell__browserRoot {
31
+ /* 브라우저 셸의 마운트 경계 · 자식은 body의 기존 grid에 참여 */
32
+ display: contents;
150
33
  }
151
34
 
152
35
  .wg_shell__main {
@@ -160,168 +43,14 @@
160
43
  padding-inline: var(--app-space-column);
161
44
  }
162
45
 
163
- .wg_shell__drawer {
164
- /* 네이티브 모달의 top layer · 뷰포트 왼쪽에 고정 */
165
- position: fixed;
166
- inset: 0 auto 0 0;
167
- width: min(var(--app-size-drawer), calc(100% - var(--app-space-gutter) * 2));
168
- max-width: none;
169
- height: 100dvh;
170
- max-height: 100dvh;
171
- margin: 0;
172
- padding: 0 var(--app-space-gutter) var(--app-space-group);
173
- border: 0;
174
- border-right: 1px solid var(--app-color-border-layout);
175
- background: var(--app-color-ground);
176
- color: var(--app-color-text);
177
- scrollbar-gutter: stable;
178
- scrollbar-width: auto;
179
- scrollbar-color: auto;
180
- overscroll-behavior: contain;
181
- scroll-padding-block: var(--app-size-mobile-header) var(--app-space-group);
182
-
183
- &::-webkit-scrollbar {
184
- width: 3px;
185
- }
186
-
187
- &::-webkit-scrollbar-track {
188
- background: transparent;
189
- }
190
-
191
- &::-webkit-scrollbar-thumb {
192
- border-radius: 3px;
193
- background: transparent;
194
- }
195
-
196
- &:hover::-webkit-scrollbar-thumb {
197
- background: var(--app-color-border-soft);
198
- }
199
-
200
- &:focus-within::-webkit-scrollbar-thumb {
201
- background: var(--app-color-border-soft);
202
- }
203
-
204
- &::backdrop {
205
- background: var(--app-color-backdrop);
206
- }
207
- }
208
-
209
- .wg_shell__drawerHeader {
210
- /* 스크롤하는 drawer 안에서 닫기 버튼 유지 */
211
- position: sticky;
212
- top: 0;
213
- z-index: var(--app-z-index-sticky);
214
- display: flex;
215
- align-items: center;
216
- justify-content: space-between;
217
- gap: var(--app-space-inline);
218
- min-height: var(--app-size-mobile-header);
219
- margin-bottom: var(--app-space-block);
220
- background: var(--app-color-ground);
221
- }
222
-
223
- .wg_shell__drawerTitle {
224
- margin: 0;
225
- font-size: var(--app-font-size-part);
226
- font-weight: var(--app-font-weight-strong);
227
- }
228
-
229
- .wg_shell__drawerClose {
230
- display: flex;
231
- align-items: center;
232
- justify-content: center;
233
- flex: none;
234
- width: var(--app-size-menu-button);
235
- height: var(--app-size-menu-button);
236
- padding: 0;
237
- border: 0;
238
- border-radius: var(--app-radius-box);
239
- background: transparent;
240
- color: var(--app-color-text-strong);
241
- cursor: pointer;
242
-
243
- &:hover {
244
- background: var(--app-color-accent-soft);
245
- }
246
-
247
- &:focus-visible {
248
- outline: var(--app-outline-focus);
249
- outline-offset: var(--app-outline-focus-offset);
250
- }
251
- }
252
-
253
- .wg_shell__drawerContent {
254
- min-width: 0;
255
- }
256
-
257
- .wg_shell__cursorFace {
258
- /* 뷰포트 기준 · 본문 흐름과 포인터 입력에 참여하지 않는 장식 */
259
- position: fixed;
260
- top: 0;
261
- left: 0;
262
- z-index: var(--app-z-index-popper);
263
- visibility: hidden;
264
- pointer-events: none;
265
- user-select: none;
266
- image-rendering: pixelated;
267
- }
268
-
269
- .wg_shell__cursorFace--visible {
270
- visibility: visible;
271
- }
272
-
273
- /* Firefox는 표준 thin 사용 · 손잡이 폭은 브라우저가 결정 */
274
- @supports not selector(::-webkit-scrollbar) {
275
- .wg_shell__sidebar {
276
- scrollbar-width: thin;
277
- scrollbar-color: transparent transparent;
278
-
279
- &:hover {
280
- scrollbar-color: var(--app-color-border-soft) transparent;
281
- }
282
-
283
- &:focus-within {
284
- scrollbar-color: var(--app-color-border-soft) transparent;
285
- }
286
- }
287
-
288
- .wg_shell__drawer {
289
- scrollbar-width: thin;
290
- scrollbar-color: var(--app-color-border-soft) transparent;
291
- }
292
- }
293
-
294
- /* 색상 강제 환경은 시스템 손잡이 유지 · 대비 설정을 덮어쓰지 않음 */
295
- @media (forced-colors: active) {
296
- .wg_shell__sidebar {
297
- /* non-auto 표준 폭은 WebKit 전용 스타일을 비활성화해 네이티브 색상 유지 */
298
- scrollbar-width: thin;
299
- scrollbar-color: auto;
300
-
301
- &:hover {
302
- scrollbar-color: auto;
303
- }
304
-
305
- &:focus-within {
306
- scrollbar-color: auto;
307
- }
308
- }
309
-
310
- .wg_shell__drawer {
311
- scrollbar-width: thin;
312
- scrollbar-color: auto;
313
-
314
- &:hover {
315
- scrollbar-color: auto;
316
- }
317
-
318
- &:focus-within {
319
- scrollbar-color: auto;
320
- }
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);
321
50
  }
322
51
  }
323
52
 
324
- /* 좁은 화면 · 상단 브랜드와 메뉴 버튼 · 탐색 목록은 네이티브 모달 */
53
+ /* 모바일의 브랜드·본문은 한 열 배치 */
325
54
  @media (width < 1024px) {
326
55
  .wg_shell__root {
327
56
  max-width: calc(var(--app-size-main) + var(--app-space-gutter) * 2);
@@ -330,31 +59,6 @@
330
59
  "main" 1fr / minmax(0, 1fr);
331
60
  }
332
61
 
333
- .wg_shell__sidebar {
334
- position: static;
335
- min-height: 0;
336
- max-height: none;
337
- overflow-y: visible;
338
- margin-block-end: var(--app-space-block);
339
- margin-inline-end: 0;
340
- border-bottom: 1px solid var(--app-color-border-layout);
341
- }
342
-
343
- .wg_shell__sidebar--navigationReady {
344
- /* 본문을 스크롤해도 메뉴 진입 유지 · 스크립트 없는 긴 목록에는 적용하지 않음 */
345
- position: sticky;
346
- top: 0;
347
- z-index: var(--app-z-index-sticky);
348
- background: var(--app-color-ground);
349
- }
350
-
351
- .wg_shell__brandRow {
352
- position: static;
353
- height: var(--app-size-mobile-header);
354
- padding-block: 0;
355
- align-items: center;
356
- }
357
-
358
62
  .wg_shell__main {
359
63
  padding-block-start: var(--app-space-page-top);
360
64
  padding-inline: 0;
@@ -1,23 +1,17 @@
1
1
  import clsx from "clsx";
2
- import type {ReactNode} from "react";
3
- import {toTocGroups} from "@/component/widget/shell/_function/to-toc-groups";
4
- import type {DocOutline} from "@/component/widget/shell/_type/doc-outline";
5
- import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
6
- import {WgShellToc} from "@/component/widget/shell/_wg-shell-toc";
7
- import {asset_favicon_path, asset_reload_path} from "@/constant/asset";
8
- 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";
9
7
  import type {Doc} from "@/type/doc";
8
+ import type {DocOutline} from "@/type/doc-outline";
10
9
  import type {SiteAssets} from "@/type/site-assets";
11
10
  import type {SiteConfig} from "@/type/site-config";
12
11
  import "@/style/base.css";
13
12
  import "@/style/token.css";
14
13
  import "./wg-shell.css";
15
14
 
16
- /**
17
- * dev 문서 변경 시 SSE 새로고침
18
- */
19
- const reloadScript = `new EventSource(${JSON.stringify(asset_reload_path)}).onmessage=function(){location.reload()}`;
20
-
21
15
  /**
22
16
  * 공통 HTML 틀의 입력 · 문서 탐색·현재 목차·본문
23
17
  */
@@ -49,16 +43,16 @@ export interface WgShellProps {
49
43
  /**
50
44
  * 현재 페이지 본문
51
45
  */
52
- children: ReactNode;
46
+ children: Child;
53
47
  }
54
48
 
55
49
  export const WgShell = (props: WgShellProps) => {
56
- const tocGroups = props.outline === undefined ? [] : toTocGroups(props.outline);
50
+ const navigationData = toNavigationData({site: props.site, docs: props.docs, current: props.current, outline: props.outline});
57
51
 
58
52
  return (
59
53
  <html lang="ko">
60
54
  {/**
61
- * 공통 자원 · 초기 smooth scroll은 client.ts의 hash 위치 보정 후 복원
55
+ * 공통 자원 · 브라우저 동작은 WgShellControls의 생명주기에서 연결
62
56
  */}
63
57
  <head>
64
58
  <meta charSet="utf-8" />
@@ -70,77 +64,24 @@ export const WgShell = (props: WgShellProps) => {
70
64
  {props.assets.preload.map((href) => (
71
65
  <link key={href} rel="preload" href={href} as="font" type="font/woff2" crossOrigin="" />
72
66
  ))}
73
- <script dangerouslySetInnerHTML={{__html: 'document.documentElement.style.scrollBehavior="auto"'}} />
74
67
  <link rel="stylesheet" href={props.assets.stylePath} />
75
- {props.assets.reload && <script dangerouslySetInnerHTML={{__html: reloadScript}} />}
76
68
  </head>
77
69
  {/**
78
70
  * 문서 탐색·현재 목차·본문 · 브라우저 동작 연결
79
71
  */}
80
72
  <body className={clsx("wg_shell__root")}>
81
73
  {/**
82
- * 데스크톱의 공유 사이드바 · 모바일에서는 브랜드와 메뉴 버튼
74
+ * 서버·브라우저의 공통 셸 조립 · 본문 HTML은 마운트 범위에서 제외
83
75
  */}
84
- <div className={clsx("wg_shell__sidebar")}>
85
- {/**
86
- * 브랜드와 모바일 메뉴 진입 · 버튼은 동작 연결 후 표시
87
- */}
88
- <div className={clsx("wg_shell__brandRow")}>
89
- {/**
90
- * 어느 문서에서도 README 홈으로 이동
91
- */}
92
- <a className={clsx("wg_shell__brand")} href="/" aria-current={props.current === undefined ? "page" : undefined}>
93
- {props.site.title}
94
- </a>
95
- {/**
96
- * 접힌 모바일 탐색의 진입점과 열림 상태 전달
97
- */}
98
- <button
99
- className={clsx("wg_shell__menuToggle")}
100
- type="button"
101
- aria-label={copy_nav_open}
102
- aria-controls="fh-navigation-drawer"
103
- aria-expanded="false"
104
- data-navigation-open=""
105
- >
106
- <svg className={clsx("wg_shell__menuIcon")} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
107
- <path d="M4 6h16M4 12h16M4 18h16" />
108
- </svg>
109
- </button>
110
- </div>
111
- {/**
112
- * 같은 탐색 DOM을 모바일 대화상자로 이동 · 스크립트 없이도 기본 목록 제공
113
- */}
114
- <div className={clsx("wg_shell__navigation")} data-navigation="">
115
- <WgShellNav site={props.site} docs={props.docs} current={props.current} />
116
- {tocGroups.length > 0 && <WgShellToc groups={tocGroups} />}
117
- </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} />
118
78
  </div>
119
- <main className={clsx("wg_shell__main")}>{props.children}</main>
120
79
  {/**
121
- * 브라우저의 모달 포커스·Escape 처리 · 목록은 한 번만 렌더링
80
+ * 최소 탐색 자료와 단일 JSX 번들 · 본문 전 상태·스크롤 복원
122
81
  */}
123
- <dialog className={clsx("wg_shell__drawer")} id="fh-navigation-drawer" aria-labelledby="fh-navigation-title" data-navigation-drawer="">
124
- <div className={clsx("wg_shell__drawerHeader")}>
125
- {/**
126
- * 대화상자의 접근 가능한 이름
127
- */}
128
- <h2 className={clsx("wg_shell__drawerTitle")} id="fh-navigation-title">
129
- {copy_nav_title}
130
- </h2>
131
- {/**
132
- * 목록을 스크롤해도 보이는 닫기 동작
133
- */}
134
- <button className={clsx("wg_shell__drawerClose")} type="button" aria-label={copy_nav_close} data-navigation-close="">
135
- <svg className={clsx("wg_shell__menuIcon")} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
136
- <path d="m6 6 12 12M18 6 6 18" />
137
- </svg>
138
- </button>
139
- </div>
140
- <div className={clsx("wg_shell__drawerContent")} data-navigation-content="" />
141
- </dialog>
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={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>
144
85
  </body>
145
86
  </html>
146
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
  */