@jogak/ui 0.1.0-alpha.1 → 0.1.0-alpha.10.3
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 +141 -0
- package/README.md +201 -15
- package/dist/app/App.d.ts +24 -1
- package/dist/components/Preview/IframeMount.d.ts +35 -0
- package/dist/components/Preview/ShadowMount.d.ts +25 -0
- package/dist/components/Preview/format-usage.d.ts +6 -0
- package/dist/components/Preview/index.d.ts +19 -2
- package/dist/host/index.cjs +1 -0
- package/dist/host/index.d.ts +38 -1
- package/dist/host/index.mjs +40 -34
- package/dist/index.cjs +9 -0
- package/dist/index.mjs +798 -919
- package/package.json +20 -8
- package/preview-frame.html +17 -0
- package/src/app/App.tsx +189 -0
- package/src/app/main.tsx +31 -0
- package/src/app/preview-frame.tsx +61 -0
- package/src/components/Actions/index.tsx +92 -0
- package/src/components/Controls/index.tsx +190 -0
- package/src/components/Preview/IframeMount.tsx +101 -0
- package/src/components/Preview/ShadowMount.tsx +57 -0
- package/src/components/Preview/format-usage.test.ts +115 -0
- package/src/components/Preview/format-usage.ts +129 -0
- package/src/components/Preview/index.tsx +767 -0
- package/src/components/Sidebar/index.tsx +285 -0
- package/src/hooks/useRegistry.ts +22 -0
- package/src/index.ts +12 -0
- package/src/styles/jogak.css +128 -0
- package/src/vite-env.d.ts +30 -0
- package/dist/host/index.js +0 -1
- package/dist/index.js +0 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import type { ReactElement } from 'react'
|
|
3
|
+
import type { RegistryEntry } from '@jogak/core'
|
|
4
|
+
|
|
5
|
+
export interface IframeMountProps {
|
|
6
|
+
readonly entry: RegistryEntry
|
|
7
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
8
|
+
/**
|
|
9
|
+
* 알파.9: 어댑터 dev URL (예: `http://localhost:5174`).
|
|
10
|
+
* 빈 문자열 시 fallback (jogak SPA Vite scope의 `/preview-frame.html`).
|
|
11
|
+
*/
|
|
12
|
+
readonly userPreviewUrl: string
|
|
13
|
+
/**
|
|
14
|
+
* 알파.9: iframe entry path (예: `/__jogak_preview__/index.html`).
|
|
15
|
+
* 어댑터의 `previewEntryMeta.devEntryPath`.
|
|
16
|
+
*/
|
|
17
|
+
readonly previewEntryPath: string
|
|
18
|
+
readonly className?: string
|
|
19
|
+
readonly 'data-testid'?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 알파.8: previewIsolation='iframe' 모드의 mount 컴포넌트.
|
|
24
|
+
*
|
|
25
|
+
* 통신:
|
|
26
|
+
* - 사용자 vite spawn URL이 주어지면(`userViteUrl !== ''`) iframe src를
|
|
27
|
+
* `${userViteUrl}/__jogak_preview__/index.html` (cross-origin)로 설정.
|
|
28
|
+
* - 동일 origin fallback 시 `/preview-frame.html` (jogak SPA Vite scope).
|
|
29
|
+
*
|
|
30
|
+
* 양쪽 모두 postMessage로 통신:
|
|
31
|
+
* - 부모 → iframe: `{ type: 'jogak:setProps', entryId, args }` | `{ type: 'jogak:unmount' }`
|
|
32
|
+
* - iframe → 부모: `{ type: 'jogak:ready' }` | `{ type: 'jogak:rendered', entryId }`
|
|
33
|
+
*
|
|
34
|
+
* `entry`는 객체가 아닌 **id만 전달** — iframe 안에서 `defaultRegistry.requestEntry(id)`로
|
|
35
|
+
* dynamic import. 사용자 vite scope의 entry 가상 모듈이 사용자 컴포넌트를 fetch하므로
|
|
36
|
+
* 사용자 plugins(@tailwindcss/vite, custom alias 등)이 정상 작동.
|
|
37
|
+
*/
|
|
38
|
+
export function IframeMount({
|
|
39
|
+
entry,
|
|
40
|
+
args,
|
|
41
|
+
userPreviewUrl,
|
|
42
|
+
previewEntryPath,
|
|
43
|
+
className,
|
|
44
|
+
'data-testid': dataTestId,
|
|
45
|
+
}: IframeMountProps): ReactElement {
|
|
46
|
+
const iframeRef = useRef<HTMLIFrameElement | null>(null)
|
|
47
|
+
const [ready, setReady] = useState(false)
|
|
48
|
+
|
|
49
|
+
const src =
|
|
50
|
+
userPreviewUrl !== ''
|
|
51
|
+
? `${userPreviewUrl}${previewEntryPath}`
|
|
52
|
+
: '/preview-frame.html'
|
|
53
|
+
|
|
54
|
+
// postMessage 리스너 — iframe contentWindow 일치성 검증 후 처리.
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const handler = (event: MessageEvent): void => {
|
|
57
|
+
const iframe = iframeRef.current
|
|
58
|
+
if (iframe === null) return
|
|
59
|
+
if (event.source !== iframe.contentWindow) return
|
|
60
|
+
const data = event.data
|
|
61
|
+
if (data == null || typeof data !== 'object') return
|
|
62
|
+
if (data.type === 'jogak:ready') setReady(true)
|
|
63
|
+
}
|
|
64
|
+
window.addEventListener('message', handler)
|
|
65
|
+
return () => {
|
|
66
|
+
window.removeEventListener('message', handler)
|
|
67
|
+
}
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
// iframe ready 또는 entry/args 변경 시 setProps.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!ready) return
|
|
73
|
+
const iframe = iframeRef.current
|
|
74
|
+
if (iframe === null) return
|
|
75
|
+
iframe.contentWindow?.postMessage(
|
|
76
|
+
{ type: 'jogak:setProps', entryId: entry.id, args },
|
|
77
|
+
'*',
|
|
78
|
+
)
|
|
79
|
+
}, [ready, entry, args])
|
|
80
|
+
|
|
81
|
+
// unmount 시 unmount 메시지 (race 회피 microtask defer).
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const iframe = iframeRef.current
|
|
84
|
+
return () => {
|
|
85
|
+
if (iframe === null) return
|
|
86
|
+
queueMicrotask(() => {
|
|
87
|
+
iframe.contentWindow?.postMessage({ type: 'jogak:unmount' }, '*')
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<iframe
|
|
94
|
+
ref={iframeRef}
|
|
95
|
+
src={src}
|
|
96
|
+
title="Preview"
|
|
97
|
+
className={className}
|
|
98
|
+
data-testid={dataTestId}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import type { ReactElement, ReactNode, CSSProperties } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface ShadowMountProps {
|
|
6
|
+
readonly children: ReactNode
|
|
7
|
+
readonly className?: string
|
|
8
|
+
readonly style?: CSSProperties
|
|
9
|
+
readonly 'data-testid'?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 알파.7.1: previewIsolation='shadow' 모드의 mount 컴포넌트.
|
|
14
|
+
*
|
|
15
|
+
* 책임: 양방향 격리만 제공 (Preview ↔ outer document 양방향 cascade 차단).
|
|
16
|
+
* - 사용자 globalCss는 main.tsx 가드로 outer document에 inject되지 않음.
|
|
17
|
+
* - shadow root 안에는 jogak chrome css도 사용자 css도 없음 (둘 다 외부에서 격리).
|
|
18
|
+
* - 사용자 컴포넌트의 utility class 컴파일은 결함 B (알파.8 사이클).
|
|
19
|
+
*
|
|
20
|
+
* 알파.7 결함 정정:
|
|
21
|
+
* - `syncStyleSheets`/`MutationObserver`/`adoptedStyleSheets` 흡수 로직 제거.
|
|
22
|
+
* 알파.7은 outer document에 사용자 css가 있는 한 chrome을 침범했고, shadow
|
|
23
|
+
* 안의 흡수 로직은 의미가 없었음. 알파.7.1: outer에 사용자 css 자체가 없음.
|
|
24
|
+
*
|
|
25
|
+
* Radix portal 한계 (사용자 인지 필요, README 명시):
|
|
26
|
+
* - default Portal target = document.body (shadow 외부). 사용자가 명시적으로
|
|
27
|
+
* `<Portal container={shadowRootEl}>`을 전달해야 portal 내용도 격리됨.
|
|
28
|
+
*/
|
|
29
|
+
export function ShadowMount({
|
|
30
|
+
children,
|
|
31
|
+
className,
|
|
32
|
+
style,
|
|
33
|
+
'data-testid': dataTestId,
|
|
34
|
+
}: ShadowMountProps): ReactElement {
|
|
35
|
+
const hostRef = useRef<HTMLDivElement | null>(null)
|
|
36
|
+
const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null)
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const host = hostRef.current
|
|
40
|
+
if (host === null) return
|
|
41
|
+
const sr = host.shadowRoot ?? host.attachShadow({ mode: 'open' })
|
|
42
|
+
setShadowRoot(sr)
|
|
43
|
+
// shadow root는 host element와 함께 GC — 명시 detach 불필요.
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
ref={hostRef}
|
|
49
|
+
className={className}
|
|
50
|
+
data-testid={dataTestId}
|
|
51
|
+
// eslint-disable-next-line no-restricted-syntax -- jogak: ShadowMount caller-supplied style passthrough (host wrapper, content goes through ShadowRoot portal)
|
|
52
|
+
style={style}
|
|
53
|
+
>
|
|
54
|
+
{shadowRoot !== null ? createPortal(children, shadowRoot) : null}
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -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,129 @@
|
|
|
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
|
+
const component = entry.meta.component as
|
|
64
|
+
| { displayName?: unknown; name?: unknown }
|
|
65
|
+
| undefined
|
|
66
|
+
if (component !== undefined) {
|
|
67
|
+
if (typeof component.displayName === 'string' && component.displayName.length > 0) {
|
|
68
|
+
return component.displayName
|
|
69
|
+
}
|
|
70
|
+
if (typeof component.name === 'string' && component.name.length > 0) {
|
|
71
|
+
return component.name
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// fallback: title의 마지막 segment ("UI/Badge" → "Badge")
|
|
75
|
+
const lastSeg = entry.title.split('/').pop()
|
|
76
|
+
return lastSeg !== undefined && lastSeg.length > 0 ? lastSeg : 'Component'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface SplitChildrenResult {
|
|
80
|
+
readonly children: unknown
|
|
81
|
+
readonly restProps: Readonly<Record<string, unknown>>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function splitChildren(args: Readonly<Record<string, unknown>>): SplitChildrenResult {
|
|
85
|
+
const { children, ...rest } = args as { children?: unknown } & Record<string, unknown>
|
|
86
|
+
return { children, restProps: rest }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatChildren(children: unknown): string | null {
|
|
90
|
+
if (children === undefined || children === null) return null
|
|
91
|
+
if (typeof children === 'string') {
|
|
92
|
+
if (children.length === 0) return null
|
|
93
|
+
return children
|
|
94
|
+
}
|
|
95
|
+
if (typeof children === 'number' || typeof children === 'bigint') {
|
|
96
|
+
return `{${children.toString()}}`
|
|
97
|
+
}
|
|
98
|
+
if (typeof children === 'boolean') {
|
|
99
|
+
return null
|
|
100
|
+
}
|
|
101
|
+
// 복합 타입(object/array/function): JSON 표현
|
|
102
|
+
return `{${stringifyValue(children)}}`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatProp(key: string, value: unknown): string {
|
|
106
|
+
if (value === true) return key
|
|
107
|
+
if (value === false) return `${key}={false}`
|
|
108
|
+
if (value === null) return `${key}={null}`
|
|
109
|
+
if (typeof value === 'string') {
|
|
110
|
+
// 따옴표 escape
|
|
111
|
+
const escaped = value.replace(/"/gu, '"')
|
|
112
|
+
return `${key}="${escaped}"`
|
|
113
|
+
}
|
|
114
|
+
if (typeof value === 'number' || typeof value === 'bigint') {
|
|
115
|
+
return `${key}={${value.toString()}}`
|
|
116
|
+
}
|
|
117
|
+
if (typeof value === 'function') {
|
|
118
|
+
return `${key}={fn}`
|
|
119
|
+
}
|
|
120
|
+
return `${key}={${stringifyValue(value)}}`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stringifyValue(value: unknown): string {
|
|
124
|
+
try {
|
|
125
|
+
return JSON.stringify(value)
|
|
126
|
+
} catch {
|
|
127
|
+
return String(value)
|
|
128
|
+
}
|
|
129
|
+
}
|