for-humanity 0.1.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 (177) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +118 -0
  4. package/dist/cli.css +962 -0
  5. package/dist/cli.js +1241 -0
  6. package/dist/client.js +5 -0
  7. package/package.json +92 -0
  8. package/src/app.tsx +57 -0
  9. package/src/asset/favicon.svg +5 -0
  10. package/src/asset/font/architects-daughter/LICENSE.txt +93 -0
  11. package/src/asset/font/architects-daughter/architects-daughter-latin-400-normal.woff2 +0 -0
  12. package/src/asset/font/architects-daughter/architects-daughter.css +10 -0
  13. package/src/asset/font/jetbrains-mono/LICENSE +93 -0
  14. package/src/asset/font/jetbrains-mono/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  15. package/src/asset/font/jetbrains-mono/jetbrains-mono.css +11 -0
  16. package/src/asset/font/pretendard/LICENSE.txt +94 -0
  17. package/src/asset/font/pretendard/pretendardvariable-dynamic-subset.css +1008 -0
  18. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.0.woff2 +0 -0
  19. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.1.woff2 +0 -0
  20. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.10.woff2 +0 -0
  21. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.11.woff2 +0 -0
  22. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.12.woff2 +0 -0
  23. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.13.woff2 +0 -0
  24. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.14.woff2 +0 -0
  25. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.15.woff2 +0 -0
  26. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.16.woff2 +0 -0
  27. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.17.woff2 +0 -0
  28. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.18.woff2 +0 -0
  29. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.19.woff2 +0 -0
  30. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.2.woff2 +0 -0
  31. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.20.woff2 +0 -0
  32. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.21.woff2 +0 -0
  33. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.22.woff2 +0 -0
  34. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.23.woff2 +0 -0
  35. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.24.woff2 +0 -0
  36. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.25.woff2 +0 -0
  37. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.26.woff2 +0 -0
  38. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.27.woff2 +0 -0
  39. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.28.woff2 +0 -0
  40. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.29.woff2 +0 -0
  41. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.3.woff2 +0 -0
  42. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.30.woff2 +0 -0
  43. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.31.woff2 +0 -0
  44. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.32.woff2 +0 -0
  45. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.33.woff2 +0 -0
  46. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.34.woff2 +0 -0
  47. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.35.woff2 +0 -0
  48. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.36.woff2 +0 -0
  49. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.37.woff2 +0 -0
  50. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.38.woff2 +0 -0
  51. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.39.woff2 +0 -0
  52. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.4.woff2 +0 -0
  53. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.40.woff2 +0 -0
  54. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.41.woff2 +0 -0
  55. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.42.woff2 +0 -0
  56. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.43.woff2 +0 -0
  57. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.44.woff2 +0 -0
  58. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.45.woff2 +0 -0
  59. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.46.woff2 +0 -0
  60. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.47.woff2 +0 -0
  61. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.48.woff2 +0 -0
  62. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.49.woff2 +0 -0
  63. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.5.woff2 +0 -0
  64. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.50.woff2 +0 -0
  65. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.51.woff2 +0 -0
  66. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.52.woff2 +0 -0
  67. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.53.woff2 +0 -0
  68. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.54.woff2 +0 -0
  69. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.55.woff2 +0 -0
  70. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.56.woff2 +0 -0
  71. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.57.woff2 +0 -0
  72. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.58.woff2 +0 -0
  73. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.59.woff2 +0 -0
  74. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.6.woff2 +0 -0
  75. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.60.woff2 +0 -0
  76. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.61.woff2 +0 -0
  77. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.62.woff2 +0 -0
  78. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.63.woff2 +0 -0
  79. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.64.woff2 +0 -0
  80. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.65.woff2 +0 -0
  81. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.66.woff2 +0 -0
  82. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.67.woff2 +0 -0
  83. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.68.woff2 +0 -0
  84. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.69.woff2 +0 -0
  85. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.7.woff2 +0 -0
  86. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.70.woff2 +0 -0
  87. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.71.woff2 +0 -0
  88. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.72.woff2 +0 -0
  89. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.73.woff2 +0 -0
  90. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.74.woff2 +0 -0
  91. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.75.woff2 +0 -0
  92. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.76.woff2 +0 -0
  93. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.77.woff2 +0 -0
  94. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.78.woff2 +0 -0
  95. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.79.woff2 +0 -0
  96. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.8.woff2 +0 -0
  97. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.80.woff2 +0 -0
  98. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.81.woff2 +0 -0
  99. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.82.woff2 +0 -0
  100. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.83.woff2 +0 -0
  101. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.84.woff2 +0 -0
  102. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.85.woff2 +0 -0
  103. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.86.woff2 +0 -0
  104. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.87.woff2 +0 -0
  105. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.88.woff2 +0 -0
  106. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.89.woff2 +0 -0
  107. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.9.woff2 +0 -0
  108. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.90.woff2 +0 -0
  109. package/src/asset/font/pretendard/woff2-dynamic-subset/PretendardVariable.subset.91.woff2 +0 -0
  110. package/src/cli.ts +162 -0
  111. package/src/client.ts +322 -0
  112. package/src/component/widget/prose/_constant/heading-highlight.ts +4 -0
  113. package/src/component/widget/prose/_constant/section.ts +7 -0
  114. package/src/component/widget/prose/_constant/status.ts +9 -0
  115. package/src/component/widget/prose/_function/has-block-content.ts +30 -0
  116. package/src/component/widget/prose/_function/rehype-head.ts +31 -0
  117. package/src/component/widget/prose/_function/rehype-sections/_to-section-headings.ts +20 -0
  118. package/src/component/widget/prose/_function/rehype-sections/rehype-sections.ts +21 -0
  119. package/src/component/widget/prose/_function/rehype-tables.ts +22 -0
  120. package/src/component/widget/prose/_function/remark-blocks.ts +112 -0
  121. package/src/component/widget/prose/_function/remark-flow.ts +26 -0
  122. package/src/component/widget/prose/_function/remark-links.ts +33 -0
  123. package/src/component/widget/prose/_function/remark-parts.ts +26 -0
  124. package/src/component/widget/prose/_function/remark-status.ts +59 -0
  125. package/src/component/widget/prose/_function/remark-swatch.ts +16 -0
  126. package/src/component/widget/prose/_function/remark-unknown-directives.ts +25 -0
  127. package/src/component/widget/prose/_type/doc-section.ts +9 -0
  128. package/src/component/widget/prose/wg-prose.css +381 -0
  129. package/src/component/widget/prose/wg-prose.tsx +43 -0
  130. package/src/component/widget/shell/_constant/cursor-face.ts +9 -0
  131. package/src/component/widget/shell/_constant/navigation.ts +4 -0
  132. package/src/component/widget/shell/_constant/reading-line.ts +4 -0
  133. package/src/component/widget/shell/_function/bind-mobile-navigation.ts +140 -0
  134. package/src/component/widget/shell/_function/to-toc-groups.ts +38 -0
  135. package/src/component/widget/shell/_type/doc-outline.ts +16 -0
  136. package/src/component/widget/shell/_type/toc-group.ts +39 -0
  137. package/src/component/widget/shell/_wg-shell-nav.css +134 -0
  138. package/src/component/widget/shell/_wg-shell-nav.tsx +75 -0
  139. package/src/component/widget/shell/_wg-shell-toc.css +268 -0
  140. package/src/component/widget/shell/_wg-shell-toc.tsx +57 -0
  141. package/src/component/widget/shell/wg-shell.css +261 -0
  142. package/src/component/widget/shell/wg-shell.tsx +147 -0
  143. package/src/constant/asset.ts +48 -0
  144. package/src/constant/cli.ts +19 -0
  145. package/src/constant/copy.ts +108 -0
  146. package/src/constant/doc.ts +13 -0
  147. package/src/constant/font.ts +39 -0
  148. package/src/constant/locale.ts +9 -0
  149. package/src/constant/site.ts +9 -0
  150. package/src/constant/status.ts +15 -0
  151. package/src/content/create-processor.ts +59 -0
  152. package/src/content/read-docs.test.ts +242 -0
  153. package/src/content/read-docs.ts +75 -0
  154. package/src/content/read-home.ts +32 -0
  155. package/src/content/rehype-heading-ids.ts +34 -0
  156. package/src/content/remark-report.ts +15 -0
  157. package/src/content/to-doc-groups.ts +18 -0
  158. package/src/dev.ts +79 -0
  159. package/src/env.d.ts +6 -0
  160. package/src/page/doc/pg-doc.tsx +35 -0
  161. package/src/page/home/pg-home.tsx +40 -0
  162. package/src/style/base.css +28 -0
  163. package/src/style/token.css +162 -0
  164. package/src/type/doc-content.ts +15 -0
  165. package/src/type/doc-data.ts +33 -0
  166. package/src/type/doc-file-data.ts +21 -0
  167. package/src/type/doc-heading.ts +17 -0
  168. package/src/type/doc.ts +16 -0
  169. package/src/type/site-assets.ts +17 -0
  170. package/src/type/site-config.ts +55 -0
  171. package/src/type/vfile-data.d.ts +10 -0
  172. package/src/util/dom/find-hash-target.ts +18 -0
  173. package/src/util/dom/reveal-hash-target.ts +15 -0
  174. package/src/util/error/to-error-message.ts +10 -0
  175. package/src/util/font/to-font-css.test.ts +41 -0
  176. package/src/util/font/to-font-css.ts +28 -0
  177. package/src/util/mermaid/render-flow.js +321 -0
package/src/client.ts ADDED
@@ -0,0 +1,322 @@
1
+ /*
2
+ * 브라우저 동작 · 모바일 탐색, 커서 장식, 읽는 절, hash 위치 보정
3
+ * 서버 HTML의 data-* 연결 · React hydration 없음
4
+ */
5
+
6
+ import {heading_highlight_hold_ms} from "@/component/widget/prose/_constant/heading-highlight";
7
+ import {cursor_face_offset_px, cursor_face_storage_key} from "@/component/widget/shell/_constant/cursor-face";
8
+ import {reading_line_slack_px} from "@/component/widget/shell/_constant/reading-line";
9
+ import {bindMobileNavigation} from "@/component/widget/shell/_function/bind-mobile-navigation";
10
+ import {findHashTarget} from "@/util/dom/find-hash-target";
11
+ import {revealHashTarget} from "@/util/dom/reveal-hash-target";
12
+
13
+ bindMobileNavigation();
14
+
15
+ const cursorFace = document.querySelector<HTMLImageElement>("[data-cursor-face]");
16
+
17
+ if (cursorFace) {
18
+ const cursorMedia = matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)");
19
+ const position = {x: 0, y: 0, frame: 0};
20
+
21
+ /**
22
+ * 터치 전환과 화면 이탈 시 예약된 이동도 취소해 장식이 다시 나타나지 않게 함
23
+ */
24
+ const handleCursorHide = () => {
25
+ cancelAnimationFrame(position.frame);
26
+ position.frame = 0;
27
+ cursorFace.classList.remove("wg_shell__cursorFace--visible");
28
+ };
29
+
30
+ /**
31
+ * 비활성 전환에서만 숨김 · 활성화 알림이 새 마우스 이동을 취소하지 않도록 함
32
+ */
33
+ const handleCursorMediaChange = () => {
34
+ if (!cursorMedia.matches) {
35
+ handleCursorHide();
36
+ }
37
+ };
38
+
39
+ /**
40
+ * 이동·화면 크기 변경·문서 진입에 같은 위치 계산 적용 · 화면 갱신마다 한 번만 반영
41
+ */
42
+ const renderCursorFace = () => {
43
+ if (position.frame !== 0) {
44
+ return;
45
+ }
46
+
47
+ position.frame = requestAnimationFrame(() => {
48
+ position.frame = 0;
49
+
50
+ // 포인터 좌표는 실행 중에만 결정 · transform으로 문서 재배치 방지
51
+ cursorFace.style.transform = `translate3d(
52
+ ${Math.max(0, Math.min(position.x + cursor_face_offset_px, document.documentElement.clientWidth - cursorFace.width - cursor_face_offset_px))}px,
53
+ ${Math.max(0, Math.min(position.y + cursor_face_offset_px, document.documentElement.clientHeight - cursorFace.height - cursor_face_offset_px))}px,
54
+ 0
55
+ )`;
56
+ cursorFace.classList.add("wg_shell__cursorFace--visible");
57
+ });
58
+ };
59
+
60
+ /**
61
+ * 누른 상태도 마우스 입력으로 처리 · 클릭·텍스트 선택 중에도 얼굴 유지
62
+ */
63
+ const handleDocumentPointer = (event: PointerEvent) => {
64
+ const isMouse = cursorMedia.matches && event.pointerType === "mouse";
65
+
66
+ if (!isMouse) {
67
+ handleCursorHide();
68
+
69
+ return;
70
+ }
71
+
72
+ position.x = event.clientX;
73
+ position.y = event.clientY;
74
+ renderCursorFace();
75
+ };
76
+
77
+ /**
78
+ * 이미 보이는 얼굴을 새 화면 크기에 맞춰 이동 · 추가 마우스 이동 불필요
79
+ */
80
+ const handleCursorResize = () => {
81
+ if (cursorFace.classList.contains("wg_shell__cursorFace--visible")) {
82
+ renderCursorFace();
83
+ }
84
+ };
85
+
86
+ /**
87
+ * 전체 페이지 이동에서도 마지막 좌표 유지 · 저장 실패는 기본 마우스 동작에 영향 없음
88
+ */
89
+ const handlePageHide = () => {
90
+ if (!cursorFace.classList.contains("wg_shell__cursorFace--visible")) {
91
+ return;
92
+ }
93
+
94
+ try {
95
+ sessionStorage.setItem(cursor_face_storage_key, JSON.stringify({x: position.x, y: position.y}));
96
+ } catch {
97
+ // 저장소가 막힌 환경에서도 클릭·선택과 현재 페이지의 장식 유지
98
+ }
99
+ };
100
+
101
+ /**
102
+ * 첫 진입과 BFCache 복귀에서 좌표를 한 번 소비 · 이전 문서의 위치가 남지 않도록 삭제
103
+ */
104
+ const handlePageShow = () => {
105
+ try {
106
+ const stored = sessionStorage.getItem(cursor_face_storage_key);
107
+
108
+ sessionStorage.removeItem(cursor_face_storage_key);
109
+
110
+ if (stored !== null && cursorMedia.matches) {
111
+ const point: unknown = JSON.parse(stored);
112
+ // 속성 타입의 좁히기를 유지하도록 저장소 검증과 사용을 같은 분기에 둠
113
+ if (
114
+ typeof point === "object" &&
115
+ point !== null &&
116
+ "x" in point &&
117
+ "y" in point &&
118
+ typeof point.x === "number" &&
119
+ typeof point.y === "number" &&
120
+ Number.isFinite(point.x) &&
121
+ Number.isFinite(point.y)
122
+ ) {
123
+ position.x = point.x;
124
+ position.y = point.y;
125
+ renderCursorFace();
126
+ }
127
+ }
128
+ } catch {
129
+ // 저장 실패나 잘못된 좌표는 무시하고 다음 실제 마우스 입력에서 표시
130
+ }
131
+ };
132
+
133
+ handlePageShow();
134
+
135
+ document.addEventListener("pointermove", handleDocumentPointer, {passive: true});
136
+ document.addEventListener("pointerdown", handleDocumentPointer, {passive: true});
137
+ document.documentElement.addEventListener("pointerleave", handleCursorHide);
138
+ addEventListener("blur", handleCursorHide);
139
+ addEventListener("resize", handleCursorResize);
140
+ addEventListener("pagehide", handlePageHide);
141
+ addEventListener("pageshow", handlePageShow);
142
+ cursorMedia.addEventListener("change", handleCursorMediaChange);
143
+ }
144
+
145
+ const hashTarget = revealHashTarget(location.hash);
146
+
147
+ // 값 조립이 아닌 현재 강조의 수명 상태 · 새 이동은 이전 타이머를 취소
148
+ let highlightedHeading: HTMLElement | null = null;
149
+ let highlightTimer = 0;
150
+ let highlightScroll: AbortController | null = null;
151
+
152
+ /**
153
+ * 강조 해제와 도착 감시 정리 · 다음 앵커의 오래된 타이머 개입 방지
154
+ */
155
+ const clearHeadingHighlight = () => {
156
+ clearTimeout(highlightTimer);
157
+ highlightScroll?.abort();
158
+ highlightScroll = null;
159
+ highlightedHeading?.classList.remove("wg_prose__headingText--highlighted");
160
+ highlightedHeading = null;
161
+ };
162
+
163
+ /**
164
+ * 도착한 제목만 잠시 강조 · 같은 대상 재클릭도 유지 시간 재시작
165
+ */
166
+ const highlightHeading = (target: HTMLElement | null) => {
167
+ clearHeadingHighlight();
168
+ highlightedHeading = target === null ? null : target.querySelector<HTMLElement>(".wg_prose__headingText");
169
+
170
+ if (highlightedHeading === null) {
171
+ return;
172
+ }
173
+
174
+ highlightedHeading.classList.add("wg_prose__headingText--highlighted");
175
+ highlightTimer = window.setTimeout(clearHeadingHighlight, heading_highlight_hold_ms);
176
+ highlightScroll = new AbortController();
177
+
178
+ // 부드러운 스크롤이 끝난 뒤에도 3초 확보 · 움직이지 않거나 scrollend가 없으면 위 타이머 사용
179
+ document.addEventListener("scrollend", handleHighlightArrival, {signal: highlightScroll.signal});
180
+ };
181
+
182
+ /**
183
+ * 본문 스크롤의 첫 도착에서 유지 시간 갱신 · 사이드바 스크롤은 제외
184
+ */
185
+ const handleHighlightArrival = (event: Event) => {
186
+ if (event.target !== document) {
187
+ return;
188
+ }
189
+
190
+ clearTimeout(highlightTimer);
191
+ highlightScroll?.abort();
192
+ highlightScroll = null;
193
+ highlightTimer = window.setTimeout(clearHeadingHighlight, heading_highlight_hold_ms);
194
+ };
195
+
196
+ /**
197
+ * 같은 hash를 다시 눌러도 접힌 대상 공개 · 기본 링크 이동 유지
198
+ */
199
+ const handleHashClick = (event: MouseEvent) => {
200
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
201
+ return;
202
+ }
203
+
204
+ if (
205
+ event.currentTarget instanceof HTMLAnchorElement &&
206
+ (event.currentTarget.target === "" || event.currentTarget.target === "_self") &&
207
+ !event.currentTarget.hasAttribute("download") &&
208
+ event.currentTarget.origin === location.origin &&
209
+ event.currentTarget.pathname === location.pathname &&
210
+ event.currentTarget.search === location.search &&
211
+ event.currentTarget.hash
212
+ ) {
213
+ const target = revealHashTarget(event.currentTarget.hash);
214
+
215
+ if (event.currentTarget.hash === location.hash) {
216
+ highlightHeading(target);
217
+ }
218
+ }
219
+ };
220
+
221
+ /**
222
+ * hash 변경의 숨은 대상 공개와 위치 보정
223
+ */
224
+ const handleHashChange = () => {
225
+ const target = revealHashTarget(location.hash);
226
+
227
+ target?.scrollIntoView();
228
+ highlightHeading(target);
229
+ };
230
+
231
+ for (const link of document.querySelectorAll<HTMLAnchorElement>("a[href]")) {
232
+ link.addEventListener("click", handleHashClick);
233
+ }
234
+
235
+ addEventListener("hashchange", handleHashChange);
236
+
237
+ // 목차 링크와 실제 제목 연결
238
+ const tocSections = [...document.querySelectorAll<HTMLAnchorElement>("[data-toc-link]")].flatMap((link) => {
239
+ const heading = findHashTarget(link.hash);
240
+ const item = link.parentElement;
241
+
242
+ if (!heading || !item) {
243
+ return [];
244
+ }
245
+
246
+ return [
247
+ {
248
+ heading,
249
+ link,
250
+ item,
251
+ list: item.parentElement,
252
+ subs: [...item.querySelectorAll<HTMLAnchorElement>("[data-toc-sub-link]")].flatMap((sub) => {
253
+ const subHeading = findHashTarget(sub.hash);
254
+ const subItem = sub.parentElement;
255
+
256
+ return subHeading && subItem ? [{heading: subHeading, link: sub, item: subItem}] : [];
257
+ }),
258
+ },
259
+ ];
260
+ });
261
+ const firstSection = tocSections.at(0);
262
+
263
+ if (firstSection) {
264
+ /**
265
+ * 읽는 선 기준 절·소제목 표시
266
+ * 기준: 제목의 scroll-margin-top + reading_line_slack_px
267
+ */
268
+ const markActive = () => {
269
+ // scroll-margin-top 계산값: CSS px
270
+ const line = Number.parseFloat(getComputedStyle(firstSection.heading).scrollMarginTop) + reading_line_slack_px;
271
+ // 접힌 Details 안 제목은 좌표가 남아도 현재 위치에서 제외
272
+ const current = tocSections.findLast((section) => section.heading.checkVisibility() && section.heading.getBoundingClientRect().top <= line) ?? firstSection;
273
+ const sub = current.subs.findLast((item) => item.heading.checkVisibility() && item.heading.getBoundingClientRect().top <= line);
274
+ const currentIndex = tocSections.indexOf(current);
275
+ const currentSubIndex = sub === undefined ? -1 : current.subs.indexOf(sub);
276
+
277
+ for (const [index, section] of tocSections.entries()) {
278
+ section.link.classList.toggle("wg_shellToc__link--active", section === current);
279
+ section.item.classList.toggle("wg_shellToc__item--trail", section.list === current.list && index < currentIndex);
280
+ section.item.classList.toggle("wg_shellToc__item--current", section === current);
281
+
282
+ for (const [subIndex, item] of section.subs.entries()) {
283
+ item.link.classList.toggle("wg_shellToc__subLink--active", item === sub);
284
+ item.item.classList.toggle("wg_shellToc__subItem--trail", section === current && subIndex < currentSubIndex);
285
+ item.item.classList.toggle("wg_shellToc__subItem--current", item === sub);
286
+ }
287
+ }
288
+ };
289
+
290
+ addEventListener("scroll", markActive, {passive: true});
291
+ addEventListener("resize", markActive);
292
+ addEventListener("toggle", markActive, true);
293
+ markActive();
294
+ }
295
+
296
+ const userScroll = new AbortController();
297
+
298
+ for (const type of ["wheel", "touchmove", "keydown", "mousedown"]) {
299
+ addEventListener(type, () => userScroll.abort(), {once: true, passive: true});
300
+ }
301
+
302
+ /**
303
+ * 글꼴 로드 후 hash 위치 보정 · 사용자 입력 시 자동 이동 취소
304
+ * 초기 smooth scroll과 보정의 충돌 방지 · 보정 후 CSS 동작 복원
305
+ */
306
+ const settleHashScroll = async () => {
307
+ await document.fonts.ready;
308
+
309
+ if (hashTarget && !userScroll.signal.aborted) {
310
+ hashTarget.scrollIntoView({behavior: "instant"});
311
+ highlightHeading(hashTarget);
312
+ }
313
+
314
+ document.documentElement.style.removeProperty("scroll-behavior");
315
+ };
316
+
317
+ // 글꼴 요청 완료 시점 보장 · load 후 fonts.ready
318
+ if (document.readyState === "complete") {
319
+ settleHashScroll();
320
+ } else {
321
+ addEventListener("load", settleHashScroll, {once: true});
322
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * 앵커 이동 후 제목 강조 유지 시간 · 이후 CSS로 서서히 해제
3
+ */
4
+ export const heading_highlight_hold_ms = 3000;
@@ -0,0 +1,7 @@
1
+ /**
2
+ * 목차 대상 제목 깊이 · h2 절, h3 소제목
3
+ */
4
+ export const section_heading_depth = {
5
+ section: 2,
6
+ sub: 3,
7
+ } as const;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 상태 문구 뒤 날짜 패턴 · YYYY-MM-DD
3
+ */
4
+ export const status_date_pattern = String.raw`\d{4}-\d{2}-\d{2}`;
5
+
6
+ /**
7
+ * 미등록 날짜 상태 후보 · 등록 문구 치환 뒤 남은 괄호 문구
8
+ */
9
+ export const status_candidate_pattern = new RegExp(String.raw`\([^()\n]*${status_date_pattern}\)`, "g");
@@ -0,0 +1,30 @@
1
+ import type {Nodes} from "mdast";
2
+
3
+ /**
4
+ * 부품 본문의 내용 판정 · 참조 정의·주석·빈 텍스트와 빈 구조 제외
5
+ * 이미지·표·구분선·주석 외 원시 HTML은 본문 요소
6
+ */
7
+ export const hasBlockContent = (node: Nodes): boolean => {
8
+ if (node.type === "definition") {
9
+ return false;
10
+ }
11
+
12
+ if (node.type === "html") {
13
+ // HTML의 빈 주석·대체 닫힘·미종결 주석 포함
14
+ return node.value.replace(/<!--(?:>|->|[\s\S]*?(?:--!?>|$))/g, "").trim() !== "";
15
+ }
16
+
17
+ if ("value" in node) {
18
+ return node.value.trim() !== "";
19
+ }
20
+
21
+ if (node.type === "table" || node.type === "textDirective") {
22
+ return true;
23
+ }
24
+
25
+ if ("children" in node) {
26
+ return node.children.some(hasBlockContent);
27
+ }
28
+
29
+ return true;
30
+ };
@@ -0,0 +1,31 @@
1
+ import type {Root} from "hast";
2
+ import type {VFile} from "vfile";
3
+ import {section_heading_depth} from "@/component/widget/prose/_constant/section";
4
+
5
+ /**
6
+ * name 제목과 첫 절 앞 본문의 공통 머리 · README는 원문의 h1 사용
7
+ */
8
+ export const rehypeHead = () => (tree: Root, file: VFile) => {
9
+ const frontmatter = file.data.fh?.frontmatter;
10
+
11
+ const firstSection = tree.children.findIndex((node) => node.type === "element" && node.tagName === `h${section_heading_depth.section}`);
12
+ const leadEnd = firstSection === -1 ? tree.children.length : firstSection;
13
+
14
+ if (frontmatter === undefined && leadEnd === 0) {
15
+ return;
16
+ }
17
+
18
+ tree.children = [
19
+ {
20
+ type: "element",
21
+ tagName: "header",
22
+ properties: {className: ["wg_prose__head"]},
23
+ children: [...tree.children.slice(0, leadEnd).filter((node) => node.type !== "doctype")],
24
+ },
25
+ ...tree.children.slice(leadEnd),
26
+ ];
27
+
28
+ if (frontmatter !== undefined && tree.children[0].type === "element") {
29
+ tree.children[0].children.unshift({type: "element", tagName: "h1", properties: {}, children: [{type: "text", value: frontmatter.name}]});
30
+ }
31
+ };
@@ -0,0 +1,20 @@
1
+ import type {Element, Root} from "hast";
2
+ import {section_heading_depth} from "@/component/widget/prose/_constant/section";
3
+
4
+ /**
5
+ * 목차 대상 h2·h3 · 중첩 블록까지 문서 순서 수집
6
+ * rehype-heading-ids의 순회 순서와 일치
7
+ */
8
+ export const toSectionHeadings = (node: Root | Element): Element[] => {
9
+ return node.children.flatMap((child) => {
10
+ if (child.type !== "element") {
11
+ return [];
12
+ }
13
+
14
+ if (child.tagName === `h${section_heading_depth.section}` || child.tagName === `h${section_heading_depth.sub}`) {
15
+ return [child];
16
+ }
17
+
18
+ return toSectionHeadings(child);
19
+ });
20
+ };
@@ -0,0 +1,21 @@
1
+ import type {Root} from "hast";
2
+ import type {VFile} from "vfile";
3
+ import {toSectionHeadings} from "@/component/widget/prose/_function/rehype-sections/_to-section-headings";
4
+ import type {DocSection} from "@/component/widget/prose/_type/doc-section";
5
+
6
+ /**
7
+ * h2·h3의 가름 메타데이터 수집 · 작성한 제목은 그대로 유지
8
+ * id·목차 텍스트는 앞 단계의 rehype-heading-ids 소유
9
+ * file.data.fh.sections는 headings의 h2·h3 순서와 일치
10
+ */
11
+ export const rehypeSections = () => (tree: Root, file: VFile) => {
12
+ if (file.data.fh === undefined) {
13
+ return;
14
+ }
15
+
16
+ file.data.fh.sections = toSectionHeadings(tree).map(
17
+ (heading): DocSection => ({
18
+ part: typeof heading.properties.dataPart === "string" ? heading.properties.dataPart : undefined,
19
+ }),
20
+ );
21
+ };
@@ -0,0 +1,22 @@
1
+ import type {Root} from "hast";
2
+ import {SKIP, visit} from "unist-util-visit";
3
+
4
+ /**
5
+ * 넓은 표의 내부 가로 스크롤 래퍼
6
+ */
7
+ export const rehypeTables = () => (tree: Root) => {
8
+ visit(tree, "element", (node, index, parent) => {
9
+ if (node.tagName !== "table" || parent === undefined || index === undefined) {
10
+ return;
11
+ }
12
+
13
+ parent.children[index] = {
14
+ type: "element",
15
+ tagName: "div",
16
+ properties: {className: ["wg_prose__table"]},
17
+ children: [node],
18
+ };
19
+
20
+ return [SKIP, index + 1];
21
+ });
22
+ };
@@ -0,0 +1,112 @@
1
+ import type {Root} from "mdast";
2
+ import {toString as toPlainText} from "mdast-util-to-string";
3
+ import {visit} from "unist-util-visit";
4
+ import type {VFile} from "vfile";
5
+ import {section_heading_depth} from "@/component/widget/prose/_constant/section";
6
+ import {hasBlockContent} from "@/component/widget/prose/_function/has-block-content";
7
+ import {copy_error_directive_syntax} from "@/constant/copy";
8
+
9
+ /**
10
+ * Note·Details의 제목·본문·속성·닫힘 계약
11
+ * label은 첫 자식 · summary는 Details의 첫 요소 · 원문 위치는 오류 기준
12
+ */
13
+ export const remarkBlocks = () => (tree: Root, file: VFile) => {
14
+ const source = String(file.value);
15
+
16
+ visit(tree, (node, _index, parent) => {
17
+ if (node.type === "leafDirective" && node.name === "part" && parent?.type !== "root") {
18
+ file.fail(`${copy_error_directive_syntax}: part · 문서 최상위에 작성`, node);
19
+ }
20
+
21
+ if (node.type !== "containerDirective" && node.type !== "leafDirective" && node.type !== "textDirective") {
22
+ return;
23
+ }
24
+
25
+ if (node.name !== "note" && node.name !== "details") {
26
+ return;
27
+ }
28
+
29
+ if (node.type !== "containerDirective") {
30
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · ::: 블록으로 작성`, node);
31
+ }
32
+
33
+ const label = node.children.at(0);
34
+
35
+ if (label?.type !== "paragraph" || label.data?.directiveLabel !== true || label.children.some((child) => child.type !== "text")) {
36
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · 일반 텍스트 제목 필수`, node);
37
+ }
38
+
39
+ const title = toPlainText(label).trim();
40
+
41
+ if (title === "") {
42
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · 비어 있지 않은 제목 필수`, node);
43
+ }
44
+
45
+ if (!node.children.slice(1).some(hasBlockContent)) {
46
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · 본문 필수`, node);
47
+ }
48
+
49
+ const unsupported = node.attributes ? Object.keys(node.attributes).find((key) => node.name !== "details" || key !== "open") : undefined;
50
+
51
+ if (unsupported !== undefined) {
52
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · 미지원 속성 ${unsupported}`, node);
53
+ }
54
+
55
+ const open = node.attributes?.open;
56
+ const attributeSource = source.slice(label.position?.end.offset, node.position?.end.offset).split(/\r?\n/, 1)[0].trim();
57
+
58
+ // 속성 정규화 이전의 표기 · 중복 open과 open="" 거부
59
+ if (attributeSource !== "" && (node.name !== "details" || !/^\{[ \t]*open[ \t]*\}$/.test(attributeSource))) {
60
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · 허용 속성은 Details의 {open}, 나머지는 생략`, node);
61
+ }
62
+
63
+ const original = source.slice(node.position?.start.offset, node.position?.end.offset);
64
+ const opening = /^(:{3,})/.exec(original);
65
+ const closing = /(?:^|\n)[ \t>]*(:{3,})[ \t]*\r?$/.exec(original);
66
+
67
+ if (
68
+ opening === null ||
69
+ closing === null ||
70
+ closing.index + closing[0].length !== original.length ||
71
+ closing[1].length < opening[1].length ||
72
+ node.position?.end.offset === node.children.at(-1)?.position?.end.offset
73
+ ) {
74
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · 블록을 닫는 fence 필수`, node);
75
+ }
76
+
77
+ visit(node, "heading", (heading) => {
78
+ if (heading.depth <= section_heading_depth.section) {
79
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · #·## 제목은 부품 밖에 작성`, heading);
80
+ }
81
+ });
82
+
83
+ // 코드 안의 :::가 container를 먼저 닫은 경우 · 더 긴 바깥 fence로 구분
84
+ visit(node, "code", (code) => {
85
+ const originalCode = source.slice(code.position?.start.offset, code.position?.end.offset);
86
+ const codeOpening = /^ {0,3}(`{3,}|~{3,})/.exec(originalCode);
87
+ const codeClosing = /(?:^|\n)[ \t>]*(`{3,}|~{3,})[ \t]*\r?$/.exec(originalCode);
88
+
89
+ if (
90
+ codeOpening !== null &&
91
+ (codeClosing === null ||
92
+ codeClosing.index + codeClosing[0].length !== originalCode.length ||
93
+ codeClosing[1][0] !== codeOpening[1][0] ||
94
+ codeClosing[1].length < codeOpening[1].length)
95
+ ) {
96
+ file.fail(`${copy_error_directive_syntax}: ${node.name} · 코드 fence를 닫고, 코드 안의 :보다 긴 바깥 fence 사용`, code);
97
+ }
98
+ });
99
+
100
+ label.children = [{type: "text", value: title}];
101
+ label.data = {
102
+ ...label.data,
103
+ hName: node.name === "note" ? "strong" : "summary",
104
+ hProperties: {className: [node.name === "note" ? "wg_prose__noteTitle" : "wg_prose__detailsSummary"]},
105
+ };
106
+ node.data = {
107
+ ...node.data,
108
+ hName: node.name === "note" ? "aside" : "details",
109
+ hProperties: node.name === "note" ? {className: ["wg_prose__note"], ariaLabel: title} : {className: ["wg_prose__details"], open: open === ""},
110
+ };
111
+ });
112
+ };
@@ -0,0 +1,26 @@
1
+ import type {Root} from "mdast";
2
+ import {visit} from "unist-util-visit";
3
+ import type {VFile} from "vfile";
4
+ import {copy_error_flow} from "@/constant/copy";
5
+ import {renderFlow} from "@/util/mermaid/render-flow";
6
+
7
+ /**
8
+ * Mermaid → 격자 SVG · beautiful-mermaid 기반
9
+ * 실패 시 파일·줄을 포함한 빌드 오류
10
+ */
11
+ export const remarkFlow = () => (tree: Root, file: VFile) => {
12
+ visit(tree, "code", (node, index, parent) => {
13
+ if (node.lang !== "mermaid" || parent === undefined || index === undefined) {
14
+ return;
15
+ }
16
+
17
+ try {
18
+ parent.children[index] = {
19
+ type: "html",
20
+ value: `<div class="wg_prose__flow">${renderFlow(node.value)}</div>`,
21
+ };
22
+ } catch (error) {
23
+ file.fail(`${copy_error_flow}: ${error instanceof Error ? error.message : String(error)}`, node);
24
+ }
25
+ });
26
+ };
@@ -0,0 +1,33 @@
1
+ import {existsSync} from "node:fs";
2
+ import {dirname, posix, relative, resolve} from "node:path";
3
+ import type {Root} from "mdast";
4
+ import {visit} from "unist-util-visit";
5
+ import type {VFile} from "vfile";
6
+ import {copy_warn_broken_link} from "@/constant/copy";
7
+
8
+ /**
9
+ * 상대 Markdown 링크 → 사이트 URL · query·hash 원문 유지
10
+ * 외부·절대 URL과 같은 페이지 hash 유지 · 없는 파일은 터미널 경고
11
+ */
12
+ export const remarkLinks = (options: {root: string}) => (tree: Root, file: VFile) => {
13
+ const here = dirname(relative(options.root, file.path));
14
+
15
+ visit(tree, (node) => {
16
+ if (node.type !== "link" && node.type !== "definition") {
17
+ return;
18
+ }
19
+
20
+ // 셋째 capture: query·hash 접미사 · 파일 확인과 경로 정규화에서 제외
21
+ const match = /^(?![a-z]+:|\/|#)([^#?]+)(\.mdx?)([?#].*|)$/i.exec(node.url);
22
+
23
+ if (match) {
24
+ if (!existsSync(resolve(dirname(file.path), `${match[1]}${match[2]}`))) {
25
+ file.message(`${copy_warn_broken_link}: ${node.url}`, node);
26
+ }
27
+
28
+ const target = posix.join(here, `${match[1]}${match[2]}`).toLowerCase();
29
+
30
+ node.url = target === "readme.md" ? `/${match[3]}` : `/${posix.join(here, match[1]).toLowerCase()}/${match[3]}`;
31
+ }
32
+ });
33
+ };
@@ -0,0 +1,26 @@
1
+ import type {Root} from "mdast";
2
+ import {toString as toPlainText} from "mdast-util-to-string";
3
+ import {section_heading_depth} from "@/component/widget/prose/_constant/section";
4
+
5
+ /**
6
+ * ::part[이름]의 본문 가름과 다음 h2의 data-part
7
+ * rehype-sections에서 같은 지점의 목차 묶음 생성
8
+ */
9
+ export const remarkParts = () => (tree: Root) => {
10
+ for (const [index, node] of tree.children.entries()) {
11
+ if (node.type !== "leafDirective" || node.name !== "part") {
12
+ continue;
13
+ }
14
+
15
+ const section = tree.children.slice(index + 1).find((next) => next.type === "heading" && next.depth === section_heading_depth.section);
16
+
17
+ node.data = {hName: "div", hProperties: {className: ["wg_prose__part"]}};
18
+
19
+ if (section) {
20
+ section.data = {
21
+ ...section.data,
22
+ hProperties: {...section.data?.hProperties, dataPart: toPlainText(node).trim()},
23
+ };
24
+ }
25
+ }
26
+ };