@nifrajs/web-svelte 0.1.0-alpha.1

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/dist/Await.svelte +27 -0
  4. package/dist/Await.svelte.d.ts +21 -0
  5. package/dist/Chain.svelte +35 -0
  6. package/dist/Content.svelte +13 -0
  7. package/dist/Content.svelte.d.ts +17 -0
  8. package/dist/I18nProvider.svelte +20 -0
  9. package/dist/I18nProvider.svelte.d.ts +15 -0
  10. package/dist/Image.svelte +27 -0
  11. package/dist/Image.svelte.d.ts +29 -0
  12. package/dist/Router.svelte +27 -0
  13. package/dist/client.d.ts +12 -0
  14. package/dist/client.d.ts.map +1 -0
  15. package/dist/client.js +30 -0
  16. package/dist/client.js.map +1 -0
  17. package/dist/error.d.ts +13 -0
  18. package/dist/error.d.ts.map +1 -0
  19. package/dist/error.js +5 -0
  20. package/dist/error.js.map +1 -0
  21. package/dist/fetcher.d.ts +36 -0
  22. package/dist/fetcher.d.ts.map +1 -0
  23. package/dist/fetcher.js +55 -0
  24. package/dist/fetcher.js.map +1 -0
  25. package/dist/i18n.d.ts +11 -0
  26. package/dist/i18n.d.ts.map +1 -0
  27. package/dist/i18n.js +15 -0
  28. package/dist/i18n.js.map +1 -0
  29. package/dist/index.d.ts +13 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +40 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/mdx.d.ts +5 -0
  34. package/dist/mdx.d.ts.map +1 -0
  35. package/dist/mdx.js +74 -0
  36. package/dist/mdx.js.map +1 -0
  37. package/dist/plugin.d.ts +3 -0
  38. package/dist/plugin.d.ts.map +1 -0
  39. package/dist/plugin.js +58 -0
  40. package/dist/plugin.js.map +1 -0
  41. package/dist/query.d.ts +26 -0
  42. package/dist/query.d.ts.map +1 -0
  43. package/dist/query.js +55 -0
  44. package/dist/query.js.map +1 -0
  45. package/package.json +111 -0
  46. package/src/Await.svelte +27 -0
  47. package/src/Await.svelte.d.ts +21 -0
  48. package/src/Chain.svelte +35 -0
  49. package/src/Content.svelte +13 -0
  50. package/src/Content.svelte.d.ts +17 -0
  51. package/src/I18nProvider.svelte +20 -0
  52. package/src/I18nProvider.svelte.d.ts +15 -0
  53. package/src/Image.svelte +27 -0
  54. package/src/Image.svelte.d.ts +29 -0
  55. package/src/Router.svelte +27 -0
  56. package/src/client.ts +33 -0
  57. package/src/error.ts +15 -0
  58. package/src/fetcher.ts +66 -0
  59. package/src/i18n.ts +23 -0
  60. package/src/index.ts +51 -0
  61. package/src/mdx.ts +72 -0
  62. package/src/plugin.ts +60 -0
  63. package/src/query.ts +66 -0
  64. package/src/svelte-shim.d.ts +9 -0
package/src/error.ts ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `errorBoundary` — the Svelte `_error.tsx` boundary chain element. Svelte components are compiled (not
3
+ * JS closures), so unlike the other adapters this can't return a component bound to `fallback`. Instead
4
+ * it returns a **marker** that `Chain.svelte` recognises and renders as a `<svelte:boundary>` (Svelte
5
+ * 5.3+) with `fallback` as the `failed` snippet. Re-exported from `@nifrajs/web-svelte/client` so nifra's
6
+ * client codegen inserts it before the page in the matched chain.
7
+ */
8
+ export interface NifraSvelteErrorBoundary {
9
+ readonly __nifraErrorBoundary: unknown
10
+ }
11
+
12
+ /** Wrap a route's `_error` component as a boundary marker for `Chain.svelte` to render. */
13
+ export function errorBoundary(fallback: unknown): NifraSvelteErrorBoundary {
14
+ return { __nifraErrorBoundary: fallback }
15
+ }
package/src/fetcher.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { ClientRouter, Fetcher, FetcherState } from "@nifrajs/web"
2
+ /**
3
+ * `@nifrajs/web-svelte/fetcher` — Svelte bindings for concurrent fetchers, as **Svelte stores** (plain
4
+ * `.ts`, no runes/compiler needed). `useFetcher(key)` returns a `Readable<FetcherState>` augmented with
5
+ * `load`/`submit` — read it reactively in a component with `$fetcher` (auto-subscription), call
6
+ * `fetcher.load(...)` / `fetcher.submit(...)` imperatively. `useFetchers()` returns the live collection
7
+ * as a store. The store's start/stop notifier subscribes to the agnostic store lazily (on the first
8
+ * `$`-subscription) and unsubscribes on the last — so lifecycle is tied to the component automatically.
9
+ *
10
+ * The router that owns the fetchers is the one `mountRouter` hydrated — it registers itself here via
11
+ * `setMountedRouter`. On the server (no mount) there is no router, so the stores hold an idle value
12
+ * (fetchers are client-only); the first client render starts from the same idle snapshot — no mismatch.
13
+ */
14
+ import { type Readable, readable } from "svelte/store"
15
+
16
+ // The active client router (set by `mountRouter`). Module-scoped: the browser mounts one app per
17
+ // page, and fetchers never exist on the server. Shared with `client.ts`'s `mountRouter`.
18
+ let mountedRouter: ClientRouter | undefined
19
+
20
+ /** Register (or clear) the router that owns fetchers — called by `mountRouter`. Not for app use. */
21
+ export function setMountedRouter(router: ClientRouter | undefined): void {
22
+ mountedRouter = router
23
+ }
24
+
25
+ const IDLE: FetcherState = { pending: false, data: undefined }
26
+ const noopAsync = async (): Promise<void> => {}
27
+
28
+ /** A fetcher store: a `Readable<FetcherState>` (read via `$`) plus imperative `load`/`submit`. */
29
+ export type FetcherStore = Readable<FetcherState> & {
30
+ /** Load a route path's loader data into this fetcher (concurrent; doesn't touch the active view). */
31
+ readonly load: (path: string) => Promise<void>
32
+ /** Submit an action into this fetcher (concurrent); honors `X-Nifra-Revalidate`. */
33
+ readonly submit: (action: string, body: NonNullable<RequestInit["body"]>) => Promise<void>
34
+ }
35
+
36
+ /**
37
+ * Subscribe to the independent fetcher for `key` (created lazily, stable across renders). Returns a
38
+ * store of its state (`pending`/`data`/`actionData`/`submission`) augmented with `load`/`submit`.
39
+ * Multiple `useFetcher` calls with different keys run concurrently without disturbing the active route.
40
+ */
41
+ export function useFetcher(key: string): FetcherStore {
42
+ const fetcher = mountedRouter?.fetcher(key)
43
+ const store = readable<FetcherState>(fetcher ? fetcher.snapshot() : IDLE, (set) => {
44
+ if (fetcher === undefined) return
45
+ set(fetcher.snapshot())
46
+ return fetcher.subscribe(() => set(fetcher.snapshot()))
47
+ })
48
+ return Object.assign(store, {
49
+ load: fetcher?.load ?? noopAsync,
50
+ submit: fetcher?.submit ?? noopAsync,
51
+ })
52
+ }
53
+
54
+ /**
55
+ * Subscribe to the whole live fetcher collection — for a global busy view (e.g. "3 saving…"). Read
56
+ * each entry's `.snapshot()` for its state. The store updates whenever any fetcher transitions or a
57
+ * new one is created.
58
+ */
59
+ export function useFetchers(): Readable<readonly Fetcher[]> {
60
+ const router = mountedRouter
61
+ return readable<readonly Fetcher[]>(router ? router.fetchers() : [], (set) => {
62
+ if (router === undefined) return
63
+ set(router.fetchers())
64
+ return router.subscribeFetchers(() => set(router.fetchers()))
65
+ })
66
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `@nifrajs/web-svelte/i18n` — Svelte bindings for `@nifrajs/i18n`: the `<I18nProvider>` component (re-exported
3
+ * from `I18nProvider.svelte`) plus `useT()`, which reads the provided `Formatter` from Svelte context.
4
+ * Call `useT()` during a component's initialization (like any `getContext`).
5
+ */
6
+ import type { Formatter } from "@nifrajs/i18n"
7
+ import { getContext } from "svelte"
8
+
9
+ export { default as I18nProvider, type I18nProviderProps } from "./I18nProvider.svelte"
10
+
11
+ // Must match the string key `I18nProvider.svelte` passes to `setContext` (a string avoids a
12
+ // `.svelte` → `.ts` import that wouldn't resolve once the .svelte is copied to dist).
13
+ const I18N_KEY = "@nifrajs/web-svelte:i18n"
14
+
15
+ /** Read the current {@link Formatter} (`{ locale, t, n, d }`). Throws if no `<I18nProvider>` is above.
16
+ * nifra switches locale by re-navigating, which re-runs the consuming component with the new catalog. */
17
+ export function useT(): Formatter {
18
+ const get = getContext<(() => Formatter) | undefined>(I18N_KEY)
19
+ if (get === undefined) {
20
+ throw new Error("[nifra/web-svelte] useT() must be used within an <I18nProvider>")
21
+ }
22
+ return get()
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @nifrajs/web-svelte — the Svelte 5 render adapter for @nifrajs/web (server side) + the `.svelte` compiler
3
+ * Bun-plugin. SSR via Svelte's `render` (svelte/server), which returns HTML strings (Svelte SSR is
4
+ * string-based, not a stream), wrapped in a one-chunk Web `ReadableStream` (the seam's shape). The
5
+ * layout-chain fold is the recursive `Chain.svelte`. Client hydration lives in `@nifrajs/web-svelte/client`.
6
+ * Svelte components compile from `.svelte` files, so this adapter ships a build plugin (like
7
+ * `@nifrajs/web-solid`'s Babel plugin) — there is no callable-component runtime.
8
+ */
9
+ import type { RenderAdapter } from "@nifrajs/web"
10
+ import { render } from "svelte/server"
11
+ import Chain from "./Chain.svelte"
12
+
13
+ // Re-export the compiler plugin for convenience. NOTE: the SSR preload must import it from
14
+ // `@nifrajs/web-svelte/plugin` (not from here) — importing this module eagerly loads `Chain.svelte`,
15
+ // which must be compiled by the already-registered plugin.
16
+ export { svelteBunPlugin } from "./plugin.ts"
17
+
18
+ // Svelte SSR yields a complete HTML string; the seam wants a stream, so emit it as one chunk.
19
+ function oneChunk(html: string): ReadableStream<Uint8Array> {
20
+ const bytes = new TextEncoder().encode(html)
21
+ return new ReadableStream({
22
+ start(controller) {
23
+ controller.enqueue(bytes)
24
+ controller.close()
25
+ },
26
+ })
27
+ }
28
+
29
+ /** The Svelte server render adapter — pass to @nifrajs/web's `renderPage`. */
30
+ export const svelteAdapter: RenderAdapter = {
31
+ // Svelte SSR is already synchronous + string-based, so this is the native shape: `renderToString`
32
+ // returns the body directly (renderPage buffers it on the non-deferred fast path), and
33
+ // `renderToStream` wraps the same string in a one-chunk stream for the deferred path. No streaming
34
+ // renderer to skip here — but going straight to a string avoids the per-request stream allocation.
35
+ renderToString(chain, props) {
36
+ return render(Chain, { props: { chain, props } }).body
37
+ },
38
+ renderToStream(chain, props) {
39
+ // `Chain` folds the layout chain (page innermost gets `props`; layouts wrap via their `children`
40
+ // snippet). Svelte's `render` returns { head, body }; the body goes into #root. (Svelte's dynamic
41
+ // `head` — from <svelte:head> — isn't surfaced through the seam's static `hydrationHead`; nifra's
42
+ // own meta/head API manages the document head instead.)
43
+ const { body } = render(Chain, { props: { chain, props } })
44
+ return oneChunk(body)
45
+ },
46
+ // Svelte's client `hydrate` reconciles against the existing DOM; no per-document bootstrap script is
47
+ // needed (contrast Solid's generateHydrationScript) — the seam allows the empty string.
48
+ hydrationHead() {
49
+ return ""
50
+ },
51
+ }
package/src/mdx.ts ADDED
@@ -0,0 +1,72 @@
1
+ import type { BunPlugin } from "bun"
2
+ import { compile } from "svelte/compiler"
3
+
4
+ /**
5
+ * `@nifrajs/web-svelte/mdx` — compile `.mdx` routes/files to **Svelte** components. Svelte has no JSX
6
+ * runtime, so (unlike the JSX-family `mdxBunPlugin`) MDX-for-Svelte goes through `mdsvex` — Svelte's
7
+ * own Markdown compiler — which produces a `.svelte` module, then the standard `svelte/compiler` runs
8
+ * (`generate: "server"` for SSR, `"client"` for the browser). Pass it to `buildClient`/`buildServer`'s
9
+ * `plugins` alongside `svelteBunPlugin` (one handles `.svelte`, this one `.mdx`). `mdsvex` is an
10
+ * optional peer, lazy-loaded at build time.
11
+ *
12
+ * `<style>` blocks inside MDX are scoped + bundled exactly like `svelteBunPlugin`'s (own CSS namespace
13
+ * so the two plugins don't collide).
14
+ */
15
+ const MDSVEX_MODULE = "mdsvex" // non-literal so consumers don't need mdsvex's types to typecheck
16
+ const STYLE_SUFFIX = "?svelte-mdx-css"
17
+ const STYLE_NS = "nifra-svelte-mdx-css"
18
+
19
+ interface Mdsvex {
20
+ compile(source: string, options?: Record<string, unknown>): Promise<{ code: string } | undefined>
21
+ }
22
+
23
+ /** Build a `Bun.build` plugin that loads `.mdx` files as Svelte components via mdsvex. `generate`:
24
+ * `"ssr"` for the server build, `"dom"` for the client (matches `svelteBunPlugin`). */
25
+ export function svelteMdxBunPlugin(generate: "dom" | "ssr"): BunPlugin {
26
+ const cssByPath = new Map<string, string>()
27
+ return {
28
+ name: `nifra-svelte-mdx-${generate}`,
29
+ async setup(build) {
30
+ let mdsvexCompile: Mdsvex["compile"]
31
+ try {
32
+ mdsvexCompile = ((await import(MDSVEX_MODULE)) as Mdsvex).compile
33
+ } catch {
34
+ throw new Error(
35
+ "@nifrajs/web-svelte/mdx: compiling `.mdx` for Svelte needs the `mdsvex` package — install it (`bun add mdsvex`).",
36
+ )
37
+ }
38
+ build.onLoad({ filter: /\.mdx(\?|$)/ }, async (args) => {
39
+ const path = args.path.split("?")[0] ?? args.path
40
+ // 1. Markdown/MDX → Svelte source (mdsvex). `extensions` must include `.mdx` or mdsvex skips the
41
+ // file (returns undefined — it only processes its configured extensions, default `.svx`).
42
+ const pre = await mdsvexCompile(await Bun.file(path).text(), {
43
+ filename: path,
44
+ extensions: [".mdx", ".svx", ".md"],
45
+ })
46
+ if (!pre) throw new Error(`@nifrajs/web-svelte/mdx: mdsvex produced no output for ${path}`)
47
+ // 2. Svelte source → JS, exactly like `svelteBunPlugin`.
48
+ const { js, css } = compile(pre.code, {
49
+ generate: generate === "ssr" ? "server" : "client",
50
+ filename: path,
51
+ css: "external",
52
+ })
53
+ if (generate === "dom" && css?.code) {
54
+ cssByPath.set(path, css.code)
55
+ return {
56
+ contents: `${js.code}\nimport ${JSON.stringify(path + STYLE_SUFFIX)}\n`,
57
+ loader: "js",
58
+ }
59
+ }
60
+ return { contents: js.code, loader: "js" }
61
+ })
62
+ build.onResolve({ filter: /\?svelte-mdx-css$/ }, (args) => ({
63
+ path: args.path,
64
+ namespace: STYLE_NS,
65
+ }))
66
+ build.onLoad({ filter: /.*/, namespace: STYLE_NS }, (args) => ({
67
+ contents: cssByPath.get(args.path.slice(0, -STYLE_SUFFIX.length)) ?? "",
68
+ loader: "css",
69
+ }))
70
+ },
71
+ }
72
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { BunPlugin } from "bun"
2
+ import { compile } from "svelte/compiler"
3
+
4
+ /**
5
+ * `@nifrajs/web-svelte/plugin` — the `.svelte` compiler Bun-plugin, in its OWN module (no `.svelte`
6
+ * imports). This matters for the SSR preload: `bun --preload` must register the plugin BEFORE any
7
+ * `.svelte` file is loaded. Importing it from the package root (`./index`) would eagerly load
8
+ * `Chain.svelte` (the adapter's fold) before the plugin is registered — so it lives here instead.
9
+ *
10
+ * `generate: "server"` for SSR, `"client"` for the browser (Svelte 5's client output is hydratable).
11
+ * Preload it for SSR (`bun --preload`) and pass it to `buildClient({ plugins: [...] })` for the client
12
+ * bundle. The compiler runs at build time, on `.svelte` files only.
13
+ *
14
+ * `<style>` blocks: Svelte scopes selectors (`.foo.svelte-<hash>`) and bakes the matching classes into
15
+ * the markup (both client + server output), so SSR HTML is already scoped. The **client** build emits
16
+ * the scoped stylesheet as a virtual `?svelte-css` module that `Bun.build`'s CSS bundler folds into the
17
+ * app stylesheet (served as a `<link>`); the SSR build drops it (the stylesheet ships from the client
18
+ * build — no double-emit). With `nifra dev`, `@sveltejs/vite-plugin-svelte` injects the CSS instead.
19
+ */
20
+ const STYLE_SUFFIX = "?svelte-css"
21
+ const STYLE_NS = "nifra-svelte-css"
22
+
23
+ export function svelteBunPlugin(generate: "dom" | "ssr"): BunPlugin {
24
+ // Compiled scoped CSS per `.svelte` file (client build only) — read back by the virtual-module loader.
25
+ const cssByPath = new Map<string, string>()
26
+ return {
27
+ name: `nifra-svelte-${generate}`,
28
+ setup(build) {
29
+ // Match `.svelte`, tolerating a `?query` suffix (dev servers append one to bust Bun's import
30
+ // cache); strip it before reading the file off disk.
31
+ build.onLoad({ filter: /\.svelte(\?|$)/ }, async (args) => {
32
+ const path = args.path.split("?")[0] ?? args.path
33
+ const source = await Bun.file(path).text()
34
+ const { js, css } = compile(source, {
35
+ generate: generate === "ssr" ? "server" : "client",
36
+ filename: path,
37
+ css: "external", // emit the scoped stylesheet separately (not injected into the JS)
38
+ })
39
+ if (generate === "dom" && css?.code) {
40
+ cssByPath.set(path, css.code)
41
+ // Import the virtual style module so the bundler pulls the scoped CSS into the app stylesheet.
42
+ return {
43
+ contents: `${js.code}\nimport ${JSON.stringify(path + STYLE_SUFFIX)}\n`,
44
+ loader: "js",
45
+ }
46
+ }
47
+ return { contents: js.code, loader: "js" }
48
+ })
49
+ // Virtual CSS module: `<file>.svelte?svelte-css` → the compiled scoped stylesheet (css loader).
50
+ build.onResolve({ filter: /\?svelte-css$/ }, (args) => ({
51
+ path: args.path,
52
+ namespace: STYLE_NS,
53
+ }))
54
+ build.onLoad({ filter: /.*/, namespace: STYLE_NS }, (args) => ({
55
+ contents: cssByPath.get(args.path.slice(0, -STYLE_SUFFIX.length)) ?? "",
56
+ loader: "css",
57
+ }))
58
+ },
59
+ }
60
+ }
package/src/query.ts ADDED
@@ -0,0 +1,66 @@
1
+ import {
2
+ createQueryClient,
3
+ type QueryClient,
4
+ type QueryHandle,
5
+ type QueryState,
6
+ } from "@nifrajs/web"
7
+ /**
8
+ * `@nifrajs/web-svelte/query` — Svelte bindings for the keyed query-cache, as **Svelte stores** (plain
9
+ * `.ts`). `useQuery(key, fn)` returns a `Readable<QueryState<T>>` augmented with `refetch` — read it
10
+ * reactively with `$query`. The store fetches on mount (the start notifier fires on the first
11
+ * `$`-subscription) and subscribes to the cache entry; concurrent `useQuery`s with the same key share
12
+ * one entry + one in-flight fetch (dedup). `useQueryClient` exposes `invalidateQueries`.
13
+ *
14
+ * The query client is created lazily **client-side only** (the `typeof window` guard) — on the server
15
+ * there is none, so `useQuery` holds the idle/pending state (queries are client-first; loaders are the
16
+ * SSR data source). The first client render uses the same idle snapshot, so no mismatch.
17
+ */
18
+ import { type Readable, readable } from "svelte/store"
19
+
20
+ let client: QueryClient | undefined
21
+ function getClient(): QueryClient | undefined {
22
+ if (typeof window === "undefined") return undefined // SSR → no client (queries render idle)
23
+ if (client === undefined) client = createQueryClient({ now: () => Date.now() })
24
+ return client
25
+ }
26
+
27
+ /** Access the query client to imperatively `invalidateQueries(keyOrPrefix)` (e.g. after a mutation). */
28
+ export function useQueryClient(): Pick<QueryClient, "invalidateQueries"> {
29
+ return getClient() ?? { invalidateQueries: () => {} }
30
+ }
31
+
32
+ // Stable idle snapshot for the server / pre-fetch render.
33
+ const IDLE: QueryState<never> = Object.freeze({
34
+ status: "pending",
35
+ data: undefined,
36
+ error: undefined,
37
+ isFetching: false,
38
+ updatedAt: Number.NEGATIVE_INFINITY,
39
+ })
40
+
41
+ /** A query store: a `Readable<QueryState<T>>` (read via `$`) plus `refetch`. */
42
+ export type QueryStore<T> = Readable<QueryState<T>> & {
43
+ /** Force a refetch (ignores `staleTime`). */
44
+ readonly refetch: () => Promise<T>
45
+ }
46
+
47
+ /**
48
+ * Subscribe to the keyed query for `key`, fetched via `fn`. Returns a store of `{ status, data, error,
49
+ * isFetching, updatedAt }` augmented with `refetch`. Fetches on mount (first `$`-subscription); SSR-idle.
50
+ */
51
+ export function useQuery<T>(key: unknown, fn: () => Promise<T>): QueryStore<T> {
52
+ const handle: QueryHandle<T> | undefined = getClient()?.query<T>(key, fn)
53
+ const store = readable<QueryState<T>>(handle ? handle.snapshot() : IDLE, (set) => {
54
+ if (handle === undefined) return
55
+ set(handle.snapshot())
56
+ const unsubscribe = handle.subscribe(() => set(handle.snapshot()))
57
+ handle.fetch().catch(() => {}) // fetch on mount (the first subscriber attached)
58
+ return unsubscribe
59
+ })
60
+ const refetch = handle
61
+ ? handle.refetch
62
+ : ((async () => {
63
+ throw new Error("[nifra/web-svelte] useQuery.refetch called with no query client (server?)")
64
+ }) as () => Promise<T>)
65
+ return Object.assign(store, { refetch })
66
+ }
@@ -0,0 +1,9 @@
1
+ // `.svelte` files are compiled by `svelteBunPlugin` (or the consumer's Svelte bundler), not by tsc.
2
+ // This ambient declaration lets the .ts adapter/client import them with a usable type. The default
3
+ // export is a Svelte 5 `Component`; our helpers take a loose props bag (the chain + the loader props).
4
+ declare module "*.svelte" {
5
+ import type { Component } from "svelte"
6
+
7
+ const component: Component<Record<string, unknown>>
8
+ export default component
9
+ }