@jogak/ui 0.1.0-alpha.12 → 0.1.0-alpha.14.2

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.
@@ -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": "0.1.0-alpha.12",
3
+ "version": "0.1.0-alpha.14.2",
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",
@@ -65,7 +66,7 @@
65
66
  "prism-react-renderer": "^2.4.1",
66
67
  "tailwindcss": "^4.0.0",
67
68
  "@tailwindcss/vite": "^4.0.0",
68
- "@jogak/core": "0.1.0-alpha.12"
69
+ "@jogak/core": "0.1.0-alpha.14.2"
69
70
  },
70
71
  "devDependencies": {
71
72
  "@types/node": "^20.14.0",
@@ -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 { reactAdapter } from '@jogak/core/renderers/react'
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
- reactAdapter.render(entry, args, currentContainer)
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
- reactAdapter.unmount(currentContainer)
50
+ if (currentContainer !== null && currentAdapter !== null) {
51
+ currentAdapter.unmount(currentContainer)
36
52
  currentContainer = null
53
+ currentAdapter = null
37
54
  }
38
55
  }
39
56
 
@@ -60,10 +60,13 @@ export function formatUsageCode(
60
60
  }
61
61
 
62
62
  function resolveComponentName(entry: RegistryEntry): string {
63
+ // 알파.14.1: iframe isolation 모드는 chrome scope에 component를 import하지 않으므로
64
+ // entry.meta.component가 `null`. fallback (title 마지막 segment)으로 직행한다.
63
65
  const component = entry.meta.component as
64
66
  | { displayName?: unknown; name?: unknown }
67
+ | null
65
68
  | undefined
66
- if (component !== undefined) {
69
+ if (component !== null && component !== undefined) {
67
70
  if (typeof component.displayName === 'string' && component.displayName.length > 0) {
68
71
  return component.displayName
69
72
  }
@@ -3,9 +3,10 @@ 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 { reactAdapter, useEntry } from '@jogak/core/renderers/react'
6
+ import { useEntry } from '@jogak/core/renderers/react'
7
7
  import type { UseEntryState } from '@jogak/core/renderers/react'
8
- import type { RegistryEntry, RegistryEntryMeta, ArgType } from '@jogak/core'
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'
@@ -134,7 +135,11 @@ export function Preview({
134
135
  userPreviewUrl = '',
135
136
  previewEntryPath = '/__jogak_preview__/index.html',
136
137
  }: PreviewProps): ReactElement {
137
- const state = useEntry(entryId)
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' })
138
143
  const [viewport, setViewport] = useState<ViewportKey>('desktop')
139
144
  const [bgMode, setBgMode] = useState<BgMode>('white')
140
145
  const [bottomTab, setBottomTab] = useState<'controls' | 'actions'>('controls')
@@ -636,23 +641,50 @@ function PreviewMount({ entry, args, previewIsolation, userPreviewUrl, previewEn
636
641
 
637
642
  function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
638
643
  const containerRef = useRef<HTMLDivElement>(null)
644
+ const adapterRef = useRef<JogakAdapter | null>(null)
639
645
 
646
+ // 알파.14.1: entry.meta.framework로 dispatch. async 적응을 위해 effect 내에서
647
+ // await adapterFor → 캡처된 adapter로 render. unmount는 같은 adapter ref 사용.
640
648
  useEffect(() => {
641
649
  const container = containerRef.current
642
650
  if (container === null) return
643
- reactAdapter.render(entry, args, container)
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
+
644
660
  return () => {
661
+ cancelled = true
662
+ const adapter = adapterRef.current
663
+ if (adapter === null) return
645
664
  // 알파.7.1: React 18 concurrent unmount race(`Attempted to synchronously unmount...`)
646
665
  // 회피 — fiber commit 끝난 직후로 defer.
647
- queueMicrotask(() => { reactAdapter.unmount(container) })
666
+ queueMicrotask(() => { adapter.unmount(container) })
648
667
  }
649
668
  // eslint-disable-next-line react-hooks/exhaustive-deps
650
669
  }, [entry])
651
670
 
671
+ // args 갱신용 effect — adapter가 이미 캐시돼 있으면 동기 분기를 탄다.
652
672
  useEffect(() => {
653
673
  const container = containerRef.current
654
674
  if (container === null) return
655
- reactAdapter.render(entry, args, container)
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 }
656
688
  }, [entry, args])
657
689
 
658
690
  return (
@@ -665,19 +697,31 @@ function NoneAdapterContent({ entry, args }: { entry: RegistryEntry; args: Reado
665
697
  }
666
698
 
667
699
  /**
668
- * Shadow 모드 — ShadowMount의 ShadowRoot 안에서 react-adapter.render를 호출하는
700
+ * Shadow 모드 — ShadowMount의 ShadowRoot 안에서 adapter.render를 호출하는
669
701
  * 작은 wrapper. ShadowMount 안 portal 내부에 위치하므로 useRef는 ShadowRoot scope.
670
702
  */
671
703
  function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Readonly<Record<string, unknown>> }): ReactElement {
672
704
  const ref = useRef<HTMLDivElement>(null)
705
+ const adapterRef = useRef<JogakAdapter | null>(null)
673
706
 
674
707
  useEffect(() => {
675
708
  const c = ref.current
676
709
  if (c === null) return
677
- reactAdapter.render(entry, args, c)
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
+
678
719
  return () => {
720
+ cancelled = true
721
+ const adapter = adapterRef.current
722
+ if (adapter === null) return
679
723
  // 알파.7.1: unmount race 회피
680
- queueMicrotask(() => { reactAdapter.unmount(c) })
724
+ queueMicrotask(() => { adapter.unmount(c) })
681
725
  }
682
726
  // eslint-disable-next-line react-hooks/exhaustive-deps
683
727
  }, [entry])
@@ -685,7 +729,19 @@ function ShadowAdapterContent({ entry, args }: { entry: RegistryEntry; args: Rea
685
729
  useEffect(() => {
686
730
  const c = ref.current
687
731
  if (c === null) return
688
- reactAdapter.render(entry, args, c)
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 }
689
745
  }, [entry, args])
690
746
 
691
747
  return <div ref={ref} data-testid="preview-content-shadow" />
@@ -0,0 +1,173 @@
1
+ /**
2
+ * 알파.14.1: framework별 renderer adapter dispatch router.
3
+ *
4
+ * `RegistryEntryMeta.framework` 필드를 보고 적절한 어댑터를 dynamic import한다.
5
+ * React-only 사용자가 Vue/Svelte 모듈을 로딩 받지 않도록 dynamic import + 모듈 캐시.
6
+ *
7
+ * 지원 framework:
8
+ * - 'react' / 'next' → `@jogak/core/renderers/react#reactAdapter`
9
+ * - 'vue' → `@jogak/core/renderers/vue#vueAdapter`
10
+ * - 'svelte' → `@jogak/core/renderers/svelte#svelteAdapter`
11
+ * - 'web-components' → 내부 wrapper (defineJogakElement 기반)
12
+ *
13
+ * 알 수 없는 framework는 명시적 에러 메시지로 throw한다.
14
+ */
15
+
16
+ import type { JogakAdapter, RegistryEntry } from '@jogak/core'
17
+
18
+ /** RegistryEntryMeta.framework가 받을 수 있는 모든 값 (JogakAdapter.framework와 동일). */
19
+ export type FrameworkKey =
20
+ | 'react'
21
+ | 'next'
22
+ | 'web-components'
23
+ | 'vue'
24
+ | 'svelte'
25
+
26
+ const cache = new Map<FrameworkKey, JogakAdapter>()
27
+ const inflight = new Map<FrameworkKey, Promise<JogakAdapter>>()
28
+
29
+ /**
30
+ * framework 이름으로 어댑터를 가져온다. 첫 호출은 dynamic import, 이후는 캐시 반환.
31
+ *
32
+ * 동시에 같은 framework로 호출되는 경우(예: 여러 effect가 동시에 await)에도 단 한 번만
33
+ * import가 일어나도록 inflight Promise를 공유한다.
34
+ */
35
+ export async function adapterFor(framework: string): Promise<JogakAdapter> {
36
+ const key = framework as FrameworkKey
37
+ const cached = cache.get(key)
38
+ if (cached !== undefined) return cached
39
+
40
+ const pending = inflight.get(key)
41
+ if (pending !== undefined) return pending
42
+
43
+ const loader = loadAdapter(key)
44
+ inflight.set(key, loader)
45
+ try {
46
+ const adapter = await loader
47
+ cache.set(key, adapter)
48
+ return adapter
49
+ } finally {
50
+ inflight.delete(key)
51
+ }
52
+ }
53
+
54
+ async function loadAdapter(framework: FrameworkKey): Promise<JogakAdapter> {
55
+ switch (framework) {
56
+ case 'react':
57
+ case 'next': {
58
+ // Next.js의 client-side 렌더링도 React 18+ root API와 동일하므로 reactAdapter 재사용.
59
+ const mod = await import('@jogak/core/renderers/react')
60
+ return mod.reactAdapter
61
+ }
62
+ case 'vue': {
63
+ const mod = await import('@jogak/core/renderers/vue')
64
+ return mod.vueAdapter
65
+ }
66
+ case 'svelte': {
67
+ const mod = await import('@jogak/core/renderers/svelte')
68
+ return mod.svelteAdapter
69
+ }
70
+ case 'web-components': {
71
+ const mod = await import('@jogak/core/renderers/web-components')
72
+ return createWebComponentsAdapter(mod.defineJogakElement)
73
+ }
74
+ default: {
75
+ throw new Error(
76
+ `[jogak/ui] Unknown framework: '${framework as string}'. ` +
77
+ `Expected one of: 'react' | 'next' | 'vue' | 'svelte' | 'web-components'.`,
78
+ )
79
+ }
80
+ }
81
+ }
82
+
83
+ // ── web-components wrapper ────────────────────────────────
84
+ //
85
+ // web-components renderer는 `defineJogakElement(tagName, entry)`로 custom element를
86
+ // 등록하는 형태로, `JogakAdapter` ABI(render/unmount(container))를 직접 제공하지 않는다.
87
+ // UI 측 dispatch 통일을 위해 thin wrapper로 감싼다.
88
+
89
+ type CustomElementHost = HTMLElement & {
90
+ setAttribute(name: string, value: string): void
91
+ }
92
+
93
+ type WCContainer = HTMLElement & {
94
+ _jogakWCElement?: HTMLElement
95
+ _jogakWCTagName?: string
96
+ }
97
+
98
+ function entryToTagName(entryId: string): string {
99
+ // entryId는 `Category/Subcategory/Name` 형태일 수 있다. custom element는 dash를
100
+ // 포함한 lowercase여야 하므로 안전한 문자만 남기고 'jogak-' prefix를 부여한다.
101
+ const safe = entryId
102
+ .toLowerCase()
103
+ .replace(/[^a-z0-9]+/gu, '-')
104
+ .replace(/^-+|-+$/gu, '')
105
+ return `jogak-${safe || 'entry'}`
106
+ }
107
+
108
+ function serializeAttribute(value: unknown): string | null {
109
+ if (value === undefined || value === null) return null
110
+ if (typeof value === 'string') return value
111
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value)
112
+ // 함수/객체는 attribute로 표현 불가 — null로 skip(어댑터가 injectActions 처리).
113
+ if (typeof value === 'function' || typeof value === 'object') return null
114
+ return String(value)
115
+ }
116
+
117
+ function createWebComponentsAdapter(
118
+ defineJogakElement: (tagName: string, entry: RegistryEntry) => void,
119
+ ): JogakAdapter {
120
+ return {
121
+ framework: 'web-components',
122
+ render(
123
+ entry: RegistryEntry,
124
+ args: Readonly<Record<string, unknown>>,
125
+ container: HTMLElement,
126
+ ): void {
127
+ const state = container as WCContainer
128
+ const tagName = entryToTagName(entry.id)
129
+ defineJogakElement(tagName, entry)
130
+
131
+ let el = state._jogakWCElement
132
+ if (el === undefined || state._jogakWCTagName !== tagName) {
133
+ // entry가 바뀐 경우(다른 tagName) 기존 element 제거 후 재생성.
134
+ if (el !== undefined) el.remove()
135
+ el = document.createElement(tagName) as CustomElementHost
136
+ container.replaceChildren(el)
137
+ state._jogakWCElement = el
138
+ state._jogakWCTagName = tagName
139
+ }
140
+
141
+ for (const [key, value] of Object.entries(args)) {
142
+ const serialized = serializeAttribute(value)
143
+ if (serialized === null) {
144
+ el.removeAttribute(key)
145
+ } else {
146
+ el.setAttribute(key, serialized)
147
+ }
148
+ }
149
+ },
150
+ unmount(container: HTMLElement): void {
151
+ const state = container as WCContainer
152
+ const el = state._jogakWCElement
153
+ if (el !== undefined) {
154
+ el.remove()
155
+ }
156
+ delete state._jogakWCElement
157
+ delete state._jogakWCTagName
158
+ },
159
+ }
160
+ }
161
+
162
+ // ── test-only helpers ─────────────────────────────────────
163
+
164
+ /**
165
+ * @internal test-only. 어댑터 캐시를 초기화한다.
166
+ *
167
+ * cache hit/miss 테스트, framework lookup 격리 테스트에 사용. 프로덕션 코드에서는
168
+ * 호출하지 말 것 — dynamic import의 비용이 발생한다.
169
+ */
170
+ export function __resetAdapterCacheForTesting(): void {
171
+ cache.clear()
172
+ inflight.clear()
173
+ }