@hoardodile/sdk-react 0.0.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.
@@ -0,0 +1,100 @@
1
+ import type { PluginSchema } from "@hoardodile/sdk-types"
2
+ import type {
3
+ AnchorData,
4
+ ReactivePluginAPI,
5
+ WebPluginAPI,
6
+ } from "@hoardodile/sdk-web"
7
+ import type { Provider } from "react"
8
+ import { useContext, useEffect, useRef } from "react"
9
+ import { PluginAPIContext } from "./context.tsx"
10
+
11
+ /** The full API seen by React plugin components: imperative + hooks. */
12
+ export type FullPluginAPI<TSchema extends PluginSchema> =
13
+ WebPluginAPI<TSchema> & ReactivePluginAPI<TSchema>
14
+
15
+ export type DefinePluginAPIOptions<TSchema extends PluginSchema> = {
16
+ /**
17
+ * Validate incoming anchor data (host → plugin) against the schema's
18
+ * `anchor` slot. Anchors that fail decoding are dropped silently and
19
+ * never reach the `useAnchorJump` callback. Declare this whenever the
20
+ * schema declares an `anchor` type.
21
+ */
22
+ readonly decodeAnchor?: (data: unknown) => TSchema["anchor"] | undefined
23
+ }
24
+
25
+ /**
26
+ * Define a typed plugin API context. The schema is declared once at module
27
+ * level — every consumer below gets properly typed access without repeating
28
+ * generics.
29
+ *
30
+ * The returned provider and hook share the same React context as the default
31
+ * {@link PluginAPIProvider}, so plugin roots created by `createPluginRoot`
32
+ * automatically satisfy typed consumers when the same provider is passed in.
33
+ *
34
+ * ```typescript
35
+ * interface VideoSchema { file: VideoFile; sourceMeta: VideoSourceMeta; anchor: VideoTimeAnchor }
36
+ * const { PluginAPIProvider, usePluginAPI, useAnchorJump } = definePluginAPI<VideoSchema>({
37
+ * decodeAnchor: decodeVideoTimeAnchor,
38
+ * })
39
+ *
40
+ * function Viewer() {
41
+ * const api = usePluginAPI()
42
+ * const { data: files } = api.useFileList()
43
+ * // files → readonly VideoFile[] | undefined
44
+ * }
45
+ * ```
46
+ */
47
+ export function definePluginAPI<TSchema extends PluginSchema = PluginSchema>(
48
+ options?: DefinePluginAPIOptions<TSchema>,
49
+ ): {
50
+ readonly PluginAPIProvider: Provider<FullPluginAPI<TSchema> | null>
51
+ readonly usePluginAPI: () => FullPluginAPI<TSchema>
52
+ readonly useAnchorJump: (cb: (anchor: TSchema["anchor"]) => void) => void
53
+ } {
54
+ const decodeAnchor = options?.decodeAnchor
55
+
56
+ function useTypedPluginAPI(): FullPluginAPI<TSchema> {
57
+ const api = useContext(PluginAPIContext)
58
+ if (api === null) {
59
+ throw new Error("usePluginAPI must be used within a PluginAPIProvider")
60
+ }
61
+ // SDK boundary: the shared context stores the base API; the schema
62
+ // slots narrow it for this plugin. Declared once here so consumers
63
+ // stay cast-free.
64
+ return api as unknown as FullPluginAPI<TSchema>
65
+ }
66
+
67
+ /**
68
+ * Typed anchor-jump hook: incoming anchor data is decoded once at the
69
+ * SDK boundary, so the callback receives the schema's anchor type
70
+ * directly with no manual narrowing. The latest callback is invoked
71
+ * without resubscribing on every render.
72
+ */
73
+ function useTypedAnchorJump(cb: (anchor: TSchema["anchor"]) => void) {
74
+ const api = useTypedPluginAPI()
75
+ const cbRef = useRef(cb)
76
+ cbRef.current = cb
77
+
78
+ useEffect(
79
+ function subscribe() {
80
+ return api.onAnchorJump(function handle(anchor: AnchorData) {
81
+ if (decodeAnchor === undefined) {
82
+ cbRef.current(anchor.data as TSchema["anchor"])
83
+ return
84
+ }
85
+ const data = decodeAnchor(anchor.data)
86
+ if (data === undefined) return
87
+ cbRef.current(data)
88
+ })
89
+ },
90
+ [api],
91
+ )
92
+ }
93
+
94
+ return {
95
+ PluginAPIProvider:
96
+ PluginAPIContext.Provider as Provider<FullPluginAPI<TSchema> | null>,
97
+ usePluginAPI: useTypedPluginAPI,
98
+ useAnchorJump: useTypedAnchorJump,
99
+ }
100
+ }
@@ -0,0 +1,25 @@
1
+ import { createWebPluginAPI, type DeepPartial } from "@hoardodile/sdk-web"
2
+ import type { ReactNode } from "react"
3
+ import { type BasePluginAPI, PluginAPIProvider } from "./context.tsx"
4
+
5
+ export { createWebPluginAPI, type DeepPartial } from "@hoardodile/sdk-web"
6
+
7
+ /**
8
+ * Wrap children with a stubbed API provider for tests. Builds the stub
9
+ * via `createWebPluginAPI` from `@hoardodile/sdk-web` (re-exported
10
+ * below) — the imperative surface plus no-op reactive hooks, overridable
11
+ * via `api`.
12
+ */
13
+ export function StubPluginAPIProvider({
14
+ api,
15
+ children,
16
+ }: {
17
+ readonly api?: DeepPartial<BasePluginAPI>
18
+ readonly children: ReactNode
19
+ }) {
20
+ return (
21
+ <PluginAPIProvider value={createWebPluginAPI(api)}>
22
+ {children}
23
+ </PluginAPIProvider>
24
+ )
25
+ }
@@ -0,0 +1,147 @@
1
+ import { act } from "react"
2
+ import { createRoot, type Root } from "react-dom/client"
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
4
+ import { createPluginTranslation } from "./i18n.ts"
5
+
6
+ // The wire payload is a bare string; the legacy object shape must keep
7
+ // working, so the mock accepts both.
8
+ type LanguagePush = string | { language: string }
9
+
10
+ const mocks = vi.hoisted(() => ({
11
+ contextLanguage: { value: "en" as string | undefined },
12
+ pushHandlers: [] as ((data: LanguagePush) => void)[],
13
+ }))
14
+
15
+ vi.mock("@hoardodile/sdk-web", () => ({
16
+ ensureHostBridge: () => ({
17
+ subscribe: (
18
+ _key: string,
19
+ handler: (data: LanguagePush) => void,
20
+ ): (() => void) => {
21
+ mocks.pushHandlers.push(handler)
22
+ return () => {}
23
+ },
24
+ }),
25
+ getPluginContext: () =>
26
+ mocks.contextLanguage.value === undefined
27
+ ? undefined
28
+ : { language: mocks.contextLanguage.value },
29
+ }))
30
+
31
+ const BUNDLES = {
32
+ en: { greeting: "Hello", withName: "Hi {{name}}" },
33
+ zh: { greeting: "你好", withName: "你好 {{name}}" },
34
+ }
35
+
36
+ function renderProbe() {
37
+ const container = document.createElement("div")
38
+ document.body.appendChild(container)
39
+ const { useTranslation } = createPluginTranslation(BUNDLES)
40
+ let root: Root | undefined
41
+ let translation:
42
+ | {
43
+ t: (key: string, vars?: Record<string, string | number>) => string
44
+ language: string
45
+ }
46
+ | undefined
47
+
48
+ function Probe() {
49
+ translation = useTranslation()
50
+ return <div data-testid="out">{translation.t("greeting")}</div>
51
+ }
52
+
53
+ act(() => {
54
+ root = createRoot(container)
55
+ root.render(<Probe />)
56
+ })
57
+
58
+ return {
59
+ text: () =>
60
+ container.querySelector("[data-testid='out']")?.textContent ?? "",
61
+ current: () => translation!,
62
+ unmount: () => {
63
+ act(() => {
64
+ root?.unmount()
65
+ })
66
+ container.remove()
67
+ },
68
+ }
69
+ }
70
+
71
+ afterEach(() => {
72
+ mocks.contextLanguage.value = "en"
73
+ mocks.pushHandlers.length = 0
74
+ })
75
+
76
+ describe("createPluginTranslation", () => {
77
+ beforeEach(() => {
78
+ mocks.pushHandlers.length = 0
79
+ })
80
+
81
+ it("starts in the language from the plugin context", () => {
82
+ mocks.contextLanguage.value = "zh"
83
+ const probe = renderProbe()
84
+ expect(probe.text()).toBe("你好")
85
+ expect(probe.current().language).toBe("zh")
86
+ probe.unmount()
87
+ })
88
+
89
+ it("updates when the host pushes a language change", () => {
90
+ mocks.contextLanguage.value = "en"
91
+ const probe = renderProbe()
92
+ expect(probe.text()).toBe("Hello")
93
+
94
+ // The real wire payload is a bare language-code string.
95
+ act(() => {
96
+ for (const handler of mocks.pushHandlers) {
97
+ handler("zh")
98
+ }
99
+ })
100
+ expect(probe.text()).toBe("你好")
101
+ expect(probe.current().language).toBe("zh")
102
+ probe.unmount()
103
+ })
104
+
105
+ it("still accepts the legacy object-shaped language push", () => {
106
+ mocks.contextLanguage.value = "en"
107
+ const probe = renderProbe()
108
+
109
+ act(() => {
110
+ for (const handler of mocks.pushHandlers) {
111
+ handler({ language: "zh" })
112
+ }
113
+ })
114
+ expect(probe.text()).toBe("你好")
115
+ probe.unmount()
116
+ })
117
+
118
+ it("stays in the host language and falls back to English for missing bundle languages", () => {
119
+ mocks.contextLanguage.value = "ja"
120
+ const probe = renderProbe()
121
+ // The plugin ships en/zh only; the shared ui namespace still
122
+ // resolves the host language, plugin strings fall back to en.
123
+ expect(probe.current().language).toBe("ja")
124
+ expect(probe.text()).toBe("Hello")
125
+ probe.unmount()
126
+ })
127
+
128
+ it("interpolates {{var}} placeholders", () => {
129
+ const probe = renderProbe()
130
+ expect(probe.current().t("withName", { name: "A" })).toBe("Hi A")
131
+ probe.unmount()
132
+ })
133
+
134
+ it("returns the key when the message is missing", () => {
135
+ const probe = renderProbe()
136
+ expect(probe.current().t("noSuchKey")).toBe("noSuchKey")
137
+ probe.unmount()
138
+ })
139
+
140
+ it("ignores the host push when no bridge subscription ran yet", () => {
141
+ mocks.contextLanguage.value = "en"
142
+ const probe = renderProbe()
143
+ const before = mocks.pushHandlers.length
144
+ expect(before).toBeGreaterThan(0)
145
+ probe.unmount()
146
+ })
147
+ })
package/src/i18n.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { uiCatalogFor } from "@hoardodile/i18n/catalogs/ui"
2
+ import { isSupportedLanguage, SUPPORTED_LANGUAGES } from "@hoardodile/i18n/core"
3
+ import { createI18n } from "@hoardodile/i18n/create-i18n"
4
+ import { ensureHostBridge, getPluginContext } from "@hoardodile/sdk-web"
5
+ import type { Resource } from "i18next"
6
+ import { useEffect } from "react"
7
+ import { setI18n, useTranslation as useReactTranslation } from "react-i18next"
8
+
9
+ type RawBundle = Record<string, unknown>
10
+
11
+ type InterpolationVars = Record<string, string | number>
12
+
13
+ type PluginTranslation = {
14
+ readonly t: (key: string, vars?: InterpolationVars) => string
15
+ readonly language: string
16
+ }
17
+
18
+ function resolveLocale(lang: string, available: Set<string>): string {
19
+ if (available.has(lang)) return lang
20
+ const base = lang.split("-")[0]!
21
+ if (available.has(base)) return base
22
+ return "en"
23
+ }
24
+
25
+ /**
26
+ * Creates a `useTranslation` hook backed by the given locale bundles plus
27
+ * the shared `ui` catalog namespace (so `@hoardodile/ui` components
28
+ * render localized chrome in every supported host language).
29
+ *
30
+ * Backed by i18next/react-i18next with the same options as the host
31
+ * surfaces: the language follows the plugin context, updates when the
32
+ * host sends a `languageChanged` push, interpolates `{{var}}`
33
+ * placeholders, and falls back to English (via `fallbackLng`) for
34
+ * languages the plugin's own bundle does not ship.
35
+ */
36
+ export function createPluginTranslation(bundles: Record<string, RawBundle>): {
37
+ readonly useTranslation: () => PluginTranslation
38
+ } {
39
+ const availableLangs = new Set<string>([
40
+ ...Object.keys(bundles),
41
+ ...SUPPORTED_LANGUAGES,
42
+ ])
43
+
44
+ // The small shared `ui` namespace (every supported language, so ui
45
+ // chrome always matches the host language) plus the plugin's own
46
+ // `plugin` namespace — never the full app catalog (the iframe bundle
47
+ // stays a fraction of the SPA's i18n payload).
48
+ const resources: Resource = {}
49
+ for (const language of availableLangs) {
50
+ const base = language.split("-")[0]!
51
+ resources[language] = {
52
+ ...(isSupportedLanguage(base) ? { ui: uiCatalogFor(base) } : {}),
53
+ ...(bundles[language] === undefined ? {} : { plugin: bundles[language] }),
54
+ }
55
+ }
56
+
57
+ const initial = resolveLocale(
58
+ getPluginContext()?.language ?? "en",
59
+ availableLangs,
60
+ )
61
+ const instance = createI18n({ lng: initial, resources })
62
+
63
+ // Bind as react-i18next's default instance so every `@hoardodile/ui`
64
+ // component rendered in this iframe resolves the same instance.
65
+ setI18n(instance)
66
+
67
+ let subscribed = false
68
+ function subscribeToLanguageChanges(): void {
69
+ if (subscribed) return
70
+ subscribed = true
71
+ ensureHostBridge().subscribe("languageChanged", (data) => {
72
+ // The wire payload is a bare language-code string (predates the
73
+ // typed protocol table); accept the legacy object shape too so
74
+ // plugins compiled against either contract keep switching.
75
+ const language =
76
+ typeof data === "string"
77
+ ? data
78
+ : String((data as { language?: string }).language ?? "")
79
+ void instance.changeLanguage(resolveLocale(language, availableLangs))
80
+ })
81
+ }
82
+
83
+ function useTranslation(): PluginTranslation {
84
+ useEffect(subscribeToLanguageChanges, [])
85
+ const { t, i18n } = useReactTranslation("plugin", {
86
+ i18n: instance,
87
+ useSuspense: false,
88
+ })
89
+ return {
90
+ t: t as unknown as PluginTranslation["t"],
91
+ language: i18n.resolvedLanguage ?? i18n.language,
92
+ }
93
+ }
94
+
95
+ return { useTranslation }
96
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { PluginAPIProvider, usePluginAPI } from "./context.tsx"
2
+ export {
3
+ type DefinePluginAPIOptions,
4
+ definePluginAPI,
5
+ type FullPluginAPI,
6
+ } from "./define-api.ts"
7
+ export {
8
+ createWebPluginAPI,
9
+ type DeepPartial,
10
+ StubPluginAPIProvider,
11
+ } from "./fixtures.tsx"
12
+ export { createPluginTranslation } from "./i18n.ts"
13
+ export { createPluginQueryAPI } from "./query.ts"
14
+ export {
15
+ createPluginRoot,
16
+ type PluginRootConfig,
17
+ useVisibility,
18
+ } from "./root.tsx"
19
+ export { useCacheWriter } from "./use-cache-writer.ts"
20
+ export {
21
+ type ExtractProgressState,
22
+ useExtractProgress,
23
+ } from "./use-extract-progress.ts"