@jogak/ui 0.1.0-alpha.10.2 → 0.1.0-alpha.11
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 +29 -0
- package/dist/components/Preview/format-usage.d.ts +6 -0
- package/dist/index.cjs +9 -1
- package/dist/index.mjs +458 -404
- package/package.json +7 -4
- 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 +5 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jogak/ui",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.11",
|
|
4
4
|
"description": "Showcase viewer UI for Jogak — Sidebar / Preview / Controls / Actions and the JogakApp shell.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jogak",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"prism-react-renderer": "^2.4.1",
|
|
66
66
|
"tailwindcss": "^4.0.0",
|
|
67
67
|
"@tailwindcss/vite": "^4.0.0",
|
|
68
|
-
"@jogak/core": "0.1.0-alpha.
|
|
68
|
+
"@jogak/core": "0.1.0-alpha.11"
|
|
69
69
|
},
|
|
70
70
|
"devDependencies": {
|
|
71
71
|
"@types/node": "^20.14.0",
|
|
@@ -76,7 +76,9 @@
|
|
|
76
76
|
"react-dom": "^19.0.0",
|
|
77
77
|
"typescript": "^5.5.0",
|
|
78
78
|
"vite": "^6.0.0",
|
|
79
|
-
"vite-plugin-dts": "^4.5.4"
|
|
79
|
+
"vite-plugin-dts": "^4.5.4",
|
|
80
|
+
"vitest": "^2.0.0",
|
|
81
|
+
"happy-dom": "^20.9.0"
|
|
80
82
|
},
|
|
81
83
|
"peerDependencies": {
|
|
82
84
|
"@vitejs/plugin-react": "^4.3.0",
|
|
@@ -96,6 +98,7 @@
|
|
|
96
98
|
"dev": "vite",
|
|
97
99
|
"build": "vite build",
|
|
98
100
|
"preview": "vite preview",
|
|
99
|
-
"typecheck": "tsc --noEmit"
|
|
101
|
+
"typecheck": "tsc --noEmit",
|
|
102
|
+
"test": "vitest run"
|
|
100
103
|
}
|
|
101
104
|
}
|
|
@@ -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
|
+
}
|
|
@@ -10,6 +10,7 @@ import { Controls } from '../Controls/index.js'
|
|
|
10
10
|
import { Actions } from '../Actions/index.js'
|
|
11
11
|
import { ShadowMount } from './ShadowMount.js'
|
|
12
12
|
import { IframeMount } from './IframeMount.js'
|
|
13
|
+
import { formatUsageCode } from './format-usage.js'
|
|
13
14
|
|
|
14
15
|
export interface PreviewProps {
|
|
15
16
|
readonly entryId: string
|
|
@@ -378,7 +379,6 @@ function ReadyFrame({
|
|
|
378
379
|
key={`${entry.id}/${jogak.name}`}
|
|
379
380
|
entry={entry}
|
|
380
381
|
args={mergedArgs}
|
|
381
|
-
source={entry.source}
|
|
382
382
|
theme={prismTheme}
|
|
383
383
|
previewIsolation={previewIsolation}
|
|
384
384
|
userPreviewUrl={userPreviewUrl}
|
|
@@ -530,7 +530,6 @@ function Toolbar({
|
|
|
530
530
|
interface JogakRendererProps {
|
|
531
531
|
readonly entry: RegistryEntry
|
|
532
532
|
readonly args: Readonly<Record<string, unknown>>
|
|
533
|
-
readonly source: string | undefined
|
|
534
533
|
readonly theme: PrismTheme
|
|
535
534
|
readonly previewIsolation: 'none' | 'shadow' | 'iframe'
|
|
536
535
|
readonly userPreviewUrl: string
|
|
@@ -544,8 +543,10 @@ interface JogakRendererProps {
|
|
|
544
543
|
* - `'shadow'` (deprecated) — `<ShadowMount>` 안에 마운트.
|
|
545
544
|
* - `'none'` (deprecated) — 같은 document에 직접 마운트.
|
|
546
545
|
*/
|
|
547
|
-
function JogakRenderer({ entry, args,
|
|
546
|
+
function JogakRenderer({ entry, args, theme, previewIsolation, userPreviewUrl, previewEntryPath }: JogakRendererProps): ReactElement {
|
|
548
547
|
const [showCode, setShowCode] = useState(false)
|
|
548
|
+
// 알파.10.3: 코드 패널은 jogak 메타 파일이 아니라 현재 args 기반 사용 코드를 노출.
|
|
549
|
+
const usageCode = formatUsageCode(entry, args)
|
|
549
550
|
|
|
550
551
|
const previewBody = (
|
|
551
552
|
<div className="jogak:relative">
|
|
@@ -580,7 +581,7 @@ function JogakRenderer({ entry, args, source, theme, previewIsolation, userPrevi
|
|
|
580
581
|
{/* 코드 패널 — preview-content 하단으로 펼쳐짐 */}
|
|
581
582
|
{showCode && (
|
|
582
583
|
<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={
|
|
584
|
+
<SourceViewer source={usageCode} theme={theme} />
|
|
584
585
|
</div>
|
|
585
586
|
)}
|
|
586
587
|
</div>
|