@jogak/ui 0.1.0-alpha.9.1 → 1.0.0-beta.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.
- package/CHANGELOG.md +136 -0
- package/dist/components/Preview/format-usage.d.ts +6 -0
- package/dist/components/Preview/index.d.ts +1 -1
- package/dist/host/index.cjs +1 -1
- package/dist/host/index.d.ts +1 -1
- package/dist/host/index.mjs +12 -12
- package/dist/index.cjs +9 -1
- package/dist/index.mjs +637 -462
- package/dist/lib/adapter-for.d.ts +17 -0
- package/package.json +10 -7
- package/src/app/App.tsx +1 -1
- package/src/app/preview-frame.tsx +21 -4
- package/src/components/Controls/index.tsx +36 -0
- package/src/components/Preview/format-usage.test.ts +115 -0
- package/src/components/Preview/format-usage.ts +132 -0
- package/src/components/Preview/index.tsx +72 -15
- package/src/components/Sidebar/index.tsx +1 -1
- package/src/hooks/useRegistry.ts +1 -1
- package/src/lib/adapter-for.ts +173 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { JogakAdapter } from '@jogak/core';
|
|
2
|
+
/** RegistryEntryMeta.framework가 받을 수 있는 모든 값 (JogakAdapter.framework와 동일). */
|
|
3
|
+
export type FrameworkKey = 'react' | 'next' | 'web-components' | 'vue' | 'svelte';
|
|
4
|
+
/**
|
|
5
|
+
* framework 이름으로 어댑터를 가져온다. 첫 호출은 dynamic import, 이후는 캐시 반환.
|
|
6
|
+
*
|
|
7
|
+
* 동시에 같은 framework로 호출되는 경우(예: 여러 effect가 동시에 await)에도 단 한 번만
|
|
8
|
+
* import가 일어나도록 inflight Promise를 공유한다.
|
|
9
|
+
*/
|
|
10
|
+
export declare function adapterFor(framework: string): Promise<JogakAdapter>;
|
|
11
|
+
/**
|
|
12
|
+
* @internal test-only. 어댑터 캐시를 초기화한다.
|
|
13
|
+
*
|
|
14
|
+
* cache hit/miss 테스트, framework lookup 격리 테스트에 사용. 프로덕션 코드에서는
|
|
15
|
+
* 호출하지 말 것 — dynamic import의 비용이 발생한다.
|
|
16
|
+
*/
|
|
17
|
+
export declare function __resetAdapterCacheForTesting(): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jogak/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.0.0-beta.0",
|
|
4
4
|
"description": "Showcase viewer UI for Jogak — Sidebar / Preview / Controls / Actions and the JogakApp shell.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jogak",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"src/app",
|
|
47
47
|
"src/components",
|
|
48
48
|
"src/hooks",
|
|
49
|
+
"src/lib/adapter-for.ts",
|
|
49
50
|
"src/styles",
|
|
50
51
|
"src/index.ts",
|
|
51
52
|
"src/vite-env.d.ts",
|
|
@@ -61,23 +62,24 @@
|
|
|
61
62
|
"registry": "https://registry.npmjs.org/"
|
|
62
63
|
},
|
|
63
64
|
"dependencies": {
|
|
65
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
64
66
|
"clsx": "^2.1.1",
|
|
65
67
|
"prism-react-renderer": "^2.4.1",
|
|
66
68
|
"tailwindcss": "^4.0.0",
|
|
67
|
-
"@
|
|
68
|
-
"@jogak/core": "0.1.0-alpha.9.1",
|
|
69
|
-
"@jogak/react": "0.1.0-alpha.9.1"
|
|
69
|
+
"@jogak/core": "1.0.0-beta.0"
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
72
|
"@types/node": "^20.14.0",
|
|
73
73
|
"@types/react": "^19.0.0",
|
|
74
74
|
"@types/react-dom": "^19.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.3.0",
|
|
76
|
+
"happy-dom": "^20.9.0",
|
|
76
77
|
"react": "^19.0.0",
|
|
77
78
|
"react-dom": "^19.0.0",
|
|
78
79
|
"typescript": "^5.5.0",
|
|
79
|
-
"vite": "^6.
|
|
80
|
-
"vite-plugin-dts": "^4.5.4"
|
|
80
|
+
"vite": "^6.4.3",
|
|
81
|
+
"vite-plugin-dts": "^4.5.4",
|
|
82
|
+
"vitest": "^2.0.0"
|
|
81
83
|
},
|
|
82
84
|
"peerDependencies": {
|
|
83
85
|
"@vitejs/plugin-react": "^4.3.0",
|
|
@@ -97,6 +99,7 @@
|
|
|
97
99
|
"dev": "vite",
|
|
98
100
|
"build": "vite build",
|
|
99
101
|
"preview": "vite preview",
|
|
100
|
-
"typecheck": "tsc --noEmit"
|
|
102
|
+
"typecheck": "tsc --noEmit",
|
|
103
|
+
"test": "vitest run"
|
|
101
104
|
}
|
|
102
105
|
}
|
package/src/app/App.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useMemo } from 'react'
|
|
2
2
|
import { ComponentRegistry, defaultRegistry } from '@jogak/core'
|
|
3
3
|
import type { RegistryEntry, RegistryEntryMeta } from '@jogak/core'
|
|
4
|
-
import { JogakProvider } from '@jogak/react'
|
|
4
|
+
import { JogakProvider } from '@jogak/core/renderers/react'
|
|
5
5
|
import { Sidebar } from '../components/Sidebar/index.js'
|
|
6
6
|
import { Preview } from '../components/Preview/index.js'
|
|
7
7
|
import type { ReactElement } from 'react'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 알파.9: standalone-adapter 또는 fallback 시 사용되는 same-origin iframe entry.
|
|
3
|
+
* 알파.14.1: entry.meta.framework 기반 adapter dispatch (react/vue/svelte/wc 지원).
|
|
3
4
|
*
|
|
4
5
|
* `IframeMount`는 알파.9에서 postMessage 프로토콜로 통일됐다 (cross-origin 어댑터와 동일).
|
|
5
6
|
* preview-frame.tsx도 같은 프로토콜을 따라야 한다 — 부모는 `jogak:setProps` 메시지를,
|
|
@@ -8,8 +9,9 @@
|
|
|
8
9
|
* jogak host vite scope에서 동작하므로 `virtual:jogak` (registry metas + entry loader)와
|
|
9
10
|
* `virtual:jogak/global-css` (사용자 globalCss) 가상 모듈을 그대로 사용한다.
|
|
10
11
|
*/
|
|
11
|
-
import {
|
|
12
|
+
import type { JogakAdapter } from '@jogak/core'
|
|
12
13
|
import { defaultRegistry } from '@jogak/core'
|
|
14
|
+
import { adapterFor } from '../lib/adapter-for.js'
|
|
13
15
|
import 'virtual:jogak'
|
|
14
16
|
import 'virtual:jogak/global-css'
|
|
15
17
|
|
|
@@ -17,23 +19,38 @@ const rootEl = document.getElementById('jogak-preview-root')
|
|
|
17
19
|
if (rootEl === null) throw new Error('#jogak-preview-root not found')
|
|
18
20
|
|
|
19
21
|
let currentContainer: HTMLDivElement | null = null
|
|
22
|
+
let currentAdapter: JogakAdapter | null = null
|
|
20
23
|
|
|
21
24
|
async function renderEntry(
|
|
22
25
|
entryId: string,
|
|
23
26
|
args: Readonly<Record<string, unknown>>,
|
|
24
27
|
): Promise<void> {
|
|
25
28
|
const entry = await defaultRegistry.requestEntry(entryId)
|
|
29
|
+
const framework = entry.meta.framework ?? 'react'
|
|
30
|
+
const nextAdapter = await adapterFor(framework)
|
|
31
|
+
|
|
26
32
|
if (currentContainer === null) {
|
|
27
33
|
currentContainer = document.createElement('div')
|
|
28
34
|
rootEl?.replaceChildren(currentContainer)
|
|
29
35
|
}
|
|
30
|
-
|
|
36
|
+
// entry framework가 바뀐 경우 이전 어댑터로 unmount 후 새 어댑터로 mount.
|
|
37
|
+
if (currentAdapter !== null && currentAdapter !== nextAdapter) {
|
|
38
|
+
currentAdapter.unmount(currentContainer)
|
|
39
|
+
// container 자체는 재사용 가능하지만 이전 어댑터가 남긴 root/component 상태가
|
|
40
|
+
// 섞이지 않도록 새 div로 교체.
|
|
41
|
+
const fresh = document.createElement('div')
|
|
42
|
+
rootEl?.replaceChildren(fresh)
|
|
43
|
+
currentContainer = fresh
|
|
44
|
+
}
|
|
45
|
+
currentAdapter = nextAdapter
|
|
46
|
+
await nextAdapter.render(entry, args, currentContainer)
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
function unmount(): void {
|
|
34
|
-
if (currentContainer !== null) {
|
|
35
|
-
|
|
50
|
+
if (currentContainer !== null && currentAdapter !== null) {
|
|
51
|
+
currentAdapter.unmount(currentContainer)
|
|
36
52
|
currentContainer = null
|
|
53
|
+
currentAdapter = null
|
|
37
54
|
}
|
|
38
55
|
}
|
|
39
56
|
|
|
@@ -137,6 +137,12 @@ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactE
|
|
|
137
137
|
const keys = Array.from(new Set([...Object.keys(args), ...Object.keys(argTypes)]))
|
|
138
138
|
const entries = keys.map((k) => [k, args[k]] as const)
|
|
139
139
|
|
|
140
|
+
// 알파.12: defaultValue를 가진 prop이 하나라도 있으면 Default 컬럼 노출.
|
|
141
|
+
// 모두 비어 있으면 컬럼 자체를 숨겨 테이블 가독성을 유지.
|
|
142
|
+
const hasAnyDefault = entries.some(
|
|
143
|
+
([key]) => argTypes[key]?.defaultValue !== undefined,
|
|
144
|
+
)
|
|
145
|
+
|
|
140
146
|
return (
|
|
141
147
|
<div className="jogak:border-t-2 jogak:border-[var(--jogak-color-border)]">
|
|
142
148
|
<div className="jogak:px-5 jogak:py-1.5 jogak:text-[11px] jogak:font-bold jogak:text-[var(--jogak-color-fg-subtle)] jogak:uppercase jogak:tracking-[0.08em] jogak:border-b jogak:border-[var(--jogak-color-border)] jogak:bg-[var(--jogak-color-bg-subtle)]">
|
|
@@ -152,6 +158,7 @@ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactE
|
|
|
152
158
|
<tr>
|
|
153
159
|
<th className={thClass}>Name</th>
|
|
154
160
|
<th className={thClass}>Control</th>
|
|
161
|
+
{hasAnyDefault && <th className={thClass}>Default</th>}
|
|
155
162
|
<th className={thClass}>Description</th>
|
|
156
163
|
</tr>
|
|
157
164
|
</thead>
|
|
@@ -176,6 +183,18 @@ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactE
|
|
|
176
183
|
onArgChange={onArgChange}
|
|
177
184
|
/>
|
|
178
185
|
</td>
|
|
186
|
+
{hasAnyDefault && (
|
|
187
|
+
<td
|
|
188
|
+
className={clsx(
|
|
189
|
+
tdClass,
|
|
190
|
+
'jogak:font-[family-name:var(--jogak-font-mono)] jogak:text-[12px] jogak:text-[var(--jogak-color-fg-muted)] jogak:whitespace-nowrap',
|
|
191
|
+
)}
|
|
192
|
+
>
|
|
193
|
+
{argType?.defaultValue !== undefined
|
|
194
|
+
? formatDefaultValue(argType.defaultValue)
|
|
195
|
+
: ''}
|
|
196
|
+
</td>
|
|
197
|
+
)}
|
|
179
198
|
<td className={clsx(tdClass, 'jogak:text-[var(--jogak-color-fg-subtle)]')}>
|
|
180
199
|
{argType?.description ?? ''}
|
|
181
200
|
</td>
|
|
@@ -188,3 +207,20 @@ export function Controls({ args, argTypes, onArgChange }: ControlsProps): ReactE
|
|
|
188
207
|
</div>
|
|
189
208
|
)
|
|
190
209
|
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 알파.12: defaultValue를 Controls 패널에 표시할 때 사용. JSON-safe 값은 작은
|
|
213
|
+
* 인용부호로 string, 그 외 literal은 그대로 직렬화. 의도: 사용자가 코드에 쓸 수
|
|
214
|
+
* 있는 형태로 보여주기.
|
|
215
|
+
*/
|
|
216
|
+
function formatDefaultValue(v: unknown): string {
|
|
217
|
+
if (typeof v === 'string') return `'${v}'`
|
|
218
|
+
if (typeof v === 'number' || typeof v === 'boolean' || v === null) {
|
|
219
|
+
return String(v)
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
return JSON.stringify(v)
|
|
223
|
+
} catch {
|
|
224
|
+
return String(v)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { RegistryEntry } from '@jogak/core'
|
|
3
|
+
import { formatUsageCode } from './format-usage.js'
|
|
4
|
+
|
|
5
|
+
function makeEntry(componentName: string, title = 'UI/Demo'): RegistryEntry {
|
|
6
|
+
function Demo(): null {
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(Demo, 'name', { value: componentName })
|
|
10
|
+
return {
|
|
11
|
+
id: title,
|
|
12
|
+
title,
|
|
13
|
+
jogaks: [],
|
|
14
|
+
meta: { title, component: Demo, argTypes: {} },
|
|
15
|
+
source: '',
|
|
16
|
+
filePath: '',
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('formatUsageCode', () => {
|
|
21
|
+
it('children 문자열 → 단일 라인', () => {
|
|
22
|
+
const entry = makeEntry('Badge')
|
|
23
|
+
expect(formatUsageCode(entry, { children: 'New', variant: 'default' })).toBe(
|
|
24
|
+
'<Badge variant="default">New</Badge>',
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('children 없음 → self-closing', () => {
|
|
29
|
+
const entry = makeEntry('Card')
|
|
30
|
+
expect(formatUsageCode(entry, { title: 'Hello' })).toBe(
|
|
31
|
+
'<Card title="Hello" />',
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('boolean true → key only, false → key={false}', () => {
|
|
36
|
+
const entry = makeEntry('Toggle')
|
|
37
|
+
expect(formatUsageCode(entry, { disabled: true, checked: false })).toBe(
|
|
38
|
+
'<Toggle disabled checked={false} />',
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('number → 중괄호 표현', () => {
|
|
43
|
+
const entry = makeEntry('Counter')
|
|
44
|
+
expect(formatUsageCode(entry, { count: 42 })).toBe('<Counter count={42} />')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('function → key={fn}', () => {
|
|
48
|
+
const entry = makeEntry('Button')
|
|
49
|
+
expect(
|
|
50
|
+
formatUsageCode(entry, { onClick: () => undefined, label: 'Go' }),
|
|
51
|
+
).toBe('<Button onClick={fn} label="Go" />')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('객체/배열 → JSON 표현', () => {
|
|
55
|
+
const entry = makeEntry('List')
|
|
56
|
+
expect(formatUsageCode(entry, { items: [1, 2, 3] })).toBe(
|
|
57
|
+
'<List items={[1,2,3]} />',
|
|
58
|
+
)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('children에 number → JSX 표현', () => {
|
|
62
|
+
const entry = makeEntry('Counter')
|
|
63
|
+
expect(formatUsageCode(entry, { children: 7 })).toBe(
|
|
64
|
+
'<Counter>{7}</Counter>',
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('component name 없으면 title의 마지막 segment 사용', () => {
|
|
69
|
+
const entry: RegistryEntry = {
|
|
70
|
+
id: 'UI/Anonymous',
|
|
71
|
+
title: 'UI/Anonymous',
|
|
72
|
+
jogaks: [],
|
|
73
|
+
meta: {
|
|
74
|
+
title: 'UI/Anonymous',
|
|
75
|
+
component: undefined,
|
|
76
|
+
argTypes: {},
|
|
77
|
+
},
|
|
78
|
+
source: '',
|
|
79
|
+
filePath: '',
|
|
80
|
+
}
|
|
81
|
+
expect(formatUsageCode(entry, { children: 'X' })).toBe(
|
|
82
|
+
'<Anonymous>X</Anonymous>',
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('많은 props → multi-line 포맷', () => {
|
|
87
|
+
const entry = makeEntry('Form')
|
|
88
|
+
const out = formatUsageCode(entry, {
|
|
89
|
+
title: 'Long descriptive title',
|
|
90
|
+
description: 'Another lengthy text value',
|
|
91
|
+
disabled: false,
|
|
92
|
+
autoFocus: true,
|
|
93
|
+
})
|
|
94
|
+
expect(out).toContain('\n')
|
|
95
|
+
expect(out.startsWith('<Form\n ')).toBe(true)
|
|
96
|
+
expect(out.endsWith('/>')).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('따옴표 escape', () => {
|
|
100
|
+
const entry = makeEntry('Label')
|
|
101
|
+
expect(formatUsageCode(entry, { text: 'He said "hi"' })).toBe(
|
|
102
|
+
'<Label text="He said "hi"" />',
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('children + props 모두 multi-line으로 ', () => {
|
|
107
|
+
const entry = makeEntry('Card')
|
|
108
|
+
const out = formatUsageCode(entry, {
|
|
109
|
+
title: 'A long title',
|
|
110
|
+
description: 'A long description',
|
|
111
|
+
children: 'Body content',
|
|
112
|
+
})
|
|
113
|
+
expect(out).toMatch(/^<Card\n {2}title="A long title"\n {2}description="A long description"\n>\n {2}Body content\n<\/Card>$/u)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 알파.10.3: 컴포넌트 사용 코드 포매터.
|
|
3
|
+
*
|
|
4
|
+
* 코드 패널에는 `.jogak.tsx` 파일 전체가 아니라, 현재 args 기반의 사용 스니펫을 노출한다.
|
|
5
|
+
* 사용자가 Controls 패널에서 args를 바꾸면 즉시 갱신된다.
|
|
6
|
+
*
|
|
7
|
+
* 출력 예:
|
|
8
|
+
* <Badge variant="default">New</Badge>
|
|
9
|
+
*
|
|
10
|
+
* <Card
|
|
11
|
+
* title="Hello"
|
|
12
|
+
* disabled
|
|
13
|
+
* onClick={fn}
|
|
14
|
+
* />
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { RegistryEntry } from '@jogak/core'
|
|
18
|
+
|
|
19
|
+
const SINGLE_LINE_THRESHOLD = 60
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `entry` + 현재 `args`로부터 JSX 사용 코드를 생성한다.
|
|
23
|
+
* children은 태그 본문에, 나머지 props는 attribute로.
|
|
24
|
+
*/
|
|
25
|
+
export function formatUsageCode(
|
|
26
|
+
entry: RegistryEntry,
|
|
27
|
+
args: Readonly<Record<string, unknown>>,
|
|
28
|
+
): string {
|
|
29
|
+
const componentName = resolveComponentName(entry)
|
|
30
|
+
const { children, restProps } = splitChildren(args)
|
|
31
|
+
|
|
32
|
+
const propTokens = Object.entries(restProps)
|
|
33
|
+
.filter(([, v]) => v !== undefined)
|
|
34
|
+
.map(([k, v]) => formatProp(k, v))
|
|
35
|
+
|
|
36
|
+
const childrenStr = formatChildren(children)
|
|
37
|
+
const hasChildren = childrenStr !== null
|
|
38
|
+
|
|
39
|
+
// single-line 시도
|
|
40
|
+
const inlineProps = propTokens.length === 0 ? '' : ' ' + propTokens.join(' ')
|
|
41
|
+
const singleLine = hasChildren
|
|
42
|
+
? `<${componentName}${inlineProps}>${childrenStr ?? ''}</${componentName}>`
|
|
43
|
+
: `<${componentName}${inlineProps} />`
|
|
44
|
+
|
|
45
|
+
if (singleLine.length <= SINGLE_LINE_THRESHOLD && !singleLine.includes('\n')) {
|
|
46
|
+
return singleLine
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// multi-line — 각 prop을 별도 줄에
|
|
50
|
+
const indentedProps =
|
|
51
|
+
propTokens.length === 0 ? '' : '\n ' + propTokens.join('\n ') + '\n'
|
|
52
|
+
if (hasChildren) {
|
|
53
|
+
const indentedChildren = (childrenStr ?? '')
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((line) => ` ${line}`)
|
|
56
|
+
.join('\n')
|
|
57
|
+
return `<${componentName}${indentedProps}>\n${indentedChildren}\n</${componentName}>`
|
|
58
|
+
}
|
|
59
|
+
return `<${componentName}${indentedProps}/>`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function resolveComponentName(entry: RegistryEntry): string {
|
|
63
|
+
// 알파.14.1: iframe isolation 모드는 chrome scope에 component를 import하지 않으므로
|
|
64
|
+
// entry.meta.component가 `null`. fallback (title 마지막 segment)으로 직행한다.
|
|
65
|
+
const component = entry.meta.component as
|
|
66
|
+
| { displayName?: unknown; name?: unknown }
|
|
67
|
+
| null
|
|
68
|
+
| undefined
|
|
69
|
+
if (component !== null && component !== undefined) {
|
|
70
|
+
if (typeof component.displayName === 'string' && component.displayName.length > 0) {
|
|
71
|
+
return component.displayName
|
|
72
|
+
}
|
|
73
|
+
if (typeof component.name === 'string' && component.name.length > 0) {
|
|
74
|
+
return component.name
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// fallback: title의 마지막 segment ("UI/Badge" → "Badge")
|
|
78
|
+
const lastSeg = entry.title.split('/').pop()
|
|
79
|
+
return lastSeg !== undefined && lastSeg.length > 0 ? lastSeg : 'Component'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface SplitChildrenResult {
|
|
83
|
+
readonly children: unknown
|
|
84
|
+
readonly restProps: Readonly<Record<string, unknown>>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function splitChildren(args: Readonly<Record<string, unknown>>): SplitChildrenResult {
|
|
88
|
+
const { children, ...rest } = args as { children?: unknown } & Record<string, unknown>
|
|
89
|
+
return { children, restProps: rest }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatChildren(children: unknown): string | null {
|
|
93
|
+
if (children === undefined || children === null) return null
|
|
94
|
+
if (typeof children === 'string') {
|
|
95
|
+
if (children.length === 0) return null
|
|
96
|
+
return children
|
|
97
|
+
}
|
|
98
|
+
if (typeof children === 'number' || typeof children === 'bigint') {
|
|
99
|
+
return `{${children.toString()}}`
|
|
100
|
+
}
|
|
101
|
+
if (typeof children === 'boolean') {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
// 복합 타입(object/array/function): JSON 표현
|
|
105
|
+
return `{${stringifyValue(children)}}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatProp(key: string, value: unknown): string {
|
|
109
|
+
if (value === true) return key
|
|
110
|
+
if (value === false) return `${key}={false}`
|
|
111
|
+
if (value === null) return `${key}={null}`
|
|
112
|
+
if (typeof value === 'string') {
|
|
113
|
+
// 따옴표 escape
|
|
114
|
+
const escaped = value.replace(/"/gu, '"')
|
|
115
|
+
return `${key}="${escaped}"`
|
|
116
|
+
}
|
|
117
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
118
|
+
return `${key}={${value.toString()}}`
|
|
119
|
+
}
|
|
120
|
+
if (typeof value === 'function') {
|
|
121
|
+
return `${key}={fn}`
|
|
122
|
+
}
|
|
123
|
+
return `${key}={${stringifyValue(value)}}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function stringifyValue(value: unknown): string {
|
|
127
|
+
try {
|
|
128
|
+
return JSON.stringify(value)
|
|
129
|
+
} catch {
|
|
130
|
+
return String(value)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -3,13 +3,15 @@ import type { ReactElement, CSSProperties } from 'react'
|
|
|
3
3
|
import clsx from 'clsx'
|
|
4
4
|
import { Highlight, themes } from 'prism-react-renderer'
|
|
5
5
|
import type { PrismTheme } from 'prism-react-renderer'
|
|
6
|
-
import {
|
|
7
|
-
import type { UseEntryState } from '@jogak/react'
|
|
8
|
-
import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
|
|
6
|
+
import { useEntry } from '@jogak/core/renderers/react'
|
|
7
|
+
import type { UseEntryState } from '@jogak/core/renderers/react'
|
|
8
|
+
import type { JogakAdapter, RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
|
|
9
|
+
import { adapterFor } from '../../lib/adapter-for.js'
|
|
9
10
|
import { Controls } from '../Controls/index.js'
|
|
10
11
|
import { Actions } from '../Actions/index.js'
|
|
11
12
|
import { ShadowMount } from './ShadowMount.js'
|
|
12
13
|
import { IframeMount } from './IframeMount.js'
|
|
14
|
+
import { formatUsageCode } from './format-usage.js'
|
|
13
15
|
|
|
14
16
|
export interface PreviewProps {
|
|
15
17
|
readonly entryId: string
|
|
@@ -133,7 +135,11 @@ export function Preview({
|
|
|
133
135
|
userPreviewUrl = '',
|
|
134
136
|
previewEntryPath = '/__jogak_preview__/index.html',
|
|
135
137
|
}: PreviewProps): ReactElement {
|
|
136
|
-
|
|
138
|
+
// 알파.14.1: iframe isolation 모드에서는 chrome 측에 component 모듈을 import하지 않는다
|
|
139
|
+
// (chrome vite scope에 .vue/.svelte가 들어오면 plugin-vue/svelte 부재로 transform 실패).
|
|
140
|
+
// skipHydrate=true → useEntry가 synthetic entry(component=null)로 ready를 노출하고,
|
|
141
|
+
// 실제 마운트는 IframeMount가 사용자 vite scope의 iframe entry에 위임한다.
|
|
142
|
+
const state = useEntry(entryId, { skipHydrate: previewIsolation === 'iframe' })
|
|
137
143
|
const [viewport, setViewport] = useState<ViewportKey>('desktop')
|
|
138
144
|
const [bgMode, setBgMode] = useState<BgMode>('white')
|
|
139
145
|
const [bottomTab, setBottomTab] = useState<'controls' | 'actions'>('controls')
|
|
@@ -378,7 +384,6 @@ function ReadyFrame({
|
|
|
378
384
|
key={`${entry.id}/${jogak.name}`}
|
|
379
385
|
entry={entry}
|
|
380
386
|
args={mergedArgs}
|
|
381
|
-
source={entry.source}
|
|
382
387
|
theme={prismTheme}
|
|
383
388
|
previewIsolation={previewIsolation}
|
|
384
389
|
userPreviewUrl={userPreviewUrl}
|
|
@@ -530,7 +535,6 @@ function Toolbar({
|
|
|
530
535
|
interface JogakRendererProps {
|
|
531
536
|
readonly entry: RegistryEntry
|
|
532
537
|
readonly args: Readonly<Record<string, unknown>>
|
|
533
|
-
readonly source: string | undefined
|
|
534
538
|
readonly theme: PrismTheme
|
|
535
539
|
readonly previewIsolation: 'none' | 'shadow' | 'iframe'
|
|
536
540
|
readonly userPreviewUrl: string
|
|
@@ -544,8 +548,10 @@ interface JogakRendererProps {
|
|
|
544
548
|
* - `'shadow'` (deprecated) — `<ShadowMount>` 안에 마운트.
|
|
545
549
|
* - `'none'` (deprecated) — 같은 document에 직접 마운트.
|
|
546
550
|
*/
|
|
547
|
-
function JogakRenderer({ entry, args,
|
|
551
|
+
function JogakRenderer({ entry, args, theme, previewIsolation, userPreviewUrl, previewEntryPath }: JogakRendererProps): ReactElement {
|
|
548
552
|
const [showCode, setShowCode] = useState(false)
|
|
553
|
+
// 알파.10.3: 코드 패널은 jogak 메타 파일이 아니라 현재 args 기반 사용 코드를 노출.
|
|
554
|
+
const usageCode = formatUsageCode(entry, args)
|
|
549
555
|
|
|
550
556
|
const previewBody = (
|
|
551
557
|
<div className="jogak:relative">
|
|
@@ -580,7 +586,7 @@ function JogakRenderer({ entry, args, source, theme, previewIsolation, userPrevi
|
|
|
580
586
|
{/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
|
|
581
587
|
{showCode && (
|
|
582
588
|
<div className="jogak:mt-2 jogak:rounded-[var(--jogak-radius-xl)] jogak:overflow-hidden jogak:h-[320px] jogak:shadow-[0_0_0_1px_rgba(0,0,0,0.08),_0_4px_16px_rgba(0,0,0,0.12)]">
|
|
583
|
-
<SourceViewer source={
|
|
589
|
+
<SourceViewer source={usageCode} theme={theme} />
|
|
584
590
|
</div>
|
|
585
591
|
)}
|
|
586
592
|
</div>
|
|
@@ -635,23 +641,50 @@ function PreviewMount({ entry, args, previewIsolation, userPreviewUrl, previewEn
|
|
|
635
641
|
|
|
636
642
|
function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
|
|
637
643
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
644
|
+
const adapterRef = useRef<JogakAdapter | null>(null)
|
|
638
645
|
|
|
646
|
+
// 알파.14.1: entry.meta.framework로 dispatch. async 적응을 위해 effect 내에서
|
|
647
|
+
// await adapterFor → 캡처된 adapter로 render. unmount는 같은 adapter ref 사용.
|
|
639
648
|
useEffect(() => {
|
|
640
649
|
const container = containerRef.current
|
|
641
650
|
if (container === null) return
|
|
642
|
-
|
|
651
|
+
let cancelled = false
|
|
652
|
+
|
|
653
|
+
const framework = entry.meta.framework ?? 'react'
|
|
654
|
+
void adapterFor(framework).then((adapter) => {
|
|
655
|
+
if (cancelled) return
|
|
656
|
+
adapterRef.current = adapter
|
|
657
|
+
void adapter.render(entry, args, container)
|
|
658
|
+
})
|
|
659
|
+
|
|
643
660
|
return () => {
|
|
661
|
+
cancelled = true
|
|
662
|
+
const adapter = adapterRef.current
|
|
663
|
+
if (adapter === null) return
|
|
644
664
|
// 알파.7.1: React 18 concurrent unmount race(`Attempted to synchronously unmount...`)
|
|
645
665
|
// 회피 — fiber commit 끝난 직후로 defer.
|
|
646
|
-
queueMicrotask(() => {
|
|
666
|
+
queueMicrotask(() => { adapter.unmount(container) })
|
|
647
667
|
}
|
|
648
668
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
649
669
|
}, [entry])
|
|
650
670
|
|
|
671
|
+
// args 갱신용 effect — adapter가 이미 캐시돼 있으면 동기 분기를 탄다.
|
|
651
672
|
useEffect(() => {
|
|
652
673
|
const container = containerRef.current
|
|
653
674
|
if (container === null) return
|
|
654
|
-
|
|
675
|
+
let cancelled = false
|
|
676
|
+
const adapter = adapterRef.current
|
|
677
|
+
if (adapter !== null) {
|
|
678
|
+
void adapter.render(entry, args, container)
|
|
679
|
+
return
|
|
680
|
+
}
|
|
681
|
+
const framework = entry.meta.framework ?? 'react'
|
|
682
|
+
void adapterFor(framework).then((resolved) => {
|
|
683
|
+
if (cancelled) return
|
|
684
|
+
adapterRef.current = resolved
|
|
685
|
+
void resolved.render(entry, args, container)
|
|
686
|
+
})
|
|
687
|
+
return () => { cancelled = true }
|
|
655
688
|
}, [entry, args])
|
|
656
689
|
|
|
657
690
|
return (
|
|
@@ -664,19 +697,31 @@ function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Reado
|
|
|
664
697
|
}
|
|
665
698
|
|
|
666
699
|
/**
|
|
667
|
-
* Shadow 모드 — ShadowMount의 ShadowRoot 안에서
|
|
700
|
+
* Shadow 모드 — ShadowMount의 ShadowRoot 안에서 adapter.render를 호출하는
|
|
668
701
|
* 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
|
|
669
702
|
*/
|
|
670
703
|
function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
|
|
671
704
|
const ref = useRef<HTMLDivElement>(null)
|
|
705
|
+
const adapterRef = useRef<JogakAdapter | null>(null)
|
|
672
706
|
|
|
673
707
|
useEffect(() => {
|
|
674
708
|
const c = ref.current
|
|
675
709
|
if (c === null) return
|
|
676
|
-
|
|
710
|
+
let cancelled = false
|
|
711
|
+
|
|
712
|
+
const framework = entry.meta.framework ?? 'react'
|
|
713
|
+
void adapterFor(framework).then((adapter) => {
|
|
714
|
+
if (cancelled) return
|
|
715
|
+
adapterRef.current = adapter
|
|
716
|
+
void adapter.render(entry, args, c)
|
|
717
|
+
})
|
|
718
|
+
|
|
677
719
|
return () => {
|
|
720
|
+
cancelled = true
|
|
721
|
+
const adapter = adapterRef.current
|
|
722
|
+
if (adapter === null) return
|
|
678
723
|
// 알파.7.1: unmount race 회피
|
|
679
|
-
queueMicrotask(() => {
|
|
724
|
+
queueMicrotask(() => { adapter.unmount(c) })
|
|
680
725
|
}
|
|
681
726
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
682
727
|
}, [entry])
|
|
@@ -684,7 +729,19 @@ function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Rea
|
|
|
684
729
|
useEffect(() => {
|
|
685
730
|
const c = ref.current
|
|
686
731
|
if (c === null) return
|
|
687
|
-
|
|
732
|
+
let cancelled = false
|
|
733
|
+
const adapter = adapterRef.current
|
|
734
|
+
if (adapter !== null) {
|
|
735
|
+
void adapter.render(entry, args, c)
|
|
736
|
+
return
|
|
737
|
+
}
|
|
738
|
+
const framework = entry.meta.framework ?? 'react'
|
|
739
|
+
void adapterFor(framework).then((resolved) => {
|
|
740
|
+
if (cancelled) return
|
|
741
|
+
adapterRef.current = resolved
|
|
742
|
+
void resolved.render(entry, args, c)
|
|
743
|
+
})
|
|
744
|
+
return () => { cancelled = true }
|
|
688
745
|
}, [entry, args])
|
|
689
746
|
|
|
690
747
|
return <div ref={ref} data-testid="preview-content-shadow" />
|
|
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
|
|
2
2
|
import type { CSSProperties, ReactElement } from 'react'
|
|
3
3
|
import clsx from 'clsx'
|
|
4
4
|
import type { CategoryMetaTree, RegistryEntryMeta } from '@jogak/core'
|
|
5
|
-
import { useRegistryMeta } from '@jogak/react'
|
|
5
|
+
import { useRegistryMeta } from '@jogak/core/renderers/react'
|
|
6
6
|
|
|
7
7
|
// CSS custom property를 React style prop에 주입하기 위한 헬퍼 타입.
|
|
8
8
|
// React 18+는 string-keyed `--` prefix를 인식하나 TS는 명시적 cast 필요.
|
package/src/hooks/useRegistry.ts
CHANGED