@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.
- package/LICENSE +18 -0
- package/README.md +61 -0
- package/dist/index.d.ts +199 -0
- package/dist/index.js +481 -0
- package/dist/index.js.map +1 -0
- package/package.json +61 -0
- package/src/context.tsx +28 -0
- package/src/define-api.ts +100 -0
- package/src/fixtures.tsx +25 -0
- package/src/i18n.test.tsx +147 -0
- package/src/i18n.ts +96 -0
- package/src/index.ts +23 -0
- package/src/query.ts +407 -0
- package/src/root.tsx +144 -0
- package/src/use-cache-writer.ts +61 -0
- package/src/use-extract-progress.test.tsx +118 -0
- package/src/use-extract-progress.ts +76 -0
|
@@ -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
|
+
}
|
package/src/fixtures.tsx
ADDED
|
@@ -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"
|