sh-ui-cli 0.25.0 → 0.32.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 (43) hide show
  1. package/README.md +8 -9
  2. package/data/changelog/versions.json +117 -1
  3. package/data/registry/react/components/code-editor/index.tsx +232 -0
  4. package/data/registry/react/components/code-editor/styles.css +76 -0
  5. package/data/registry/react/components/code-tabs/index.tsx +49 -0
  6. package/data/registry/react/components/header/index.tsx +632 -82
  7. package/data/registry/react/components/header/styles.css +169 -9
  8. package/data/registry/react/components/markdown-editor/index.tsx +121 -0
  9. package/data/registry/react/components/markdown-editor/styles.css +160 -0
  10. package/data/registry/react/components/page-toc/index.tsx +175 -0
  11. package/data/registry/react/components/page-toc/styles.css +82 -0
  12. package/data/registry/react/components/rich-text-editor/index.tsx +350 -0
  13. package/data/registry/react/components/rich-text-editor/styles.css +196 -0
  14. package/data/registry/react/registry.json +100 -0
  15. package/data/summaries/react.json +6 -1
  16. package/package.json +1 -1
  17. package/src/create/cli-args.js +1 -1
  18. package/src/create/index.mjs +1 -1
  19. package/src/create/plugins/authJwt.js +340 -0
  20. package/src/create/plugins/index.js +2 -1
  21. package/src/create/plugins/sentry.js +32 -280
  22. package/src/mcp.mjs +1 -2
  23. package/templates/flutter-standalone/README.md +2 -2
  24. package/templates/monorepo/README.md +1 -1
  25. package/templates/nextjs-app/app/api/proxy/[...path]/route.ts +31 -4
  26. package/templates/nextjs-app/package.json +0 -1
  27. package/templates/nextjs-app/src/app/providers/tanstack/QueryClientProvider.tsx +5 -18
  28. package/templates/nextjs-app/src/shared/api/clientFetch.ts +40 -0
  29. package/templates/nextjs-app/src/shared/api/http.ts +13 -56
  30. package/templates/nextjs-app/src/shared/api/observability.ts +20 -0
  31. package/templates/nextjs-app/src/shared/api/queryClient.ts +30 -0
  32. package/templates/nextjs-app/src/shared/api/serverFetch.ts +59 -0
  33. package/templates/nextjs-app/src/shared/hooks/useAppMutation.ts +52 -0
  34. package/templates/nextjs-standalone/README.md +3 -3
  35. package/templates/nextjs-standalone/app/api/proxy/[...path]/route.ts +31 -4
  36. package/templates/nextjs-standalone/package.json +0 -1
  37. package/templates/nextjs-standalone/src/app/providers/tanstack/QueryClientProvider.tsx +5 -18
  38. package/templates/nextjs-standalone/src/shared/api/clientFetch.ts +40 -0
  39. package/templates/nextjs-standalone/src/shared/api/http.ts +13 -56
  40. package/templates/nextjs-standalone/src/shared/api/observability.ts +20 -0
  41. package/templates/nextjs-standalone/src/shared/api/queryClient.ts +30 -0
  42. package/templates/nextjs-standalone/src/shared/api/serverFetch.ts +59 -0
  43. package/templates/nextjs-standalone/src/shared/hooks/useAppMutation.ts +52 -0
@@ -8,6 +8,49 @@
8
8
  padding: 0 var(--space-3);
9
9
  background: var(--background);
10
10
  border-bottom: 1px solid var(--border);
11
+ transition: transform var(--duration-base) var(--ease-standard),
12
+ background-color var(--duration-base) var(--ease-standard);
13
+ /* hover/active 배경 — transparent/blur variant 가 currentColor 기반으로 재정의 */
14
+ --sh-ui-header-hover-bg: var(--background-muted);
15
+ /* blur variant 튜닝용 — instance 별 style 로 override 가능 */
16
+ --sh-ui-header-blur-opacity: 85%;
17
+ --sh-ui-header-blur-radius: 16px;
18
+ }
19
+
20
+ /* prefers-reduced-motion — stickyHide 슬라이드 애니메이션을 즉시 toggle 로 대체.
21
+ variant 전환의 background 트랜지션도 비활성화. */
22
+ @media (prefers-reduced-motion: reduce) {
23
+ .sh-ui-header {
24
+ transition: none;
25
+ }
26
+ }
27
+
28
+ /* variant */
29
+ .sh-ui-header--solid {
30
+ background: var(--background);
31
+ }
32
+ .sh-ui-header--transparent {
33
+ background: transparent;
34
+ border-bottom-color: transparent;
35
+ /* 컬러풀 배경 위에서도 자연스러운 hover — 텍스트 색의 14% 오버레이 */
36
+ --sh-ui-header-hover-bg: color-mix(in srgb, currentColor 14%, transparent);
37
+ }
38
+ .sh-ui-header--blur {
39
+ background: color-mix(in srgb, var(--background) var(--sh-ui-header-blur-opacity), transparent);
40
+ backdrop-filter: saturate(180%) blur(var(--sh-ui-header-blur-radius));
41
+ -webkit-backdrop-filter: saturate(180%) blur(var(--sh-ui-header-blur-radius));
42
+ --sh-ui-header-hover-bg: color-mix(in srgb, currentColor 14%, transparent);
43
+ }
44
+ /* backdrop-filter 미지원 브라우저 폴백 — 더 불투명한 배경으로 가독성 확보 */
45
+ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
46
+ .sh-ui-header--blur {
47
+ background: var(--background);
48
+ }
49
+ }
50
+
51
+ /* sticky-hide — sticky 컨텍스트는 사용자가 직접 (예: position:sticky; top:0) 적용. */
52
+ .sh-ui-header[data-sticky-hide][data-hidden] {
53
+ transform: translateY(-100%);
11
54
  }
12
55
 
13
56
  /* ───── Brand ───── */
@@ -17,13 +60,11 @@
17
60
  gap: var(--space-2);
18
61
  flex-shrink: 0;
19
62
  }
20
-
21
63
  .sh-ui-header__logo {
22
64
  display: inline-flex;
23
65
  align-items: center;
24
66
  color: var(--foreground);
25
67
  }
26
-
27
68
  .sh-ui-header__title {
28
69
  font-size: var(--text-base);
29
70
  font-weight: var(--weight-bold);
@@ -47,7 +88,7 @@
47
88
  transition: background-color var(--duration-fast);
48
89
  }
49
90
  .sh-ui-header__trigger:hover {
50
- background: var(--background-muted);
91
+ background: var(--sh-ui-header-hover-bg);
51
92
  }
52
93
  .sh-ui-header__trigger:focus-visible {
53
94
  outline: var(--border-width-strong) solid var(--foreground);
@@ -87,7 +128,7 @@
87
128
  }
88
129
  .sh-ui-header__item:hover {
89
130
  color: var(--foreground);
90
- background: var(--background-muted);
131
+ background: var(--sh-ui-header-hover-bg);
91
132
  }
92
133
  .sh-ui-header__item[data-active] {
93
134
  color: var(--foreground);
@@ -107,6 +148,102 @@
107
148
  flex-shrink: 0;
108
149
  }
109
150
 
151
+ /* ───── 반응형 가시성 유틸 (HeaderDesktopOnly / HeaderMobileOnly) ─────
152
+ * display: contents 로 wrapper 가 레이아웃에 잡히지 않게 함 — 부모(HeaderActions 등)의 flex 흐름 유지.
153
+ */
154
+ .sh-ui-header__desktop-only {
155
+ display: contents;
156
+ }
157
+ .sh-ui-header__mobile-only {
158
+ display: none;
159
+ }
160
+
161
+ /* ───── NavGroup ─────
162
+ * inline 모드에서는 자식만 펼친 것처럼 평면 렌더(라벨 숨김).
163
+ * drawer 모드에서는 라벨 + 들여쓴 항목.
164
+ */
165
+ .sh-ui-header__group--inline {
166
+ display: contents;
167
+ }
168
+ .sh-ui-header__group--drawer {
169
+ display: flex;
170
+ flex-direction: column;
171
+ margin-top: var(--space-3);
172
+ }
173
+ .sh-ui-header__group--drawer:first-child {
174
+ margin-top: 0;
175
+ }
176
+ .sh-ui-header__group-label {
177
+ display: flex;
178
+ align-items: center;
179
+ height: 2rem;
180
+ padding: 0 var(--space-2);
181
+ font-size: var(--text-xs);
182
+ font-weight: var(--weight-medium);
183
+ color: var(--foreground-muted);
184
+ }
185
+ .sh-ui-header__group-items {
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: 1px;
189
+ }
190
+
191
+ /* ───── Menu (서브메뉴) — inline 모드 = dropdown ───── */
192
+ .sh-ui-header__menu {
193
+ position: relative;
194
+ }
195
+ .sh-ui-header__menu--inline {
196
+ display: inline-block;
197
+ }
198
+ .sh-ui-header__menu-trigger {
199
+ display: inline-flex;
200
+ align-items: center;
201
+ gap: var(--space-1);
202
+ padding: var(--space-2) var(--space-3);
203
+ font-size: var(--text-sm);
204
+ font-weight: var(--weight-medium);
205
+ color: var(--foreground-muted);
206
+ background: transparent;
207
+ border: 0;
208
+ border-radius: calc(var(--radius) - 2px);
209
+ cursor: pointer;
210
+ white-space: nowrap;
211
+ transition: color var(--duration-fast), background-color var(--duration-fast);
212
+ }
213
+ .sh-ui-header__menu-trigger:hover,
214
+ .sh-ui-header__menu-trigger[data-open] {
215
+ color: var(--foreground);
216
+ background: var(--sh-ui-header-hover-bg);
217
+ }
218
+ .sh-ui-header__menu-trigger:focus-visible {
219
+ outline: var(--border-width-strong) solid var(--foreground);
220
+ outline-offset: 2px;
221
+ }
222
+ .sh-ui-header__chevron {
223
+ transition: transform var(--duration-fast) var(--ease-standard);
224
+ }
225
+ .sh-ui-header__menu-trigger[data-open] .sh-ui-header__chevron {
226
+ transform: rotate(180deg);
227
+ }
228
+
229
+ /* inline 모드 dropdown 은 portal 로 document.body 에 렌더된다 — 부모 overflow 영향 받지 않음. */
230
+ .sh-ui-header__menu-content--portal {
231
+ z-index: var(--z-dropdown, 50);
232
+ padding: var(--space-1);
233
+ background: var(--background);
234
+ border: 1px solid var(--border);
235
+ border-radius: var(--radius);
236
+ box-shadow: 0 8px 24px -8px rgba(0, 0, 0, 0.18);
237
+ display: flex;
238
+ flex-direction: column;
239
+ gap: 1px;
240
+ color: var(--foreground);
241
+ }
242
+ .sh-ui-header__menu-content--portal .sh-ui-header__item {
243
+ padding: var(--space-2) var(--space-3);
244
+ font-size: var(--text-sm);
245
+ }
246
+
110
247
  /* ───── Drawer (backdrop + panel) — 기본 숨김 ───── */
111
248
  .sh-ui-header__backdrop,
112
249
  .sh-ui-header__drawer {
@@ -115,19 +252,23 @@
115
252
 
116
253
  /* ───── Mobile (< breakpoint.md) ───── */
117
254
  @media (max-width: 767px) {
118
- /* 햄버거 노출 & 좌측 배치 */
119
255
  .sh-ui-header__trigger {
120
256
  display: inline-flex;
121
257
  order: -1;
122
258
  }
123
- /* inline nav 숨김 */
124
259
  .sh-ui-header__nav {
125
260
  display: none;
126
261
  }
127
- /* 모바일에서 gap 조정 */
128
262
  .sh-ui-header {
129
263
  gap: var(--space-2);
130
264
  }
265
+ /* 가시성 유틸 토글 */
266
+ .sh-ui-header__desktop-only {
267
+ display: none;
268
+ }
269
+ .sh-ui-header__mobile-only {
270
+ display: contents;
271
+ }
131
272
 
132
273
  /* backdrop */
133
274
  .sh-ui-header__backdrop {
@@ -148,7 +289,7 @@
148
289
 
149
290
  /* drawer panel */
150
291
  .sh-ui-header__drawer {
151
- display: block;
292
+ display: flex;
152
293
  position: fixed;
153
294
  left: 0;
154
295
  top: 0;
@@ -159,7 +300,6 @@
159
300
  z-index: var(--z-modal);
160
301
  transform: translateX(-100%);
161
302
  transition: transform var(--duration-base) var(--ease-standard);
162
- display: flex;
163
303
  flex-direction: column;
164
304
  overflow-y: auto;
165
305
  }
@@ -187,4 +327,24 @@
187
327
  font-size: var(--text-sm);
188
328
  border-radius: calc(var(--radius) - 2px);
189
329
  }
330
+
331
+ /* Menu in drawer = collapsible */
332
+ .sh-ui-header__menu--drawer {
333
+ display: flex;
334
+ flex-direction: column;
335
+ }
336
+ .sh-ui-header__menu--drawer > .sh-ui-header__menu-trigger {
337
+ justify-content: space-between;
338
+ width: 100%;
339
+ padding: var(--space-3) var(--space-3);
340
+ }
341
+ .sh-ui-header__menu--drawer > .sh-ui-header__menu-content {
342
+ display: flex;
343
+ flex-direction: column;
344
+ padding: var(--space-1) 0 var(--space-1) var(--space-4);
345
+ gap: 1px;
346
+ }
347
+ .sh-ui-header__menu--drawer > .sh-ui-header__menu-content[hidden] {
348
+ display: none;
349
+ }
190
350
  }
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import ReactMarkdown from "react-markdown";
5
+ import remarkGfm from "remark-gfm";
6
+ import { CodeEditor } from "../code-editor";
7
+ import "./styles.css";
8
+
9
+ export interface MarkdownEditorProps {
10
+ /**
11
+ * Controlled — 현재 마크다운. 명시 시 외부 상태가 진실원천.
12
+ * 미지정이면 uncontrolled — 컴포넌트가 자체 내부 상태로 동작.
13
+ */
14
+ value?: string;
15
+ /**
16
+ * Uncontrolled 초기값. value 미지정 시에만 사용된다.
17
+ * @default ""
18
+ */
19
+ defaultValue?: string;
20
+ /** 마크다운이 바뀔 때마다 호출 (controlled · uncontrolled 모두). */
21
+ onChange?: (value: string) => void;
22
+ /** 비어 있을 때 표시할 placeholder. */
23
+ placeholder?: string;
24
+ /** 읽기 전용. 키 입력 차단, 미리보기는 그대로 렌더. */
25
+ readOnly?: boolean;
26
+ /**
27
+ * 미리보기 패널 표시 여부.
28
+ * @default true
29
+ */
30
+ preview?: boolean;
31
+ /**
32
+ * 미리보기 위치. 좁은 화면(<768px)에서는 항상 아래로 쌓임.
33
+ * @default "right"
34
+ */
35
+ previewPosition?: "right" | "bottom";
36
+ /** 에디터·미리보기 영역의 최소 높이 (CSS 길이 단위). */
37
+ minHeight?: string;
38
+ /** 에디터·미리보기 영역의 최대 높이. 초과 시 내부 스크롤. */
39
+ maxHeight?: string;
40
+ className?: string;
41
+ /** 에디터 영역에 부여할 aria-label. */
42
+ "aria-label"?: string;
43
+ }
44
+
45
+ function cx(...args: (string | undefined | false | null)[]) {
46
+ return args.filter(Boolean).join(" ");
47
+ }
48
+
49
+ /**
50
+ * 마크다운 에디터 — CodeEditor(소스) + react-markdown(라이브 프리뷰)의 합성.
51
+ *
52
+ * Controlled (value/onChange) · Uncontrolled (defaultValue) 모두 지원. 미리보기 패널이
53
+ * 현재 마크다운을 필요로 하므로 uncontrolled 모드에서도 내부 상태로 트래킹.
54
+ *
55
+ * 미리보기는 GFM(테이블·체크박스·strikethrough)을 지원하고, raw HTML은 기본적으로
56
+ * 차단(react-markdown 기본 동작)되어 사용자 입력으로부터의 XSS가 자동 방어된다.
57
+ */
58
+ export function MarkdownEditor({
59
+ value: valueProp,
60
+ defaultValue,
61
+ onChange,
62
+ placeholder,
63
+ readOnly,
64
+ preview = true,
65
+ previewPosition = "right",
66
+ minHeight,
67
+ maxHeight,
68
+ className,
69
+ "aria-label": ariaLabel = "Markdown editor",
70
+ }: MarkdownEditorProps) {
71
+ const isControlled = valueProp !== undefined;
72
+ const [internalValue, setInternalValue] = useState(valueProp ?? defaultValue ?? "");
73
+ const value = isControlled ? valueProp : internalValue;
74
+
75
+ const handleChange = (next: string) => {
76
+ if (!isControlled) setInternalValue(next);
77
+ onChange?.(next);
78
+ };
79
+
80
+ return (
81
+ <div
82
+ className={cx(
83
+ "sh-ui-md-editor",
84
+ preview && `sh-ui-md-editor--${previewPosition}`,
85
+ !preview && "sh-ui-md-editor--no-preview",
86
+ className,
87
+ )}
88
+ data-readonly={readOnly || undefined}
89
+ >
90
+ <div className="sh-ui-md-editor__source">
91
+ <CodeEditor
92
+ value={value}
93
+ onChange={handleChange}
94
+ language="markdown"
95
+ placeholder={placeholder}
96
+ readOnly={readOnly}
97
+ minHeight={minHeight}
98
+ maxHeight={maxHeight}
99
+ aria-label={ariaLabel}
100
+ />
101
+ </div>
102
+ {preview && (
103
+ <div
104
+ className="sh-ui-md-editor__preview"
105
+ role="region"
106
+ aria-label="Preview"
107
+ style={
108
+ {
109
+ "--sh-ui-md-editor-min-height": minHeight,
110
+ "--sh-ui-md-editor-max-height": maxHeight,
111
+ } as React.CSSProperties
112
+ }
113
+ >
114
+ <div className="sh-ui-md-editor__preview-inner">
115
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
116
+ </div>
117
+ </div>
118
+ )}
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,160 @@
1
+ .sh-ui-md-editor {
2
+ display: grid;
3
+ gap: var(--space-3);
4
+ }
5
+ .sh-ui-md-editor--right {
6
+ grid-template-columns: 1fr 1fr;
7
+ }
8
+ .sh-ui-md-editor--bottom,
9
+ .sh-ui-md-editor--no-preview {
10
+ grid-template-columns: 1fr;
11
+ }
12
+
13
+ @media (max-width: 768px) {
14
+ .sh-ui-md-editor--right {
15
+ grid-template-columns: 1fr;
16
+ }
17
+ }
18
+
19
+ .sh-ui-md-editor__source {
20
+ min-width: 0;
21
+ }
22
+
23
+ .sh-ui-md-editor__preview {
24
+ min-width: 0;
25
+ border: 1px solid var(--border);
26
+ border-radius: var(--radius);
27
+ background: var(--background);
28
+ overflow: hidden;
29
+ }
30
+
31
+ .sh-ui-md-editor__preview-inner {
32
+ padding: var(--space-3) var(--space-4);
33
+ min-height: var(--sh-ui-md-editor-min-height, 7.5rem);
34
+ max-height: var(--sh-ui-md-editor-max-height, 25rem);
35
+ overflow-y: auto;
36
+ font-size: 0.875rem;
37
+ line-height: 1.65;
38
+ color: var(--foreground);
39
+ }
40
+
41
+ /* 마크다운 본문 타이포그래피 — 토큰 기반, 페이지 기본 스타일과 충돌 없도록 자기 스코프 안에서만 */
42
+ .sh-ui-md-editor__preview-inner > :first-child {
43
+ margin-top: 0;
44
+ }
45
+ .sh-ui-md-editor__preview-inner > :last-child {
46
+ margin-bottom: 0;
47
+ }
48
+ .sh-ui-md-editor__preview-inner h1,
49
+ .sh-ui-md-editor__preview-inner h2,
50
+ .sh-ui-md-editor__preview-inner h3,
51
+ .sh-ui-md-editor__preview-inner h4,
52
+ .sh-ui-md-editor__preview-inner h5,
53
+ .sh-ui-md-editor__preview-inner h6 {
54
+ margin-top: var(--space-4);
55
+ margin-bottom: var(--space-2);
56
+ font-weight: 600;
57
+ line-height: 1.3;
58
+ color: var(--foreground);
59
+ }
60
+ .sh-ui-md-editor__preview-inner h1 { font-size: 1.5rem; }
61
+ .sh-ui-md-editor__preview-inner h2 { font-size: 1.25rem; }
62
+ .sh-ui-md-editor__preview-inner h3 { font-size: 1.125rem; }
63
+ .sh-ui-md-editor__preview-inner h4,
64
+ .sh-ui-md-editor__preview-inner h5,
65
+ .sh-ui-md-editor__preview-inner h6 { font-size: 1rem; }
66
+
67
+ .sh-ui-md-editor__preview-inner p,
68
+ .sh-ui-md-editor__preview-inner ul,
69
+ .sh-ui-md-editor__preview-inner ol,
70
+ .sh-ui-md-editor__preview-inner blockquote,
71
+ .sh-ui-md-editor__preview-inner pre,
72
+ .sh-ui-md-editor__preview-inner table {
73
+ margin-top: 0;
74
+ margin-bottom: var(--space-3);
75
+ }
76
+
77
+ .sh-ui-md-editor__preview-inner ul,
78
+ .sh-ui-md-editor__preview-inner ol {
79
+ padding-left: var(--space-5);
80
+ }
81
+ .sh-ui-md-editor__preview-inner li {
82
+ margin-bottom: var(--space-1);
83
+ }
84
+ .sh-ui-md-editor__preview-inner li > input[type="checkbox"] {
85
+ margin-right: var(--space-2);
86
+ }
87
+
88
+ .sh-ui-md-editor__preview-inner a {
89
+ color: var(--primary);
90
+ text-decoration: underline;
91
+ text-underline-offset: 2px;
92
+ }
93
+ .sh-ui-md-editor__preview-inner a:hover {
94
+ text-decoration-thickness: 2px;
95
+ }
96
+
97
+ .sh-ui-md-editor__preview-inner blockquote {
98
+ padding: var(--space-2) var(--space-3);
99
+ border-left: 3px solid var(--border-strong);
100
+ background: var(--background-subtle);
101
+ color: var(--foreground-muted);
102
+ border-radius: 0 calc(var(--radius) - 2px) calc(var(--radius) - 2px) 0;
103
+ }
104
+ .sh-ui-md-editor__preview-inner blockquote > :last-child {
105
+ margin-bottom: 0;
106
+ }
107
+
108
+ .sh-ui-md-editor__preview-inner code {
109
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
110
+ font-size: 0.875em;
111
+ padding: 0.125rem 0.375rem;
112
+ border-radius: calc(var(--radius) - 4px);
113
+ background: var(--background-muted);
114
+ color: var(--foreground);
115
+ }
116
+ .sh-ui-md-editor__preview-inner pre {
117
+ padding: var(--space-3);
118
+ border: 1px solid var(--border);
119
+ border-radius: var(--radius);
120
+ background: var(--background-subtle);
121
+ overflow-x: auto;
122
+ font-size: 0.8125rem;
123
+ line-height: 1.6;
124
+ }
125
+ .sh-ui-md-editor__preview-inner pre > code {
126
+ padding: 0;
127
+ background: transparent;
128
+ font-size: inherit;
129
+ }
130
+
131
+ .sh-ui-md-editor__preview-inner hr {
132
+ border: 0;
133
+ border-top: 1px solid var(--border);
134
+ margin: var(--space-4) 0;
135
+ }
136
+
137
+ .sh-ui-md-editor__preview-inner table {
138
+ width: 100%;
139
+ border-collapse: collapse;
140
+ font-size: 0.875rem;
141
+ }
142
+ .sh-ui-md-editor__preview-inner th,
143
+ .sh-ui-md-editor__preview-inner td {
144
+ padding: var(--space-2) var(--space-3);
145
+ border: 1px solid var(--border);
146
+ text-align: left;
147
+ }
148
+ .sh-ui-md-editor__preview-inner thead {
149
+ background: var(--background-subtle);
150
+ }
151
+
152
+ .sh-ui-md-editor__preview-inner img {
153
+ max-width: 100%;
154
+ height: auto;
155
+ border-radius: calc(var(--radius) - 2px);
156
+ }
157
+
158
+ .sh-ui-md-editor__preview-inner del {
159
+ color: var(--foreground-muted);
160
+ }
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import "./styles.css";
5
+
6
+ export type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
7
+
8
+ export interface PageTOCProps {
9
+ /**
10
+ * 스캔할 컨테이너 selector. 기본 `"main"`.
11
+ */
12
+ containerSelector?: string;
13
+ /**
14
+ * 외부 신호로 TOC 재스캔. Next.js 사용 시 `usePathname()` 결과를 그대로 전달하면
15
+ * 라우트 변경 때마다 자동 갱신. 같은 페이지 안에서 헤딩이 동적으로 바뀌면 이 값을 갱신.
16
+ */
17
+ routeKey?: string;
18
+ /**
19
+ * sticky 헤더 아래로 헤딩이 가려지지 않도록 띄울 거리(rem). `scroll-margin-top` 으로 적용.
20
+ * @default 5
21
+ */
22
+ headerOffsetRem?: number;
23
+ /**
24
+ * 라벨 텍스트.
25
+ * @default "On this page"
26
+ */
27
+ label?: React.ReactNode;
28
+ /**
29
+ * 수집할 헤딩 레벨.
30
+ * @default ["h2", "h3"]
31
+ */
32
+ levels?: HeadingLevel[];
33
+ /**
34
+ * 제외할 헤딩 selector. 컨테이너 안에서 이 selector 의 자손인 헤딩은 무시.
35
+ * 데모 미리보기·중첩 위젯 등을 TOC 에서 빼고 싶을 때 사용.
36
+ */
37
+ excludeSelector?: string;
38
+ /** 추가 클래스. */
39
+ className?: string;
40
+ }
41
+
42
+ const slugify = (text: string): string =>
43
+ text
44
+ .trim()
45
+ .toLowerCase()
46
+ .replace(/[^\w\s가-힣-]/g, "")
47
+ .replace(/\s+/g, "-");
48
+
49
+ interface TocItem {
50
+ id: string;
51
+ text: string;
52
+ level: HeadingLevel;
53
+ }
54
+
55
+ const cx = (...args: (string | undefined | false | null)[]) =>
56
+ args.filter(Boolean).join(" ");
57
+
58
+ /**
59
+ * 페이지 내 자동 목차 (On this page).
60
+ *
61
+ * 컨테이너 안의 지정한 헤딩 레벨을 스캔해 자동 slugify · id 부여 · `IntersectionObserver` 로
62
+ * 현재 보이는 섹션을 active 표시 · 클릭 시 smooth scroll. 라우터 비종속 — `routeKey` 를
63
+ * 외부에서 갱신하면 재스캔된다.
64
+ */
65
+ export function PageTOC({
66
+ containerSelector = "main",
67
+ routeKey,
68
+ headerOffsetRem = 5,
69
+ label = "On this page",
70
+ levels = ["h2", "h3"],
71
+ excludeSelector,
72
+ className,
73
+ }: PageTOCProps) {
74
+ const [items, setItems] = React.useState<TocItem[]>([]);
75
+ const [activeId, setActiveId] = React.useState<string | null>(null);
76
+
77
+ // levels 가 inline 배열(["h2", "h3"]) 로 전달되면 매 렌더마다 새 참조라 useEffect 가
78
+ // 무한 루프에 빠짐. 내용 기반 안정 키로 비교하고, 효과 안에서는 ref 로 최신 값 사용.
79
+ const levelsKey = levels.join(",");
80
+ const levelsRef = React.useRef(levels);
81
+ levelsRef.current = levels;
82
+
83
+ React.useEffect(() => {
84
+ const container = document.querySelector(containerSelector);
85
+ if (!container) {
86
+ setItems([]);
87
+ return;
88
+ }
89
+
90
+ const headingSelector = levelsRef.current.join(", ");
91
+ let headings = Array.from(
92
+ container.querySelectorAll<HTMLHeadingElement>(headingSelector),
93
+ );
94
+ if (excludeSelector) {
95
+ headings = headings.filter((h) => !h.closest(excludeSelector));
96
+ }
97
+
98
+ const usedIds = new Set<string>();
99
+ const collected: TocItem[] = headings.map((h) => {
100
+ const text = h.textContent?.trim() ?? "";
101
+ let id = h.id || slugify(text);
102
+ let suffix = 2;
103
+ const base = id;
104
+ while (!id || usedIds.has(id)) {
105
+ id = `${base}-${suffix++}`;
106
+ }
107
+ usedIds.add(id);
108
+ if (!h.id) h.id = id;
109
+ h.style.scrollMarginTop = `${headerOffsetRem}rem`;
110
+ const level = h.tagName.toLowerCase() as HeadingLevel;
111
+ return { id, text, level };
112
+ });
113
+
114
+ setItems(collected);
115
+
116
+ if (collected.length === 0) return;
117
+
118
+ const remInPx =
119
+ parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
120
+ const topOffsetPx = Math.round(headerOffsetRem * remInPx);
121
+
122
+ const observer = new IntersectionObserver(
123
+ (entries) => {
124
+ const visible = entries
125
+ .filter((e) => e.isIntersecting)
126
+ .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
127
+ if (visible.length > 0) {
128
+ setActiveId(visible[0].target.id);
129
+ }
130
+ },
131
+ {
132
+ rootMargin: `-${topOffsetPx}px 0px -70% 0px`,
133
+ threshold: 0,
134
+ },
135
+ );
136
+
137
+ headings.forEach((h) => observer.observe(h));
138
+ return () => observer.disconnect();
139
+ }, [containerSelector, headerOffsetRem, levelsKey, excludeSelector, routeKey]);
140
+
141
+ const handleClick = (event: React.MouseEvent<HTMLAnchorElement>, id: string) => {
142
+ event.preventDefault();
143
+ const el = document.getElementById(id);
144
+ if (!el) return;
145
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
146
+ history.replaceState(null, "", `#${id}`);
147
+ setActiveId(id);
148
+ };
149
+
150
+ if (items.length === 0) return null;
151
+
152
+ return (
153
+ <nav
154
+ className={cx("sh-ui-page-toc", className)}
155
+ aria-label={typeof label === "string" ? label : "목차"}
156
+ >
157
+ <div className="sh-ui-page-toc__label">{label}</div>
158
+ <ul className="sh-ui-page-toc__list">
159
+ {items.map((item) => (
160
+ <li key={item.id} data-level={item.level.replace("h", "")}>
161
+ <a
162
+ href={`#${item.id}`}
163
+ onClick={(e) => handleClick(e, item.id)}
164
+ className="sh-ui-page-toc__link"
165
+ data-active={activeId === item.id ? "true" : undefined}
166
+ aria-current={activeId === item.id ? "true" : undefined}
167
+ >
168
+ {item.text}
169
+ </a>
170
+ </li>
171
+ ))}
172
+ </ul>
173
+ </nav>
174
+ );
175
+ }