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
package/package.json CHANGED
@@ -1,18 +1,17 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.4.3",
4
- "description": "Documents written for humans to read: a docs kit with a sidebar, parts, sections and flowcharts, built on Hono and React",
3
+ "version": "0.5.0",
4
+ "description": "Documents written for humans to read: a docs kit with a sidebar, parts, sections and flowcharts, built on Hono JSX",
5
5
  "keywords": [
6
6
  "docs",
7
7
  "documentation",
8
8
  "hono",
9
- "react",
10
9
  "markdown",
11
10
  "mermaid",
12
11
  "korean"
13
12
  ],
14
13
  "license": "MIT",
15
- "author": "하민우",
14
+ "author": "\ud558\ubbfc\uc6b0",
16
15
  "repository": {
17
16
  "type": "git",
18
17
  "url": "git+https://github.com/minu-ha/for-humanity.git"
@@ -34,14 +33,15 @@
34
33
  "node": ">=22"
35
34
  },
36
35
  "scripts": {
37
- "build:kit": "esbuild src/cli.ts --bundle --platform=node --format=esm --packages=external --jsx=automatic --outfile=dist/cli.js && esbuild src/client.ts --bundle --platform=browser --format=esm --jsx=automatic --minify --define:process.env.NODE_ENV='\"production\"' --outdir=dist",
36
+ "build:kit": "node script/build-kit.mjs",
38
37
  "sync:readme": "node util/sync-readme.mjs",
39
38
  "sync:docs": "pnpm sync:readme && node util/sync-changelog.mjs",
40
39
  "dev": "pnpm sync:docs && pnpm build:kit && node dist/cli.js dev templates/document",
41
40
  "build": "pnpm sync:docs && pnpm build:kit && node dist/cli.js build templates/document",
42
41
  "build:pages": "pnpm build && node util/prepare-pages.mjs",
43
42
  "preview": "node dist/cli.js preview templates/document",
44
- "test": "esbuild src/util/async/to-change-queue.test.ts src/util/font/to-font-css.test.ts src/content/read-docs.test.ts --bundle --platform=node --format=esm --packages=external --outbase=src --outdir=node_modules/.cache/for-humanity-tests --out-extension:.js=.mjs && node --test node_modules/.cache/for-humanity-tests/util/async/*.test.mjs node_modules/.cache/for-humanity-tests/content/*.test.mjs node_modules/.cache/for-humanity-tests/util/font/*.test.mjs util/*.test.mjs",
43
+ "test": "node script/test.mjs",
44
+ "test:navigation": "pnpm build:kit && node util/navigation/check-navigation.test.mjs",
45
45
  "lint": "biome check && stylelint \"src/**/*.css\"",
46
46
  "lint:fix": "biome check --write && stylelint \"src/**/*.css\" --fix",
47
47
  "typecheck": "tsc --noEmit",
@@ -59,8 +59,6 @@
59
59
  "hono": "^4.13.12",
60
60
  "mdast-util-find-and-replace": "^3.0.2",
61
61
  "mdast-util-to-string": "^4.0.0",
62
- "react": "^19.3.0",
63
- "react-dom": "^19.3.0",
64
62
  "rehype-raw": "^7.0.0",
65
63
  "rehype-stringify": "^10.0.1",
66
64
  "remark-directive": "^4.0.0",
@@ -74,15 +72,14 @@
74
72
  "unist-util-visit": "^5.1.0",
75
73
  "vfile": "^6.0.3",
76
74
  "yaml": "^2.9.1",
77
- "zod": "^4.6.5"
75
+ "zod": "^4.6.5",
76
+ "zustand": "^5.0.15"
78
77
  },
79
78
  "devDependencies": {
80
79
  "@biomejs/biome": "^2.5.14",
81
80
  "@types/hast": "^3.0.5",
82
81
  "@types/mdast": "^4.0.4",
83
82
  "@types/node": "^26.6.3",
84
- "@types/react": "^19.3.0",
85
- "@types/react-dom": "^19.3.0",
86
83
  "esbuild": "^0.28.2",
87
84
  "mdast-util-to-hast": "^13.2.1",
88
85
  "playwright": "^1.63.0",
@@ -3,6 +3,11 @@
3
3
  */
4
4
  export const cursor_face_offset_px = 16;
5
5
 
6
+ /**
7
+ * 포인터 얼굴의 고정 표시 크기 · 좌표 계산과 SSR에서 같은 값 사용
8
+ */
9
+ export const cursor_face_size_px = 24;
10
+
6
11
  /**
7
12
  * 같은 탭의 문서 이동에서 커서 위치를 한 번 이어받는 저장소 키
8
13
  */
@@ -0,0 +1,15 @@
1
+ .wg_cursorFace__root {
2
+ /* 뷰포트 기준 · 본문 흐름과 포인터 입력에 참여하지 않는 장식 */
3
+ position: fixed;
4
+ top: 0;
5
+ left: 0;
6
+ z-index: var(--app-z-index-popper);
7
+ visibility: hidden;
8
+ pointer-events: none;
9
+ user-select: none;
10
+ image-rendering: pixelated;
11
+ }
12
+
13
+ .wg_cursorFace__root--visible {
14
+ visibility: visible;
15
+ }
@@ -0,0 +1,153 @@
1
+ import clsx from "clsx";
2
+ import {useLayoutEffect, useRef} from "hono/jsx";
3
+ import {cursor_face_offset_px, cursor_face_size_px, cursor_face_storage_key} from "@/component/widget/cursor-face/_constant/cursor-face";
4
+ import {asset_favicon_path} from "@/constant/asset";
5
+ import "./wg-cursor-face.css";
6
+
7
+ /**
8
+ * SSR 기본 장식과 브라우저 DOM 연결을 구분하는 입력
9
+ */
10
+ export interface WgCursorFaceProps {
11
+ /**
12
+ * 브라우저 셸이 준비한 DOM ref·이벤트 연결 활성화
13
+ */
14
+ ready: boolean;
15
+ }
16
+
17
+ export const WgCursorFace = (props: WgCursorFaceProps) => {
18
+ const faceRef = useRef<HTMLImageElement | null>(null);
19
+ const mediaRef = useRef<MediaQueryList | null>(null);
20
+ const positionRef = useRef({x: 0, y: 0, frame: 0});
21
+
22
+ /**
23
+ * 화면 이탈·터치 전환에서 예약된 이동까지 취소
24
+ */
25
+ const handleCursorHide = () => {
26
+ cancelAnimationFrame(positionRef.current.frame);
27
+ positionRef.current.frame = 0;
28
+ faceRef.current?.classList.remove("wg_cursorFace__root--visible");
29
+ };
30
+
31
+ /**
32
+ * 비활성 전환에서만 숨겨 새 마우스 이동을 취소하지 않음
33
+ */
34
+ const handleCursorMediaChange = () => {
35
+ if (!mediaRef.current?.matches) handleCursorHide();
36
+ };
37
+
38
+ /**
39
+ * 화면 갱신마다 한 번만 반영 · transform으로 본문 재배치 방지
40
+ */
41
+ const renderCursorFace = () => {
42
+ if (positionRef.current.frame !== 0) return;
43
+ positionRef.current.frame = requestAnimationFrame(() => {
44
+ positionRef.current.frame = 0;
45
+ const face = faceRef.current;
46
+ if (face === null) return;
47
+ face.style.transform = `translate3d(
48
+ ${Math.max(0, Math.min(positionRef.current.x + cursor_face_offset_px, document.documentElement.clientWidth - face.width - cursor_face_offset_px))}px,
49
+ ${Math.max(0, Math.min(positionRef.current.y + cursor_face_offset_px, document.documentElement.clientHeight - face.height - cursor_face_offset_px))}px,
50
+ 0
51
+ )`;
52
+ face.classList.add("wg_cursorFace__root--visible");
53
+ });
54
+ };
55
+
56
+ /**
57
+ * 클릭·텍스트 선택 중에도 장식 유지 · 실제 마우스 입력만 좌표 반영
58
+ */
59
+ const handleDocumentPointer = (event: PointerEvent) => {
60
+ if (!mediaRef.current?.matches || event.pointerType !== "mouse") {
61
+ handleCursorHide();
62
+ return;
63
+ }
64
+ positionRef.current.x = event.clientX;
65
+ positionRef.current.y = event.clientY;
66
+ renderCursorFace();
67
+ };
68
+
69
+ /**
70
+ * 새 마우스 이동 없이 화면 가장자리의 좌표 재계산
71
+ */
72
+ const handleCursorResize = () => {
73
+ if (faceRef.current?.classList.contains("wg_cursorFace__root--visible")) renderCursorFace();
74
+ };
75
+
76
+ /**
77
+ * 같은 탭의 문서 이동에서 마지막 좌표를 전달 · 저장 실패는 장식에만 영향
78
+ */
79
+ const handlePageHide = () => {
80
+ if (!faceRef.current?.classList.contains("wg_cursorFace__root--visible")) return;
81
+ try {
82
+ sessionStorage.setItem(cursor_face_storage_key, JSON.stringify({x: positionRef.current.x, y: positionRef.current.y}));
83
+ } catch {
84
+ // 저장소 차단 시 현재 페이지의 포인터 동작 유지
85
+ }
86
+ };
87
+
88
+ /**
89
+ * 첫 진입·BFCache 복귀의 좌표를 한 번 소비 · 외부 저장 값은 검증 후 사용
90
+ */
91
+ const handlePageShow = () => {
92
+ try {
93
+ const stored = sessionStorage.getItem(cursor_face_storage_key);
94
+ sessionStorage.removeItem(cursor_face_storage_key);
95
+ if (stored === null || !mediaRef.current?.matches) return;
96
+ const point: unknown = JSON.parse(stored);
97
+ if (
98
+ typeof point === "object" &&
99
+ point !== null &&
100
+ "x" in point &&
101
+ "y" in point &&
102
+ typeof point.x === "number" &&
103
+ typeof point.y === "number" &&
104
+ Number.isFinite(point.x) &&
105
+ Number.isFinite(point.y)
106
+ ) {
107
+ positionRef.current.x = point.x;
108
+ positionRef.current.y = point.y;
109
+ renderCursorFace();
110
+ }
111
+ } catch {
112
+ // 손상된 좌표는 다음 실제 마우스 입력으로 복구
113
+ }
114
+ };
115
+
116
+ /**
117
+ * 첫 paint 전 좌표 복원 · 해제 시 전역 이벤트와 예약 frame을 함께 정리
118
+ */
119
+ useLayoutEffect(() => {
120
+ if (!props.ready) return;
121
+ const lifetime = new AbortController();
122
+ const media = matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)");
123
+ mediaRef.current = media;
124
+ handlePageShow();
125
+ document.addEventListener("pointermove", handleDocumentPointer, {passive: true, signal: lifetime.signal});
126
+ document.addEventListener("pointerdown", handleDocumentPointer, {passive: true, signal: lifetime.signal});
127
+ document.documentElement.addEventListener("pointerleave", handleCursorHide, {signal: lifetime.signal});
128
+ addEventListener("blur", handleCursorHide, {signal: lifetime.signal});
129
+ addEventListener("resize", handleCursorResize, {signal: lifetime.signal});
130
+ addEventListener("pagehide", handlePageHide, {signal: lifetime.signal});
131
+ addEventListener("pageshow", handlePageShow, {signal: lifetime.signal});
132
+ media.addEventListener("change", handleCursorMediaChange, {signal: lifetime.signal});
133
+ return () => {
134
+ lifetime.abort();
135
+ mediaRef.current = null;
136
+ handleCursorHide();
137
+ };
138
+ }, [props.ready]);
139
+
140
+ return (
141
+ <img
142
+ ref={props.ready ? faceRef : undefined}
143
+ className={clsx("wg_cursorFace__root")}
144
+ src={asset_favicon_path}
145
+ width={cursor_face_size_px}
146
+ height={cursor_face_size_px}
147
+ alt=""
148
+ aria-hidden="true"
149
+ draggable={false}
150
+ data-cursor-face=""
151
+ />
152
+ );
153
+ };
@@ -0,0 +1,35 @@
1
+ import {array, boolean, lazy, minLength, number, object, optional, string, type ZodMiniType} from "zod/mini";
2
+ import type {NavigationData, NavigationDoc, NavigationGroup} from "@/component/widget/navigation/_type/navigation-data";
3
+
4
+ const navigationDocSchema: ZodMiniType<NavigationDoc> = object({
5
+ id: string(),
6
+ name: string(),
7
+ label: string(),
8
+ active: boolean(),
9
+ current: boolean(),
10
+ children: lazy(() => array(navigationDocSchema)),
11
+ });
12
+ const navigationGroupSchema: ZodMiniType<NavigationGroup> = object({
13
+ name: string(),
14
+ path: array(string()),
15
+ current: boolean(),
16
+ docs: array(navigationDocSchema),
17
+ groups: lazy(() => array(navigationGroupSchema)),
18
+ });
19
+ const headingSchema = object({depth: number(), slug: string(), text: string()});
20
+
21
+ /**
22
+ * HTML에 포함된 JSON을 읽는 브라우저 경계 · 손상된 자료에는 서버 HTML 유지
23
+ */
24
+ export const navigationDataSchema: ZodMiniType<NavigationData> = object({
25
+ title: string(),
26
+ groups: array(navigationGroupSchema),
27
+ outline: array(
28
+ object({
29
+ part: optional(string()),
30
+ sections: array(object({heading: headingSchema, part: optional(string()), subs: array(object({heading: headingSchema}))})).check(minLength(1)),
31
+ }),
32
+ ),
33
+ pageId: string(),
34
+ home: boolean(),
35
+ });
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 모바일 탐색 전환 · wg-shell.css의 폭 조건과 일치
3
+ */
4
+ export const navigation_mobile_query = "(width < 1024px)";
5
+
6
+ /**
7
+ * 양쪽 240px 탐색·본문 900px·간격 120px와 스크롤바가 들어가는 3열 기준
8
+ */
9
+ export const navigation_wide_query = "(width >= 1536px)";
@@ -0,0 +1,59 @@
1
+ import type {NavigationLayout} from "@/store/navigation/navigation-state";
2
+ import type {toNavigationScrollState} from "@/store/navigation/to-navigation-scroll-state";
3
+
4
+ /**
5
+ * 초기 HTML과 저장 스토어가 같은 좌표 형태를 소비하는 복원 경계
6
+ */
7
+ interface RestoreNavigationScrollOptions {
8
+ /**
9
+ * persist 키 · 이전 배치별 키의 접두사도 동일
10
+ */
11
+ key: string;
12
+ /**
13
+ * 수용할 저장 형태 버전
14
+ */
15
+ version: number;
16
+ /**
17
+ * 현재 표시 중인 화면 배치
18
+ */
19
+ layout: NavigationLayout;
20
+ /**
21
+ * persist와 같은 저장 자료 검증
22
+ */
23
+ toState: typeof toNavigationScrollState;
24
+ }
25
+
26
+ /**
27
+ * 탐색 진입점에서 첫 paint 전 복원 · 기존 배치별 저장 자료도 한 번 수용
28
+ */
29
+ export const restoreNavigationScroll = (options: RestoreNavigationScrollOptions) => {
30
+ const navigation = document.querySelector<HTMLElement>("[data-navigation]");
31
+ const documents = document.querySelector<HTMLElement>('[data-navigation-scroll="documents"]');
32
+ const outline = document.querySelector<HTMLElement>('[data-navigation-scroll="outline"]');
33
+ if (!navigation || !documents || !outline) return;
34
+ // module을 기다리는 동안 breakpoint가 바뀌었는지 구분 · 자료가 없어도 적용 배치는 기록
35
+ navigation.dataset.navigationLayout = options.layout;
36
+
37
+ try {
38
+ const value = sessionStorage.getItem(options.key);
39
+ const stored: unknown = value === null ? undefined : JSON.parse(value);
40
+ const state =
41
+ typeof stored === "object" && stored !== null && "version" in stored && stored.version === options.version && "state" in stored
42
+ ? options.toState(stored.state)
43
+ : {positions: {}};
44
+ const legacy = sessionStorage.getItem(`${options.key}:${options.layout === "drawer" ? "drawer" : "desktop"}`);
45
+ const previous =
46
+ state.positions[options.layout] === undefined && legacy !== null
47
+ ? options.toState({positions: {[options.layout]: JSON.parse(legacy)}}).positions[options.layout]
48
+ : undefined;
49
+ const position = state.positions[options.layout] ?? previous;
50
+ const signature = [...navigation.querySelectorAll<HTMLAnchorElement>(".wg_navigationNav__root a[href]")].map((link) => `${link.pathname}:${link.textContent}`).join("\n");
51
+ if (position === undefined || position.navigation !== signature) return;
52
+
53
+ documents.scrollTop = position.documents;
54
+ // 공통 문서 목록은 페이지 간 공유 · 목차는 같은 페이지에서만 재사용
55
+ if (position.pathname === location.pathname) outline.scrollTop = position.outline;
56
+ } catch {
57
+ // 차단·손상된 저장소는 복원만 생략 · 기본 링크와 스크롤 유지
58
+ }
59
+ };
@@ -1,6 +1,6 @@
1
- import {locale_doc_name} from "@/constant/locale";
1
+ import {locale_doc_name} from "@/component/widget/navigation/_constant/locale";
2
+ import type {DocBranch} from "@/component/widget/navigation/_type/doc-branch";
2
3
  import type {Doc} from "@/type/doc";
3
- import type {DocBranch} from "@/type/doc-branch";
4
4
 
5
5
  /**
6
6
  * 검증된 부모 관계로 문서 가지를 재귀 구성 · 형제 순서와 URL 유지
@@ -1,7 +1,7 @@
1
- import {locale_doc_group} from "@/constant/locale";
2
- import {toDocBranches} from "@/content/to-doc-groups/_to-doc-branches";
1
+ import {locale_doc_group} from "@/component/widget/navigation/_constant/locale";
2
+ import {toDocBranches} from "@/component/widget/navigation/_function/to-doc-groups/_to-doc-branches";
3
+ import type {DocGroup} from "@/component/widget/navigation/_type/doc-group";
3
4
  import type {Doc} from "@/type/doc";
4
- import type {DocGroup} from "@/type/doc-group";
5
5
 
6
6
  /**
7
7
  * 같은 부모의 문서를 다음 경로 단계로 묶는 재귀 입력
@@ -1,4 +1,4 @@
1
- import {toDocGroupBranches} from "@/content/to-doc-groups/_to-doc-group-branches";
1
+ import {toDocGroupBranches} from "@/component/widget/navigation/_function/to-doc-groups/_to-doc-group-branches";
2
2
  import type {Doc} from "@/type/doc";
3
3
  import type {SiteConfig} from "@/type/site-config";
4
4
 
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 전체 경로 기반 가지 키를 HTML id로 변환 · 제목이 같은 묶음·문서도 독립 제어
3
+ */
4
+ export const toNavigationBranchId = (key: string) => {
5
+ return `fh-branch-${encodeURIComponent(key)}`;
6
+ };
@@ -0,0 +1,14 @@
1
+ import type {DocBranch} from "@/component/widget/navigation/_type/doc-branch";
2
+ import type {NavigationDoc} from "@/component/widget/navigation/_type/navigation-data";
3
+
4
+ /**
5
+ * 本문 없는 문서 가지 · 재귀에서도 현재 페이지와 조상 경로만 유지
6
+ */
7
+ export const toNavigationDocument = (options: {doc: DocBranch; current?: string; ancestors: Set<string>}): NavigationDoc => ({
8
+ id: options.doc.id,
9
+ name: options.doc.data.name,
10
+ label: options.doc.data.label,
11
+ active: options.doc.id === options.current,
12
+ current: options.doc.id === options.current || options.ancestors.has(options.doc.id),
13
+ children: options.doc.children === undefined ? [] : options.doc.children.map((doc) => toNavigationDocument({...options, doc})),
14
+ });
@@ -0,0 +1,14 @@
1
+ import {toNavigationDocument} from "@/component/widget/navigation/_function/to-navigation-data/_to-navigation-document";
2
+ import type {DocGroup} from "@/component/widget/navigation/_type/doc-group";
3
+ import type {NavigationGroup} from "@/component/widget/navigation/_type/navigation-data";
4
+
5
+ /**
6
+ * 전체 묶음 경로와 본문 없는 하위 가지 · 현재 문서의 경로만 트리선으로 연결
7
+ */
8
+ export const toNavigationGroup = (options: {group: DocGroup; current?: string; ancestors: Set<string>; currentGroup?: string[]}): NavigationGroup => ({
9
+ name: options.group.name,
10
+ path: options.group.path,
11
+ current: options.group.path.every((name, index) => name === options.currentGroup?.[index]),
12
+ docs: options.group.docs.map((doc) => toNavigationDocument({doc, current: options.current, ancestors: options.ancestors})),
13
+ groups: options.group.groups.map((group) => toNavigationGroup({...options, group})),
14
+ });
@@ -0,0 +1,25 @@
1
+ import {toDocGroups} from "@/component/widget/navigation/_function/to-doc-groups/to-doc-groups";
2
+ import {toNavigationGroup} from "@/component/widget/navigation/_function/to-navigation-data/_to-navigation-group";
3
+ import {toTocGroups} from "@/component/widget/navigation/_function/to-toc-groups";
4
+ import type {NavigationData} from "@/component/widget/navigation/_type/navigation-data";
5
+ import type {Doc} from "@/type/doc";
6
+ import type {DocOutline} from "@/type/doc-outline";
7
+ import type {SiteConfig} from "@/type/site-config";
8
+
9
+ /**
10
+ * 현재 문서의 이름·계층·목차만 전송 · 다른 문서 본문은 브라우저 탐색 입력에서 제외
11
+ */
12
+ export const toNavigationData = (options: {site: SiteConfig; docs: Doc[]; current?: string; outline?: DocOutline}): NavigationData => {
13
+ const currentDoc = options.docs.find((doc) => doc.id === options.current);
14
+ const ancestors = new Set(currentDoc?.ancestors);
15
+
16
+ return {
17
+ title: options.site.title,
18
+ groups: toDocGroups({docs: options.docs, navigation: options.site.navigation}).map((group) =>
19
+ toNavigationGroup({group, current: options.current, ancestors, currentGroup: currentDoc?.data.group}),
20
+ ),
21
+ outline: options.outline === undefined ? [] : toTocGroups(options.outline),
22
+ pageId: options.current === undefined ? "/" : options.current,
23
+ home: options.current === undefined,
24
+ };
25
+ };
@@ -0,0 +1,8 @@
1
+ import type {NavigationData} from "@/component/widget/navigation/_type/navigation-data";
2
+
3
+ /**
4
+ * 작성한 이름이 script 종료 태그나 JavaScript 줄 구분자로 해석되지 않는 JSON
5
+ */
6
+ export const toNavigationJson = (data: NavigationData) => {
7
+ return JSON.stringify(data).replaceAll("<", "\\u003c").replaceAll("\u2028", "\\u2028").replaceAll("\u2029", "\\u2029");
8
+ };
@@ -1,7 +1,7 @@
1
1
  import {takeWhile} from "es-toolkit/array";
2
+ import type {TocGroup, TocSection} from "@/component/widget/navigation/_type/toc-group";
2
3
  import {section_heading_depth} from "@/component/widget/prose/_constant/section";
3
- import type {DocOutline} from "@/component/widget/shell/_type/doc-outline";
4
- import type {TocGroup, TocSection} from "@/component/widget/shell/_type/toc-group";
4
+ import type {DocOutline} from "@/type/doc-outline";
5
5
 
6
6
  /**
7
7
  * h2별 h3 수집 · 가름 시작 절에서 목차 묶음 분리
@@ -1,5 +1,5 @@
1
+ import type {DocBranch} from "@/component/widget/navigation/_type/doc-branch";
1
2
  import type {Doc} from "@/type/doc";
2
- import type {DocBranch} from "@/type/doc-branch";
3
3
 
4
4
  /**
5
5
  * 사이드바의 문서 묶음 · 같은 이름도 전체 경로로 구분
@@ -0,0 +1,83 @@
1
+ import type {TocGroup} from "@/component/widget/navigation/_type/toc-group";
2
+
3
+ /**
4
+ * 브라우저 문서 가지 · 본문·파일 경로를 포함하지 않는 탐색 전용 계약
5
+ */
6
+ export interface NavigationDoc {
7
+ /**
8
+ * 링크의 문서 id
9
+ */
10
+ id: string;
11
+ /**
12
+ * 표시 이름
13
+ */
14
+ name: string;
15
+ /**
16
+ * 링크의 보조 설명
17
+ */
18
+ label: string;
19
+ /**
20
+ * 현재 페이지 한 항목만 강조
21
+ */
22
+ active: boolean;
23
+ /**
24
+ * 현재 문서와 조상의 트리 경로
25
+ */
26
+ current: boolean;
27
+ /**
28
+ * 클릭 가능한 하위 문서
29
+ */
30
+ children: NavigationDoc[];
31
+ }
32
+
33
+ /**
34
+ * 클릭하지 않는 문서 묶음 · 같은 이름은 전체 경로로 구분
35
+ */
36
+ export interface NavigationGroup {
37
+ /**
38
+ * 묶음 이름
39
+ */
40
+ name: string;
41
+ /**
42
+ * 최상위부터 현재 묶음까지
43
+ */
44
+ path: string[];
45
+ /**
46
+ * 현재 페이지가 속한 묶음 경로
47
+ */
48
+ current: boolean;
49
+ /**
50
+ * 현재 단계의 문서
51
+ */
52
+ docs: NavigationDoc[];
53
+ /**
54
+ * 하위 묶음
55
+ */
56
+ groups: NavigationGroup[];
57
+ }
58
+
59
+ /**
60
+ * 서버가 계산한 최소 탐색 자료 · 서버 출력과 클라이언트 JSX의 공통 입력
61
+ */
62
+ export interface NavigationData {
63
+ /**
64
+ * 브랜드 이름
65
+ */
66
+ title: string;
67
+ /**
68
+ * 읽는 순서의 문서 트리
69
+ */
70
+ groups: NavigationGroup[];
71
+ /**
72
+ * 현재 페이지의 목차
73
+ */
74
+ outline: TocGroup[];
75
+ /**
76
+ * 목차의 접힘 상태를 구분할 문서 id
77
+ */
78
+ pageId: string;
79
+ /**
80
+ * README 홈 여부
81
+ */
82
+ home: boolean;
83
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Hono가 배치 변경에서 이전 DOM의 자식을 분리하기 전에 기억하는 키보드 위치
3
+ */
4
+ export interface NavigationFocusSnapshot {
5
+ /**
6
+ * 문서 탐색 안에서 새 배치로 옮길 위치인지 구분
7
+ */
8
+ navigation: boolean;
9
+ /**
10
+ * 메뉴 진입·닫기 버튼에서 데스크톱 브랜드로 돌아갈 위치인지 구분
11
+ */
12
+ drawer: boolean;
13
+ /**
14
+ * 하위 문서·묶음 접힘 버튼의 안정된 식별자
15
+ */
16
+ branch: string | null;
17
+ /**
18
+ * 문서 이동 링크의 안정된 식별자
19
+ */
20
+ href: string | null;
21
+ }
@@ -0,0 +1,28 @@
1
+ /* 각 가지 항목이 위치 기준 · 텍스트 이동과 독립된 접기 동작 */
2
+ .wg_navigationBranchToggle__root {
3
+ position: absolute;
4
+ top: 0;
5
+ right: 0;
6
+ width: var(--app-size-tree-toggle);
7
+ height: calc(var(--app-font-size-dense) * var(--app-line-height-tight) + var(--app-space-tree-row) * 2);
8
+ padding: 0;
9
+ border: 0;
10
+ background: transparent;
11
+ color: var(--app-color-border-soft);
12
+ font-family: var(--app-font-mono);
13
+ font-size: var(--app-font-size-body);
14
+ line-height: 1;
15
+ cursor: pointer;
16
+
17
+ &:hover {
18
+ background: var(--app-color-accent-soft);
19
+ color: var(--app-color-text-strong);
20
+ }
21
+
22
+ &:focus-visible {
23
+ background: var(--app-color-accent-soft);
24
+ color: var(--app-color-text-strong);
25
+ outline: var(--app-outline-focus);
26
+ outline-offset: calc(0px - var(--app-outline-focus-offset));
27
+ }
28
+ }
@@ -0,0 +1,48 @@
1
+ import clsx from "clsx";
2
+ import {toNavigationBranchId} from "@/component/widget/navigation/_function/to-navigation-branch-id";
3
+ import "./_wg-navigation-branch-toggle.css";
4
+
5
+ /**
6
+ * 문서·목차에서 공유하는 가지 조작 · 링크 이동과 분리된 네이티브 버튼
7
+ */
8
+ export interface WgNavigationBranchToggleProps {
9
+ /**
10
+ * 문서 ID·묶음 경로·페이지별 헤딩으로 구분한 상태 키
11
+ */
12
+ branchKey: string;
13
+ /**
14
+ * 펼치거나 접을 가지의 접근 가능한 이름
15
+ */
16
+ label: string;
17
+ /**
18
+ * 같은 저장 키의 접힘 상태
19
+ */
20
+ collapsed: Record<string, boolean>;
21
+ /**
22
+ * 브라우저에서만 제공하는 가지 변경 동작
23
+ */
24
+ onToggle?: (key: string) => void;
25
+ }
26
+
27
+ export const WgNavigationBranchToggle = (props: WgNavigationBranchToggleProps) => {
28
+ /**
29
+ * 링크 이동과 별도로 현재 가지 선택 변경
30
+ */
31
+ const handleToggleClick = () => props.onToggle?.(props.branchKey);
32
+
33
+ return (
34
+ <button
35
+ className={clsx("wg_navigationBranchToggle__root")}
36
+ type="button"
37
+ aria-label={`${props.collapsed[props.branchKey] === true ? "Expand" : "Collapse"} ${props.label}`}
38
+ aria-expanded={String(props.collapsed[props.branchKey] !== true)}
39
+ aria-controls={toNavigationBranchId(props.branchKey)}
40
+ data-navigation-toggle={props.branchKey}
41
+ data-navigation-label={props.label}
42
+ hidden={props.onToggle === undefined}
43
+ onClick={handleToggleClick}
44
+ >
45
+ {props.collapsed[props.branchKey] === true ? "+" : "−"}
46
+ </button>
47
+ );
48
+ };