@vincle/flow 0.9.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 +21 -0
- package/README.md +513 -0
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.js +19 -0
- package/dist/components/index.d.ts +19 -0
- package/dist/components/index.js +1 -0
- package/dist/context-DlzTTBsQ.js +1 -0
- package/dist/context.d.ts +127 -0
- package/dist/context.js +1 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.js +1 -0
- package/dist/index-M7xu3JXg.d.ts +201 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +1 -0
- package/dist/render-zCgGmdz0.js +5 -0
- package/dist/utils-DKRixAvn.js +1 -0
- package/dist/utils.d.ts +36 -0
- package/dist/utils.js +1 -0
- package/package.json +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Christophe Jean
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
# @vincle/flow
|
|
2
|
+
|
|
3
|
+
Fragment streaming extension for [@vincle/core](https://github.com/cjean-fr/vincle/tree/main/packages/core). Renders deferred JSX fragments and delivers them to the browser as DOM patches. **Pick the adapter that matches your JS budget: from zero JS (WebPlatform/ESI) to ~550 B (Native) to full framework (Turbo/HTMX).**
|
|
4
|
+
|
|
5
|
+
## When to use
|
|
6
|
+
|
|
7
|
+
Use `@vincle/core` alone for SSG, emails, and pure SSR. Add `@vincle/flow` when you need **progressive enhancement**: the initial HTML loads fast with placeholders, and heavy or slow components are rendered separately and patched into the page without a full reload.
|
|
8
|
+
|
|
9
|
+
| `@vincle/core` | `@vincle/flow` |
|
|
10
|
+
| ------------------------- | -------------------------------------------------- |
|
|
11
|
+
| Renders JSX → HTML string | Adds deferred fragments + streaming patch delivery |
|
|
12
|
+
| Server-only, zero runtime | Emits adapter-specific markup for DOM updates |
|
|
13
|
+
| `renderToString()` | `renderToStream()` / `renderToStatic()` |
|
|
14
|
+
| N/A | Adapters: Turbo, HTMX, Native, WebPlatform, ESI |
|
|
15
|
+
|
|
16
|
+
## Why deferred regions in streaming SSR
|
|
17
|
+
|
|
18
|
+
A standard `renderToString` call is a serial pipeline: the server computes the full page before sending the first byte. With streaming, the shell (layout, navigation, above-the-fold content) goes to the browser immediately while heavy components are still rendering.
|
|
19
|
+
|
|
20
|
+
Deferred regions of the same **wave** render **in parallel** and independently: the slowest one does not block its wave-mates. Waves flush one after another, and streams run on their own separate schedule. The browser receives the shell, paints it, then receives patches as they arrive and applies them in-place.
|
|
21
|
+
|
|
22
|
+
- **TTFB / FCP**: users see content in one round-trip, not after all async work settles
|
|
23
|
+
- **No virtual DOM, no hydration**: patches are applied as plain HTML by the adapter's client mechanism (Turbo, HTMX) or a minimal polyfill
|
|
24
|
+
- **Fault isolation**: a failed fragment routes to `onError` (or logs) and is skipped; the rest of the page is unaffected
|
|
25
|
+
- **Memory**: fragments are streamed out as they render, not accumulated before flushing
|
|
26
|
+
|
|
27
|
+
## Why deferred regions in SSG
|
|
28
|
+
|
|
29
|
+
In static generation every page is a snapshot. Cache invalidation is the hard part: when a deeply nested component changes, you regenerate the entire page. Deferred fragments split that snapshot.
|
|
30
|
+
|
|
31
|
+
The shell (stable layout, navigation) is one file. Each fragment is a separate file at a predictable URL. Your build pipeline treats them independently:
|
|
32
|
+
|
|
33
|
+
- **Granular invalidation**: only fragments whose data changed need to be regenerated; the shell stays cached
|
|
34
|
+
- **Per-fragment TTL**: a "live prices" fragment can expire in 60 s while the surrounding page is cached for a day
|
|
35
|
+
- **CDN composition**: shell and fragments are plain HTML files; any CDN can serve them
|
|
36
|
+
- **Incremental builds**: on large sites, regenerating ten fragment files instead of ten thousand pages cuts build time
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bun add @vincle/flow
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Components
|
|
45
|
+
|
|
46
|
+
@vincle/flow provides three declarative primitives for deferred content: a unified family built around **Slot** (the hole) and **Defer** (the deferred content that fills it). Each works with any adapter, in both streaming and static generation.
|
|
47
|
+
|
|
48
|
+
### `<Slot>`: a named insertion point with fallback content
|
|
49
|
+
|
|
50
|
+
Declares a named insertion point in the shell. Its `children` are rendered immediately as placeholder content: visible in the initial HTML until a `<Defer target="…">` overrides them.
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { Slot, Defer } from "@vincle/flow";
|
|
54
|
+
|
|
55
|
+
function Layout() {
|
|
56
|
+
return (
|
|
57
|
+
<html>
|
|
58
|
+
<body>
|
|
59
|
+
<nav>...</nav>
|
|
60
|
+
<main>
|
|
61
|
+
<Slot name="page">
|
|
62
|
+
<p>Loading…</p>
|
|
63
|
+
</Slot>
|
|
64
|
+
</main>
|
|
65
|
+
</body>
|
|
66
|
+
</html>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Elsewhere in the tree (or a different component):
|
|
71
|
+
function PageContent() {
|
|
72
|
+
return <Defer target="page">{() => <h1>Hello</h1>}</Defer>;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`Slot` registers nothing in the pending store. It is purely a passive hole. The `children` are the initial fallback, visible until a `<Defer>` pushes deferred content that patches the placeholder.
|
|
77
|
+
|
|
78
|
+
### `<Defer>`: deferred fragment (sync or lazy)
|
|
79
|
+
|
|
80
|
+
Defines deferred content targeting a DOM element by id. **Unified**: one component for both synchronous and deferred content. The behaviour depends on `children`:
|
|
81
|
+
|
|
82
|
+
| `children` type | `fallback` | Behaviour |
|
|
83
|
+
| ------------------- | ---------- | ------------------------------------------------- |
|
|
84
|
+
| `VNode` (plain JSX) | N/A | Registers content; renders a placeholder |
|
|
85
|
+
| `VNode` (plain JSX) | provided | Registers content; placeholder renders `fallback` |
|
|
86
|
+
| `(signal) => …` | N/A | Lazy content; renders a placeholder |
|
|
87
|
+
| `(signal) => …` | provided | Lazy content; placeholder renders `fallback` |
|
|
88
|
+
|
|
89
|
+
**Sync**: plain JSX:
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
import { Defer } from "@vincle/flow";
|
|
93
|
+
|
|
94
|
+
<Defer target="page"><h1>Hello</h1></Defer>
|
|
95
|
+
<Defer target="comments"><Comments /></Defer>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Lazy with placeholder**: factory + fallback:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
<Defer target="comments" fallback={<p>Loading…</p>}>
|
|
102
|
+
{() => <Comments />}
|
|
103
|
+
</Defer>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
**Cancellable**: factory receives `AbortSignal` (request lifetime + per-fragment timeout):
|
|
107
|
+
|
|
108
|
+
```tsx
|
|
109
|
+
// One-shot
|
|
110
|
+
<Defer target="page">{() => <h1>Hello</h1>}</Defer>
|
|
111
|
+
|
|
112
|
+
// Async
|
|
113
|
+
<Defer target="comments">{() => <Comments />}</Defer>
|
|
114
|
+
|
|
115
|
+
// Stream: each yield is a separate patch
|
|
116
|
+
<Defer target="feed" merge="append">
|
|
117
|
+
{() => liveRows()}
|
|
118
|
+
</Defer>
|
|
119
|
+
|
|
120
|
+
// With timeout: receives AbortSignal
|
|
121
|
+
<Defer target="dashboard" timeout={2000}>
|
|
122
|
+
{(signal) => <Dashboard signal={signal} />}
|
|
123
|
+
</Defer>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The factory is invoked only when the fragment is about to render. It receives an `AbortSignal` for cancellation and must return a renderable JSX node (or a `Promise` / `AsyncIterable` thereof, which the runtime unwraps automatically).
|
|
127
|
+
|
|
128
|
+
`Defer` always renders a placeholder at its position in the shell: `fallback` is that placeholder's content (empty if none given), and the deferred content replaces it when it resolves. Rendering a `Defer` without any adapter throws at registration: there is no placeholder to emit and nothing to patch into.
|
|
129
|
+
|
|
130
|
+
### `<Include>`: client-side fetch only
|
|
131
|
+
|
|
132
|
+
Renders a placeholder with a `src` attribute: the browser fetches the fragment after the shell lands. No server-push, works with any static host.
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
import { Include } from "@vincle/flow";
|
|
136
|
+
|
|
137
|
+
<Include src="/fragments/comments.html" />;
|
|
138
|
+
<Include src="/fragments/stats.html" fallback={<Spinner />} />;
|
|
139
|
+
|
|
140
|
+
// @ts-expect-error: dangerous schemes are rejected at compile time
|
|
141
|
+
<Include src="javascript:alert(1)" />;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`src` uses a strict **whitelist**: for string **literals**, only `http(s):` and relative paths compile. Every other scheme (`javascript:`, `data:`, `mailto:`, …) is a compile-time error. A fragment fetch needs no other scheme. Dynamic `string` values pass through and remain the caller's responsibility.
|
|
145
|
+
|
|
146
|
+
Named after the [draft HTML `<include>` element](https://github.com/whatwg/html/issues/2791) and ESI `<esi:include>`: short, standard, self-explanatory.
|
|
147
|
+
|
|
148
|
+
### Content forms
|
|
149
|
+
|
|
150
|
+
`Defer` accepts content in either form:
|
|
151
|
+
|
|
152
|
+
| Child | Behaviour |
|
|
153
|
+
| ---------------------------- | -------------------------------------------------------------------- |
|
|
154
|
+
| JSX node (plain children) | Deferred automatically: executes when the fragment renders |
|
|
155
|
+
| `(signal: AbortSignal) => …` | **Cancellable**: receives `AbortSignal` (request cancel + `timeout`) |
|
|
156
|
+
|
|
157
|
+
`Slot` children are plain JSX (not deferred). They render immediately as the fallback placeholder in the shell.
|
|
158
|
+
|
|
159
|
+
### Common props
|
|
160
|
+
|
|
161
|
+
| Prop | Applies to | Meaning |
|
|
162
|
+
| ---------- | ---------- | ------------------------------------------------------------------ |
|
|
163
|
+
| `name` | `Slot` | id of the placeholder (required) |
|
|
164
|
+
| `target` | `Defer` | target DOM id to push content into |
|
|
165
|
+
| `fallback` | `Defer` | placeholder content shown in the shell (only for lazy factories) |
|
|
166
|
+
| `merge` | `Defer` | how content applies to its target (default `"replace"`): see below |
|
|
167
|
+
| `timeout` | `Defer` | per-fragment render timeout in ms |
|
|
168
|
+
| `onError` | `Defer` | per-fragment error handler, overriding the renderer's `onError` |
|
|
169
|
+
| `src` | `Include` | URL the browser fetches for the fragment content |
|
|
170
|
+
|
|
171
|
+
### Merge types
|
|
172
|
+
|
|
173
|
+
`merge` describes how content is applied **relative to the target DOM element identified by `id`**.
|
|
174
|
+
|
|
175
|
+
| `merge` | Effect |
|
|
176
|
+
| ----------- | -------------------------------------------------------------- |
|
|
177
|
+
| `"replace"` | Target element is replaced by content (default) |
|
|
178
|
+
| `"append"` | Content inserted as last child of target |
|
|
179
|
+
| `"prepend"` | Content inserted as first child of target |
|
|
180
|
+
| `"before"` | Content inserted as previous sibling of target |
|
|
181
|
+
| `"after"` | Content inserted as next sibling of target |
|
|
182
|
+
| `"morph"` | Target element is diffed against content, preserving DOM state |
|
|
183
|
+
|
|
184
|
+
The first five say **where** content goes; `"morph"` says **how** it is applied: the client diffs instead of swapping, so focus, scroll position and form state survive the update. It needs a diffing client: `TurboAdapter` (Turbo >= 8) and `HtmxAdapter` (htmx >= 4) only.
|
|
185
|
+
|
|
186
|
+
An adapter that cannot express a merge **rejects it at registration** with a clear error (see capabilities). `NativeAdapter` supports the five positions but not `"morph"`: diffing is far past a 550 B budget. `WebPlatformAdapter` and `EsiAdapter` support `"replace"` only.
|
|
187
|
+
|
|
188
|
+
## Adapters
|
|
189
|
+
|
|
190
|
+
Each adapter implements `Placeholder`/`Patch`/`Frame` (JSX), optional `transformShell`, and a `capabilities` descriptor. The streaming wire format (shell → fragments, `\n`-separated) is the primitive's own serialization in `renderToStream`: an adapter only declares it can stream (`capabilities.streaming: true`), it does not implement the wire. Adapters are **pure wire formats**: HTTP negotiation is a separate concern (see below).
|
|
191
|
+
|
|
192
|
+
| Adapter | `Placeholder` | `Patch` (streaming inline) | `Frame` (SSG lazy-load) |
|
|
193
|
+
| -------------------- | ---------------------- | ----------------------------------------------- | ----------------------- |
|
|
194
|
+
| `TurboAdapter` | `<turbo-frame>` | `<turbo-stream action="…">` | `<turbo-frame id="…">` |
|
|
195
|
+
| `HtmxAdapter` | `<div hx-get>` | `<div hx-swap-oob="…">` | `<div id="…">` |
|
|
196
|
+
| `NativeAdapter` | `<?start name>…<?end>` | `<template for>` (`data-merge` for non-replace) | `<template for="…">` |
|
|
197
|
+
| `WebPlatformAdapter` | `<?start name>…<?end>` | `<template for="…">` (`replace` only) | `<template for="…">` |
|
|
198
|
+
| `EsiAdapter` | `<esi:include src>` | `<esi:inline name fetchable>` (static only) | raw HTML |
|
|
199
|
+
|
|
200
|
+
- **`Patch`**: fragment delivered inline in the same HTTP response as the shell.
|
|
201
|
+
- **`Frame`**: fragment served as a standalone file fetched by the client (SSG).
|
|
202
|
+
- `NativeAdapter` injects a ~550 B polyfill for DOM patching. Pass `WebPlatformAdapter` for zero-JS output, or `EsiAdapter` for CDN-level composition without client JS. An adapter is **required**: `renderToStream` takes it as its second argument, and a `Defer` without one throws at registration.
|
|
203
|
+
|
|
204
|
+
### Capabilities
|
|
205
|
+
|
|
206
|
+
Every adapter declares what its wire format can express:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
type AdapterCapabilities = {
|
|
210
|
+
streaming: boolean; // can carry a live FlowEvent stream
|
|
211
|
+
merges: readonly MergeType[]; // which merges it supports
|
|
212
|
+
};
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
This is surfaced in the type system. `renderToStream` / `serve` require a streaming adapter, so **`EsiAdapter` is rejected at compile time** there: ESI composition happens at the CDN, via `renderToStatic` + `emitFragments`. An unsupported `merge` fails fast at registration.
|
|
216
|
+
|
|
217
|
+
#### `NativeAdapter` (~550 B polyfill)
|
|
218
|
+
|
|
219
|
+
Uses the [Declarative Partial Updates](https://developer.chrome.com/blog/declarative-partial-updates) API plus a minimal polyfill injected via `transformShell`. All five merge positions (not `"morph"`), no external client library, works in modern browsers.
|
|
220
|
+
|
|
221
|
+
Every update is a **declarative `<template for>`**: the merge mode rides on `data-merge`, lazy client fetches on `data-src`. There are **no per-fragment inline scripts**; the only JS is a single static polyfill, which makes a strict CSP straightforward:
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { NativeAdapter, nativePolyfillHash, NATIVE_POLYFILL } from "@vincle/flow/adapters";
|
|
225
|
+
|
|
226
|
+
// Option A: keep the inline <script>, pin it by hash (static ⇒ cache/SSG-safe):
|
|
227
|
+
res.headers.set("Content-Security-Policy", `script-src 'self' '${await nativePolyfillHash()}'`);
|
|
228
|
+
|
|
229
|
+
// Option B: serve the polyfill from your origin under script-src 'self':
|
|
230
|
+
// write NATIVE_POLYFILL to e.g. /flow.js, then:
|
|
231
|
+
const selfHosted = {
|
|
232
|
+
...NativeAdapter,
|
|
233
|
+
transformShell: (shell: string) => injectIntoHead(shell, `<script src="/flow.js"></script>`),
|
|
234
|
+
};
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
A per-request **nonce** is intentionally not offered: it would break the static-cache/SSG story. A hash works because the script never changes.
|
|
238
|
+
|
|
239
|
+
#### `WebPlatformAdapter`
|
|
240
|
+
|
|
241
|
+
Emits the [WICG Declarative Partial Updates](https://developer.chrome.com/docs/web-platform/declarative-partial-updates) wire format without a client-side runtime. It supports `"replace"` only and requires native browser support, which is not universal. Use `NativeAdapter` for a polyfilled fallback.
|
|
242
|
+
|
|
243
|
+
#### `EsiAdapter`: CDN-level composition
|
|
244
|
+
|
|
245
|
+
For **SSG with a CDN ESI processor** (Varnish, Fastly, nginx ESI module). The shell contains `<esi:include src="…">` tags; the CDN fetches each fragment independently, applies separate TTLs, and assembles the final response before it reaches the browser. `"replace"` only; no client-side JS.
|
|
246
|
+
|
|
247
|
+
```tsx
|
|
248
|
+
// Defer with ESI: placeholder becomes esi:include, content renders the fragment
|
|
249
|
+
<Defer target="nav" fallback={<span>Loading nav…</span>}>
|
|
250
|
+
{(signal) => <Nav signal={signal} />}
|
|
251
|
+
</Defer>
|
|
252
|
+
<Defer target="feed" fallback={<span>Loading feed…</span>}>
|
|
253
|
+
{(signal) => <Feed signal={signal} />}
|
|
254
|
+
</Defer>
|
|
255
|
+
// Plain JSX also works when no signal is needed:
|
|
256
|
+
<Defer target="footer" fallback={<span>Loading…</span>}>
|
|
257
|
+
<Footer />
|
|
258
|
+
</Defer>
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
## Usage
|
|
262
|
+
|
|
263
|
+
### Streaming (server pushes fragments)
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
import { renderToStream, Defer } from "@vincle/flow";
|
|
267
|
+
import { NativeAdapter } from "@vincle/flow/adapters";
|
|
268
|
+
|
|
269
|
+
const stream = renderToStream(
|
|
270
|
+
() => (
|
|
271
|
+
<html>
|
|
272
|
+
<body>
|
|
273
|
+
<header>Fast</header>
|
|
274
|
+
<Defer target="dashboard" fallback={<p>Loading…</p>}>
|
|
275
|
+
<HeavyDashboard />
|
|
276
|
+
</Defer>
|
|
277
|
+
</body>
|
|
278
|
+
</html>
|
|
279
|
+
),
|
|
280
|
+
NativeAdapter,
|
|
281
|
+
);
|
|
282
|
+
// → ReadableStream<string>. Pipe it to the HTTP response.
|
|
283
|
+
// Swap the adapter (TurboAdapter, HtmxAdapter, …) to change wire format.
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Static generation, pure-static (no deferred content)
|
|
287
|
+
|
|
288
|
+
```tsx
|
|
289
|
+
import { renderToStatic } from "@vincle/flow";
|
|
290
|
+
|
|
291
|
+
await renderToStatic(async (ctx) => {
|
|
292
|
+
for (const page of pages) {
|
|
293
|
+
const html = await ctx.renderPage(() => page.component({ currentPage: page.url }));
|
|
294
|
+
await Bun.write(page.outPath, "<!DOCTYPE html>\n" + html);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
No adapter required: @vincle/flow stays invisible for pure-static rendering, as long as no `<Defer>` is rendered. A `Defer` without an adapter throws at registration.
|
|
300
|
+
|
|
301
|
+
### Static generation with deferred fragments
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
import { renderToStatic } from "@vincle/flow";
|
|
305
|
+
import { NativeAdapter } from "@vincle/flow/adapters";
|
|
306
|
+
|
|
307
|
+
await renderToStatic(
|
|
308
|
+
async (ctx) => {
|
|
309
|
+
for (const page of pages) {
|
|
310
|
+
const html = await ctx.renderPage(() => page.component({ currentPage: page.url }));
|
|
311
|
+
await Bun.write(page.outPath, "<!DOCTYPE html>\n" + html);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Each fragment is already wrapped in adapter.Frame and rendered,
|
|
315
|
+
// `html` is ready to write, no raw()/renderToString needed.
|
|
316
|
+
await ctx.emitFragments((id, url, html) => Bun.write("./out" + url, html));
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
adapter: NativeAdapter,
|
|
320
|
+
generatePath: (id) => `/fragments/${id}.html`,
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### On-demand fragment regeneration
|
|
326
|
+
|
|
327
|
+
When one piece of data changes, `renderFragment` writes just its fragment: no site rebuild:
|
|
328
|
+
|
|
329
|
+
```tsx
|
|
330
|
+
import { renderFragment } from "@vincle/flow";
|
|
331
|
+
import { NativeAdapter } from "@vincle/flow/adapters";
|
|
332
|
+
|
|
333
|
+
const { url, html } = await renderFragment("price-AAPL", <span>{price}</span>, {
|
|
334
|
+
adapter: NativeAdapter,
|
|
335
|
+
});
|
|
336
|
+
// `url` matches what the full build wrote: upload `html` there (blob store,
|
|
337
|
+
// on-demand revalidation, CDN purge + PUT…). The shell that includes it is untouched.
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
This is host-agnostic on purpose. It fits Netlify's on-demand functions or Vercel's on-demand revalidation, but doesn't depend on either.
|
|
341
|
+
|
|
342
|
+
### HTTP responses with negotiation
|
|
343
|
+
|
|
344
|
+
Negotiation is **opt-in and decoupled from the adapter**: pass a `negotiate` function (e.g. `negotiateHtmx`, or your own) to extract per-request hints and headers. Without it, the full page is rendered and the client library extracts its own target.
|
|
345
|
+
|
|
346
|
+
```tsx
|
|
347
|
+
import { serve, negotiateHtmx } from "@vincle/flow/http";
|
|
348
|
+
import { WebPlatformAdapter } from "@vincle/flow/adapters";
|
|
349
|
+
|
|
350
|
+
Bun.serve({
|
|
351
|
+
fetch(req) {
|
|
352
|
+
return serve(req, (n) => <App target={n.target} />, WebPlatformAdapter, {
|
|
353
|
+
negotiate: negotiateHtmx,
|
|
354
|
+
// `mode: "fragment"` (shell suppressed) is an explicit opt-in and only
|
|
355
|
+
// produces output when the targeted content is expressed as deferred fragments.
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Composing shell transforms
|
|
362
|
+
|
|
363
|
+
`composeShell` chains several `transformShell` functions (e.g. to inject `<title>`, asset links) into one: falsy entries are skipped, so an adapter's own transform splices in cleanly:
|
|
364
|
+
|
|
365
|
+
```tsx
|
|
366
|
+
import { NativeAdapter, createAdapter } from "@vincle/flow/adapters";
|
|
367
|
+
import { composeShell, injectIntoHead } from "@vincle/flow/utils";
|
|
368
|
+
|
|
369
|
+
const metadata = () => (html: string) => injectIntoHead(html, "<title>Home</title>");
|
|
370
|
+
|
|
371
|
+
const MyAdapter = createAdapter({
|
|
372
|
+
...NativeAdapter,
|
|
373
|
+
transformShell: composeShell(NativeAdapter.transformShell, metadata()),
|
|
374
|
+
});
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
#### `ShellContext`
|
|
378
|
+
|
|
379
|
+
`transformShell` receives a `ShellContext` with one field:
|
|
380
|
+
|
|
381
|
+
```ts
|
|
382
|
+
interface ShellContext {
|
|
383
|
+
fragments: { size: number };
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The fragment count is the number of registered `<Defer>` / `<Slot>` entries after the shell rendered. Use it to conditionally inject client runtime only when fragments exist:
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
const SmartAdapter = {
|
|
391
|
+
...NativeAdapter,
|
|
392
|
+
transformShell: (shell: string, ctx) => (ctx.fragments.size > 0 ? injectPolyfill(shell) : shell),
|
|
393
|
+
};
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### Fragment timeouts
|
|
397
|
+
|
|
398
|
+
`renderToStream`, `serve`, and `renderToStatic` accept `defaultTimeout` on their options:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
renderToStream(() => <Page />, NativeAdapter, { defaultTimeout: 5000 });
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
It is a fallback for any `<Defer>` that has no `timeout` prop. A fragment that exceeds its timeout aborts and routes to `onError` (or logs and skips). Per-fragment `timeout` overrides the default:
|
|
405
|
+
|
|
406
|
+
```tsx
|
|
407
|
+
<Defer target="page" timeout={2000}>
|
|
408
|
+
{/* This fragment has a 2s timeout, not the 5s default */}
|
|
409
|
+
<SlowComponent />
|
|
410
|
+
</Defer>
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Custom negotiation
|
|
414
|
+
|
|
415
|
+
Write a custom `negotiate` function for any client library that uses custom headers:
|
|
416
|
+
|
|
417
|
+
```ts
|
|
418
|
+
import type { Negotiate } from "@vincle/flow";
|
|
419
|
+
|
|
420
|
+
const negotiateMyLib: Negotiate = (req) => {
|
|
421
|
+
const fragment = req.headers.get("X-Fragment-Id") ?? undefined;
|
|
422
|
+
return {
|
|
423
|
+
headers: { Vary: "X-Fragment-Id" },
|
|
424
|
+
target: fragment,
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
The `Vary` header is **unioned** with existing `Vary` values (not overwritten), so multiple negotiators or manual headers never corrupt shared-cache keys.
|
|
430
|
+
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
## API
|
|
434
|
+
|
|
435
|
+
All exports are importable from `@vincle/flow` unless noted otherwise.
|
|
436
|
+
|
|
437
|
+
### Components
|
|
438
|
+
|
|
439
|
+
| Export | Import path | Description |
|
|
440
|
+
| --------- | ------------------------- | ------------------------------------------------------------------------------ |
|
|
441
|
+
| `Slot` | `@vincle/flow` | Named insertion point with optional fallback children; renders a placeholder |
|
|
442
|
+
| `Defer` | `@vincle/flow` | Push deferred content into a target DOM id: sync (plain JSX) or lazy (factory) |
|
|
443
|
+
| `Include` | `@vincle/flow` | Client-side fetch placeholder: no server deferral |
|
|
444
|
+
| `Style` | `@vincle/flow/components` | Named, deduplicated `<style>` tag |
|
|
445
|
+
| `Script` | `@vincle/flow/components` | Named, deduplicated `<script>` tag |
|
|
446
|
+
|
|
447
|
+
### Renderers
|
|
448
|
+
|
|
449
|
+
| Export | Import path | Description |
|
|
450
|
+
| -------------------- | ------------------- | --------------------------------------------------------------------------------------------- |
|
|
451
|
+
| `renderToStream` | `@vincle/flow` | Streams shell + fragments as a `ReadableStream<string>` via the given `adapter` |
|
|
452
|
+
| `renderToFlowEvents` | `@vincle/flow` | Lower level: `ReadableStream<FlowEvent>` (semantic events) with backpressure and cancellation |
|
|
453
|
+
| `renderToStatic` | `@vincle/flow` | Runs `handler` in a static render scope |
|
|
454
|
+
| `renderFragment` | `@vincle/flow` | Renders one fragment on demand, outside a full build: for on-demand regeneration |
|
|
455
|
+
| `serve` | `@vincle/flow/http` | Full HTTP `Response` builder |
|
|
456
|
+
|
|
457
|
+
### Negotiation
|
|
458
|
+
|
|
459
|
+
| Export | Import path | Description |
|
|
460
|
+
| --------------- | ------------------- | ------------------------------------------------ |
|
|
461
|
+
| `negotiateHtmx` | `@vincle/flow/http` | HTMX negotiation: reads `HX-Target`, sets `Vary` |
|
|
462
|
+
| `Negotiate` | `@vincle/flow/http` | Type: `(req: Request) => Negotiation` |
|
|
463
|
+
|
|
464
|
+
### Adapters
|
|
465
|
+
|
|
466
|
+
| Export | Import path | Description |
|
|
467
|
+
| -------------------- | ----------------------- | ------------------------------------------------------ |
|
|
468
|
+
| `NativeAdapter` | `@vincle/flow/adapters` | Declarative Partial Updates + polyfill: no `morph` |
|
|
469
|
+
| `TurboAdapter` | `@vincle/flow/adapters` | Hotwire Turbo Streams: all merge types |
|
|
470
|
+
| `HtmxAdapter` | `@vincle/flow/adapters` | HTMX OOB swaps: all merge types |
|
|
471
|
+
| `WebPlatformAdapter` | `@vincle/flow/adapters` | Pure WICG spec, zero JS: `replace` only |
|
|
472
|
+
| `EsiAdapter` | `@vincle/flow/adapters` | CDN-level ESI composition: `replace` only, static only |
|
|
473
|
+
| `createAdapter` | `@vincle/flow/adapters` | Build a custom adapter |
|
|
474
|
+
| `NATIVE_POLYFILL` | `@vincle/flow/adapters` | Native adapter polyfill as a JS string |
|
|
475
|
+
| `nativePolyfillHash` | `@vincle/flow/adapters` | `() => Promise<string>`: CSP hash for the polyfill |
|
|
476
|
+
|
|
477
|
+
### Types
|
|
478
|
+
|
|
479
|
+
| Export | Import path | Description |
|
|
480
|
+
| --------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
481
|
+
| `Adapter` | `@vincle/flow/adapters` | `{ Placeholder, Patch, Frame, capabilities, transformShell? }` |
|
|
482
|
+
| `StreamingAdapter` | `@vincle/flow/adapters` | `Adapter` with `capabilities.streaming: true` |
|
|
483
|
+
| `AdapterCapabilities` | `@vincle/flow/adapters` | `{ streaming: boolean; merges: readonly MergeType[] }` |
|
|
484
|
+
| `ShellContext` | `@vincle/flow/adapters` | What `transformShell` receives: `{ fragments: { size } }` |
|
|
485
|
+
| `PureStaticContext` | `@vincle/flow` | Static generation context (no `emitFragments`) |
|
|
486
|
+
| `StaticContext` | `@vincle/flow` | Static generation context with `emitFragments` |
|
|
487
|
+
| `MergeType` | `@vincle/flow` | `"replace" \| "append" \| "prepend" \| "before" \| "after" \| "morph"` |
|
|
488
|
+
| `FlowEvent` | `@vincle/flow` | `{ type: "shell" \| "fragment" \| "close", … }` |
|
|
489
|
+
| `Negotiation` | `@vincle/flow` | `{ headers?, mode?, target? }` |
|
|
490
|
+
| `DeferContent` | `@vincle/flow` | `JSX.Element \| string \| ((signal: AbortSignal) => JSX.Element) \| AsyncIterable<JSX.Element> \| ((signal: AbortSignal) => AsyncIterable<JSX.Element>)` |
|
|
491
|
+
|
|
492
|
+
### Utilities
|
|
493
|
+
|
|
494
|
+
| Export | Import path | Description |
|
|
495
|
+
| ---------------- | -------------------- | -------------------------------------------------------------------- |
|
|
496
|
+
| `composeShell` | `@vincle/flow/utils` | Compose several `transformShell` (string→string) into one |
|
|
497
|
+
| `injectIntoHead` | `@vincle/flow/utils` | Insert markup before `</head>` (building block for shell transforms) |
|
|
498
|
+
|
|
499
|
+
### Flow context
|
|
500
|
+
|
|
501
|
+
| Export | Import path | Description |
|
|
502
|
+
| ------------- | ---------------------- | --------------------------------------------------------------------------------------- |
|
|
503
|
+
| `Flow` | `@vincle/flow/context` | The `Scope` key for the per-render flow context: `Scope.get(Flow)` from within a render |
|
|
504
|
+
| `FlowContext` | `@vincle/flow/context` | The per-render context: `config`, `registerFragment`, `nextId`, asset state |
|
|
505
|
+
|
|
506
|
+
> `<Style>` / `<Script>` emit their tag directly, deduplicating as the walk
|
|
507
|
+
> reaches them: no post-render pass, so nothing has to re-derive the document
|
|
508
|
+
> order the engine already guarantees.
|
|
509
|
+
|
|
510
|
+
## License
|
|
511
|
+
|
|
512
|
+
MIT © Christophe Jean
|
|
513
|
+
```
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import { a as withPolyfill, c as TurboAdapter, d as createAdapter, i as WebPlatformAdapter, l as Adapter, n as HtmxAdapter, o as NATIVE_POLYFILL, r as NativeAdapter, s as nativePolyfillHash, t as EsiAdapter, u as ShellContext } from "../index-M7xu3JXg.js";
|
|
2
|
+
export { type Adapter, EsiAdapter, HtmxAdapter, NATIVE_POLYFILL, NativeAdapter, type ShellContext, TurboAdapter, WebPlatformAdapter, createAdapter, nativePolyfillHash, withPolyfill };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import{a as e,r as t,u as n}from"../utils-DKRixAvn.js";import{raw as r}from"@vincle/core";import{Fragment as i,jsx as a,jsxs as o}from"@vincle/core/jsx-runtime";import{escapeAttr as s}from"@vincle/core/html";const c={streaming:!0,merges:n};function l(t){let n={...t,capabilities:t.capabilities??c};return e(n,`createAdapter`),n}const u={replace:`replace`,append:`append`,prepend:`prepend`,before:`before`,after:`after`,morph:`replace`},d=l({Placeholder:function({id:e,src:t,children:n}){return t?/* @__PURE__ */ a(`turbo-frame`,{id:e,src:t,children:n}):/* @__PURE__ */ a(`turbo-frame`,{id:e,children:n})},Patch:({id:e,children:t,merge:n})=>/* @__PURE__ */ a(`turbo-stream`,{action:u[n],method:n===`morph`?`morph`:void 0,target:e,children:/* @__PURE__ */ a(`template`,{children:t})}),Frame:({id:e,children:t})=>/* @__PURE__ */ a(`turbo-frame`,{id:e,children:t})}),f=`(function(){
|
|
2
|
+
var ADJ={append:"beforeend",prepend:"afterbegin",before:"beforebegin",after:"afterend"};
|
|
3
|
+
function fill(name,frag){
|
|
4
|
+
var it=document.createNodeIterator(document.body||document.documentElement,128),nd,s=null,e=null;
|
|
5
|
+
while((nd=it.nextNode())){if(!s&&nd.nodeValue==='?start name="'+name+'"'){s=nd;continue;}if(s&&nd.nodeValue==='?end'){e=nd;break;}}
|
|
6
|
+
if(!s)return;var c=s.nextSibling;while(c&&c!==e){var x=c.nextSibling;c.remove();c=x;}s.after(frag);
|
|
7
|
+
}
|
|
8
|
+
function run(t){
|
|
9
|
+
var name=t.getAttribute("for");if(!name)return;
|
|
10
|
+
var src=t.getAttribute("data-src");
|
|
11
|
+
if(src!=null&&src!==""){try{var p=new URL(src,document.baseURI||location.href).protocol;if(p!=="http:"&&p!=="https:"){t.remove();return;}}catch(e){t.remove();return;}fetch(src).then(function(r){return r.text();}).then(function(h){var x=document.createElement("template");x.innerHTML=h;fill(name,x.content);});t.remove();return;}
|
|
12
|
+
var merge=t.getAttribute("data-merge");
|
|
13
|
+
if(merge&&merge!=="replace"){var el=document.getElementById(name);if(el)el.insertAdjacentHTML(ADJ[merge],t.innerHTML);t.remove();return;}
|
|
14
|
+
fill(name,t.content.cloneNode(true));t.remove();
|
|
15
|
+
}
|
|
16
|
+
function scan(r){var ts=r.querySelectorAll?r.querySelectorAll("template[for]"):[];for(var i=0;i<ts.length;i++)run(ts[i]);}
|
|
17
|
+
scan(document);
|
|
18
|
+
new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeName==='TEMPLATE'&&n.getAttribute&&n.getAttribute('for'))run(n);});});}).observe(document.documentElement,{childList:true,subtree:true});
|
|
19
|
+
})()`,p=`<script>${f}<\/script>`;async function m(){let e=await crypto.subtle.digest(`SHA-256`,new TextEncoder().encode(f));return`sha256-${btoa(String.fromCharCode(...new Uint8Array(e)))}`}const h=l({capabilities:{streaming:!0,merges:[`replace`]},Placeholder:function({id:e,src:t,children:n}){let c=s(e);return t?/* @__PURE__ */ o(i,{children:[r(`<?start name="${c}">`),n,r(`<?end>`),/* @__PURE__ */ a(`template`,{htmlFor:e,"data-src":t})]}):/* @__PURE__ */ o(i,{children:[r(`<?start name="${c}">`),n,r(`<?end>`)]})},Patch:({id:e,children:t,merge:n})=>n===`replace`?/* @__PURE__ */ a(`template`,{htmlFor:e,children:t}):/* @__PURE__ */ a(`template`,{htmlFor:e,"data-merge":n,children:t}),Frame:({id:e,children:t})=>/* @__PURE__ */ a(`template`,{htmlFor:e,children:t})}),g=[`replace`,`append`,`prepend`,`before`,`after`];function _(e){return{...e,capabilities:{streaming:e.capabilities.streaming,merges:g},transformShell:(n,r)=>{let i=e.transformShell?e.transformShell(n,r):n;return r.fragments.size===0?i:t(i,p)}}}const v=_(h),y={replace:`outerHTML`,append:`beforeend`,prepend:`afterbegin`,before:`beforebegin`,after:`afterend`,morph:`outerMorph`},b=l({Placeholder:function({id:e,src:t,children:n}){return t?/* @__PURE__ */ a(`div`,{id:e,"hx-get":t,"hx-trigger":`load`,"hx-swap":`outerHTML`,children:n}):/* @__PURE__ */ a(`div`,{id:e,children:n})},Patch:({id:e,children:t,merge:n})=>/* @__PURE__ */ a(`div`,{id:e,"hx-swap-oob":y[n],children:t}),Frame:({id:e,children:t})=>/* @__PURE__ */ a(`div`,{id:e,children:t})}),x=l({capabilities:{streaming:!1,merges:[`replace`]},Placeholder:({src:e,children:t})=>e?/* @__PURE__ */ a(i,{children:r(`<esi:include src="${s(e)}" />`)}):/* @__PURE__ */ a(i,{children:t}),Patch:({id:e,children:t})=>/* @__PURE__ */ o(i,{children:[r(`<esi:inline name="${s(e)}" fetchable="yes">`),t,r(`</esi:inline>`)]}),Frame:({children:e})=>/* @__PURE__ */ a(i,{children:e})});export{x as EsiAdapter,b as HtmxAdapter,f as NATIVE_POLYFILL,v as NativeAdapter,d as TurboAdapter,h as WebPlatformAdapter,l as createAdapter,m as nativePolyfillHash,_ as withPolyfill};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Awaitable, JSX } from "@vincle/core";
|
|
2
|
+
//#region src/components/assets.d.ts
|
|
3
|
+
interface BaseAssetProps {
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
interface StyleProps extends BaseAssetProps {
|
|
7
|
+
media?: string;
|
|
8
|
+
children: string | (() => Awaitable<string>);
|
|
9
|
+
}
|
|
10
|
+
interface ScriptProps extends BaseAssetProps {
|
|
11
|
+
src?: string;
|
|
12
|
+
module?: boolean;
|
|
13
|
+
defer?: boolean;
|
|
14
|
+
children?: string | (() => Awaitable<string>);
|
|
15
|
+
}
|
|
16
|
+
export declare function Style(props: StyleProps): JSX.Element | null;
|
|
17
|
+
export declare function Script(props: ScriptProps): JSX.Element | null;
|
|
18
|
+
//#endregion
|
|
19
|
+
export type { ScriptProps, StyleProps };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{l as e,t,u as n}from"../context-DlzTTBsQ.js";import{Scope as r}from"@vincle/core";import{jsx as i}from"@vincle/core/jsx-runtime";const a=e=>typeof e==`function`?e():e;function o(o){let{assets:s}=r.get(t),{name:c,media:l,children:u}=o,d={};return l!=null&&(d.media=l),n(s,c,{type:`style`,content:u,attrs:d}),e(s,c)?/* @__PURE__ */ i(`style`,{"data-name":c,...d,children:a(u)}):null}function s(o){let{assets:s}=r.get(t),{name:c,src:l,module:u,defer:d,children:f}=o,p={};l!=null&&(p.src=l),u&&(p.type=`module`),d&&(p.defer=!0);let m=f??``;return n(s,c,{type:`script`,content:m,attrs:p}),e(s,c)?/* @__PURE__ */ i(`script`,{"data-name":c,...p,children:a(m)}):null}export{s as Script,o as Style};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{c as e,f as t,h as n,i as r,m as i,o as a,t as o,v as s}from"./utils-DKRixAvn.js";import{Scope as c,renderToString as l}from"@vincle/core";function u(){return{entries:/* @__PURE__ */ new Map,emitted:/* @__PURE__ */ new Set,suppressed:!1}}function d(){return{entries:/* @__PURE__ */ new Map,emitted:/* @__PURE__ */ new Set,suppressed:!0}}function f(e,t,n){if(e.entries.has(t)){if(process.env.NODE_ENV!==`production`){let r=e.entries.get(t);(r.type!==n.type||JSON.stringify(r.attrs)!==JSON.stringify(n.attrs))&&console.warn(`[vincle/flow] Asset "${t}" is declared multiple times with different attributes. The first declaration wins.`)}return}e.entries.set(t,n)}function p(e,t){return e.suppressed||e.emitted.has(t)?!1:(e.emitted.add(t),!0)}function m(a){let c=/* @__PURE__ */ new Map,l=a.adapter?.capabilities.merges??[];return{register(u,d){if(o(u,`Defer`),e(d.timeout,`<Defer target="${u}">`),!a.adapter)throw s(`${r} <Defer target="${u}">: Defer requires an adapter: without one there is no placeholder to render and no patch to emit. Pass { adapter: ... } to renderToStatic, or render through renderToStream/serve with an adapter (TurboAdapter, NativeAdapter, HtmxAdapter, WebPlatformAdapter, EsiAdapter).`,n);if(!l.includes(d.merge)){let e=l.length>0?`it supports: ${l.join(`, `)}`:`it supports no merges (static output only)`;throw s(`${r} <Defer target="${u}" merge="${d.merge}">: merge="${d.merge}" is not supported by this adapter: ${e}. Pick one of those, or use an adapter that supports "${d.merge}".`,i)}if(c.has(u))throw s(`${r} <Defer target="${u}">: target is already registered: a target is registered once per render. Two <Defer> on the same target would emit two placeholders with the same DOM id, and only the second fragment would be patched. Give the second fragment a different target, or omit target to get a generated one.`,t);c.set(u,d)},get(e){return c.get(e)},outstanding(e){let t=[];for(let[n,r]of c)e.has(n)||t.push([n,r]);return t},hasOutstanding(e){for(let t of c.keys())if(!e.has(t))return!0;return!1},get size(){return c.size},clear(){c.clear()}}}const h=c.key(`@vincle/flow:flow`);function g(e,t,i){let{config:a}=c.get(h);if(!a.adapter)throw s(`${r} renderPlaceholder("${e}"): no adapter configured: a placeholder needs an adapter to emit its deferred-fragment markup. Pass { adapter: ... } to renderToStatic, or render through renderToStream() with an adapter.`,n);let o=i??(a.mode===`static`?a.generatePath(e):null);return a.adapter.Placeholder({id:e,src:o,children:t??null})}const _=()=>({names:/* @__PURE__ */ new Set,mark:void 0});function v(e,t){return e.mark??=`vincle-defer-${crypto.randomUUID()}`,[`<!--${e.mark}:${t}-->`,`<!--/${e.mark}:${t}-->`]}async function y(e){let t=await l(e);if(!t.includes(`<!--vincle-defer-`))return t;let{slots:n}=c.get(h),r=`<!--${n.mark}:`,i=0;for(;;){let e=t.indexOf(r,i);if(e===-1)return t;let a=t.indexOf(`-->`,e),o=t.slice(e+r.length,a),s=`<!--/${n.mark}:${o}-->`,c=t.indexOf(s,a);if(c===-1)return t;let l=n.names.has(o)?``:t.slice(a+3,c);t=t.slice(0,e)+l+t.slice(c+s.length),i=e}}function b(e){a(e);let t=0,n=m(e),r=u();c.set(h,{config:e,fragments:n,assets:r,slots:_(),nextId:()=>`${e.idPrefix??`fragment-`}${++t}`,registerFragment(e,t){n.register(e,t)}})}function x(){let e=c.get(h);c.set(h,{...e,assets:u(),slots:_()})}function S(){let e=c.get(h);c.set(h,{...e,assets:d()})}function C(e,t){return c.with(async function(){return b(t),e(c.get(h))})}export{y as a,C as c,v as i,p as l,b as n,g as o,x as r,S as s,h as t,f as u};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { S as OnError, m as FlowConfig, p as DeferContent, y as MergeType } from "./index-M7xu3JXg.js";
|
|
2
|
+
import { JSX, ScopeKey, renderToString } from "@vincle/core";
|
|
3
|
+
//#region src/assets.d.ts
|
|
4
|
+
type AssetType = "style" | "script";
|
|
5
|
+
type AssetContent = string | (() => string | Promise<string>);
|
|
6
|
+
type AssetEntry = {
|
|
7
|
+
type: AssetType;
|
|
8
|
+
content: AssetContent;
|
|
9
|
+
attrs: Record<string, string | boolean>;
|
|
10
|
+
};
|
|
11
|
+
type AssetState = {
|
|
12
|
+
entries: Map<string, AssetEntry>;
|
|
13
|
+
emitted: Set<string>;
|
|
14
|
+
/**
|
|
15
|
+
* Suppress every emission, whatever the name. Used for standalone fragment
|
|
16
|
+
* files, which carry no assets of their own: see `renderToStatic`.
|
|
17
|
+
*/
|
|
18
|
+
suppressed: boolean;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/fragment-store.d.ts
|
|
22
|
+
/**
|
|
23
|
+
* A unit of deferred content, keyed by its target DOM `id`. The renderer
|
|
24
|
+
* decides at drain time whether `content` is a one-shot patch or a live
|
|
25
|
+
* stream: see `flushFragments`.
|
|
26
|
+
*/
|
|
27
|
+
type FragmentEntry = {
|
|
28
|
+
content: DeferContent;
|
|
29
|
+
/** Tree context at the point where the fragment was registered. */
|
|
30
|
+
treeScope?: <T>(render: () => T) => T;
|
|
31
|
+
merge: MergeType;
|
|
32
|
+
/** Per-fragment render timeout in ms. Falls back to FlowOptions.defaultTimeout. */
|
|
33
|
+
timeout?: number;
|
|
34
|
+
/** Per-fragment error handler, overriding FlowOptions.onError. */
|
|
35
|
+
onError?: OnError;
|
|
36
|
+
/** Initial fallback content rendered in the placeholder element. */
|
|
37
|
+
fallback?: JSX.Element;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Internal storage for registered fragment entries.
|
|
41
|
+
*
|
|
42
|
+
* Hides the `Map` implementation behind a narrow interface so callers don't
|
|
43
|
+
* depend on the storage primitive. Iteration logic (filtering processed ids)
|
|
44
|
+
* lives here rather than in `flushFragments`.
|
|
45
|
+
*/
|
|
46
|
+
type FragmentStore = {
|
|
47
|
+
/** Register an entry for `id`. One target is registered once per render: a second registration throws. Validates id, timeout, adapter and merge support. */
|
|
48
|
+
register(id: string, entry: FragmentEntry): void;
|
|
49
|
+
/** Retrieve a registered entry by id. */
|
|
50
|
+
get(id: string): FragmentEntry | undefined;
|
|
51
|
+
/** Entries whose id is not in `processed`. */
|
|
52
|
+
outstanding(processed: Set<string>): Array<[string, FragmentEntry]>;
|
|
53
|
+
/** True when at least one entry is not in `processed`. */
|
|
54
|
+
hasOutstanding(processed: Set<string>): boolean;
|
|
55
|
+
/** Total registered entries (including processed ones). */
|
|
56
|
+
readonly size: number;
|
|
57
|
+
/** Purge all entries to eagerly release closures and references. */
|
|
58
|
+
clear(): void;
|
|
59
|
+
};
|
|
60
|
+
//#endregion
|
|
61
|
+
//#region src/context.d.ts
|
|
62
|
+
export interface FlowContext {
|
|
63
|
+
config: FlowConfig;
|
|
64
|
+
/** Internal fragment-content store. */
|
|
65
|
+
fragments: FragmentStore;
|
|
66
|
+
/** Named asset state for `<Style name>` / `<Script name>` dedup. */
|
|
67
|
+
assets: AssetState;
|
|
68
|
+
/** The page's `<Slot>` names, and the mark around a placeholder one may replace. */
|
|
69
|
+
slots: SlotState;
|
|
70
|
+
nextId: () => string;
|
|
71
|
+
/**
|
|
72
|
+
* Register fragment content to render into the DOM element with this `id`.
|
|
73
|
+
* Validates the id and that `merge` is supported by the active adapter.
|
|
74
|
+
* Throws when the id is already registered: one target per render.
|
|
75
|
+
*/
|
|
76
|
+
registerFragment(id: string, entry: FragmentEntry): void;
|
|
77
|
+
}
|
|
78
|
+
export declare const Flow: ScopeKey<FlowContext>;
|
|
79
|
+
/**
|
|
80
|
+
* The single adapter negotiation for deferred-fragment placeholders. Defer,
|
|
81
|
+
* Slot and Include all end in `adapter.Placeholder({ id, src, children })`;
|
|
82
|
+
* only their *policies* differ (what to register, which URL to allow, whether
|
|
83
|
+
* a missing adapter is an error). Everything about the negotiation lives here:
|
|
84
|
+
*
|
|
85
|
+
* - a missing adapter is an error (callers that want to tolerate it, e.g.
|
|
86
|
+
* Defer in pure-static mode, check `config.adapter` themselves first);
|
|
87
|
+
* - in static mode `src` defaults to `generatePath(id)`; callers that carry
|
|
88
|
+
* their own URL (Include) pass it explicitly;
|
|
89
|
+
* - children are normalized to `null`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function renderPlaceholder(id: string, children?: JSX.Element | null, src?: string): JSX.Element;
|
|
92
|
+
/**
|
|
93
|
+
* A `<Defer>` whose target names a `<Slot>` has no placeholder of its own: the
|
|
94
|
+
* slot is it. One rendered earlier is known at once. One rendered later is not,
|
|
95
|
+
* so the `<Defer>` marks its placeholder, and `renderFlow` drops it once the page
|
|
96
|
+
* is rendered if a slot of that name turned up.
|
|
97
|
+
*/
|
|
98
|
+
export interface SlotState {
|
|
99
|
+
readonly names: Set<string>;
|
|
100
|
+
/** Random per page, so no markup the author writes can match it. */
|
|
101
|
+
mark: string | undefined;
|
|
102
|
+
}
|
|
103
|
+
/** The comment pair around the placeholder of a `<Defer>` whose slot may come later. */
|
|
104
|
+
export declare function pendingMarks(slots: SlotState, id: string): [string, string];
|
|
105
|
+
/**
|
|
106
|
+
* Render a tree that may hold flow components, and settle every marked
|
|
107
|
+
* placeholder: dropped when a slot of its id was rendered, unwrapped otherwise.
|
|
108
|
+
* Ids are unique per render, so each open mark has one close mark.
|
|
109
|
+
*/
|
|
110
|
+
export declare function renderFlow(node: Parameters<typeof renderToString>[0]): Promise<string>;
|
|
111
|
+
export declare function initFlow(config: FlowConfig): void;
|
|
112
|
+
/**
|
|
113
|
+
* Give the current scope its own asset state and slot names: a page boundary.
|
|
114
|
+
*
|
|
115
|
+
* A new context object, not a mutation of the existing one: two `renderPage`
|
|
116
|
+
* calls awaited together each get their own scope, and a shared object would
|
|
117
|
+
* have them race on `.assets` and `.slots`.
|
|
118
|
+
*/
|
|
119
|
+
export declare function initFlowAssets(): void;
|
|
120
|
+
/**
|
|
121
|
+
* Give the current scope an asset state that emits nothing: used for standalone
|
|
122
|
+
* fragment files, whose assets belong to the shell that includes them.
|
|
123
|
+
*/
|
|
124
|
+
export declare function suppressFlowAssets(): void;
|
|
125
|
+
export declare function withFlow<T>(handler: (ctx: FlowContext) => T, config: FlowConfig): Promise<T>;
|
|
126
|
+
//#endregion
|
|
127
|
+
export type { FlowConfig };
|
package/dist/context.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as e,c as t,i as n,n as r,o as i,r as a,s as o,t as s}from"./context-DlzTTBsQ.js";export{s as Flow,r as initFlow,a as initFlowAssets,n as pendingMarks,e as renderFlow,i as renderPlaceholder,o as suppressFlowAssets,t as withFlow};
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { _ as FlowOptions, b as Negotiate, w as StreamingAdapter, x as Negotiation } from "./index-M7xu3JXg.js";
|
|
2
|
+
import { JSX } from "@vincle/core";
|
|
3
|
+
//#region src/http.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Create a `Response` from a page component and an adapter.
|
|
6
|
+
*
|
|
7
|
+
* Negotiation is opt-in and orthogonal to the adapter: pass `negotiate` (e.g.
|
|
8
|
+
* `negotiateHtmx`, or your own) to extract per-request hints and headers.
|
|
9
|
+
* Without it, the full page is rendered: the client library extracts its own
|
|
10
|
+
* target. `mode: "fragment"` (shell suppressed) is an explicit opt-in; it only
|
|
11
|
+
* produces output when the targeted content is expressed as `<Defer>` fragments.
|
|
12
|
+
*/
|
|
13
|
+
export declare function serve(req: Request, page: (negotiation: Negotiation) => JSX.Element, adapter: StreamingAdapter, opts?: FlowOptions & ResponseInit & {
|
|
14
|
+
negotiate?: Negotiate;
|
|
15
|
+
mode?: "full" | "fragment";
|
|
16
|
+
}): Promise<Response>;
|
|
17
|
+
/**
|
|
18
|
+
* HTMX negotiation: read `HX-Target`, and `Vary` on it so shared caches never
|
|
19
|
+
* serve a fragment response to a full-page navigation.
|
|
20
|
+
*/
|
|
21
|
+
export declare function negotiateHtmx(req: Request): Negotiation;
|
|
22
|
+
//#endregion
|
|
23
|
+
export type { Negotiate, Negotiation };
|
package/dist/http.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{n as e}from"./render-zCgGmdz0.js";function t(e,t){let n=/* @__PURE__ */ new Set,r=[],i=e=>{if(e)for(let t of e.split(`,`)){let e=t.trim(),i=e.toLowerCase();e&&!n.has(i)&&(n.add(i),r.push(e))}};i(e.get(`vary`)),i(t),e.set(`vary`,r.join(`, `))}function n(e,n,r){let i=new Headers(e);for(let e of[n,r])for(let[n,r]of new Headers(e??{}))n===`vary`?t(i,r):i.set(n,r);return i}async function r(t,r,i,a){let o=a?.negotiate?.(t)??{},s=e(()=>r(o),i,{...a,mode:a?.mode??o.mode}).pipeThrough(new TextEncoderStream),c=n({"content-type":`text/html; charset=utf-8`},a?.headers,o.headers);return new Response(s,{...a,headers:c})}function i(e){return{headers:{Vary:`HX-Target`},target:e.headers.get(`HX-Target`)??void 0}}export{i as negotiateHtmx,r as serve};
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Awaitable, JSX, RawString } from "@vincle/core";
|
|
2
|
+
//#region src/types.d.ts
|
|
3
|
+
declare const ALL_MERGES: readonly ["replace", "append", "prepend", "before", "after", "morph"];
|
|
4
|
+
/** Derived from `ALL_MERGES`, the single declaration `assertAdapter` also validates: runtime and type can't drift. */
|
|
5
|
+
type MergeType = (typeof ALL_MERGES)[number];
|
|
6
|
+
interface AdapterCapabilities {
|
|
7
|
+
streaming: boolean;
|
|
8
|
+
merges: readonly MergeType[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Content renderable as a deferred fragment:
|
|
12
|
+
* - `JSX.Element | string`: one-shot sync/async render; strings are escaped
|
|
13
|
+
* text. For real HTML, wrap with `raw()`.
|
|
14
|
+
* - `(signal) => JSX.Element | string`: lazy factory; `signal` = request abort + fragment timeout
|
|
15
|
+
* - `AsyncIterable<JSX.Element | string>`: streaming; each string is escaped
|
|
16
|
+
*/
|
|
17
|
+
type DeferValue = Awaitable<JSX.Element> | string;
|
|
18
|
+
type DeferStream = AsyncIterable<JSX.Element | string>;
|
|
19
|
+
type DeferContent = DeferValue | ((signal: AbortSignal) => DeferValue | DeferStream) | DeferStream | ((signal: AbortSignal) => DeferStream);
|
|
20
|
+
interface Shell {
|
|
21
|
+
type: "shell";
|
|
22
|
+
html: string;
|
|
23
|
+
}
|
|
24
|
+
interface Fragment {
|
|
25
|
+
type: "fragment";
|
|
26
|
+
id: string;
|
|
27
|
+
html: string;
|
|
28
|
+
merge: MergeType;
|
|
29
|
+
}
|
|
30
|
+
type FlowEvent = Shell | Fragment | {
|
|
31
|
+
type: "close";
|
|
32
|
+
html: string;
|
|
33
|
+
};
|
|
34
|
+
type FlowErrorInfo = {
|
|
35
|
+
id: string;
|
|
36
|
+
kind: "fragment" | "stream";
|
|
37
|
+
};
|
|
38
|
+
type OnError = (error: unknown, info: FlowErrorInfo) => JSX.Element | void;
|
|
39
|
+
interface FlowOptions {
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
onError?: OnError;
|
|
42
|
+
defaultTimeout?: number;
|
|
43
|
+
}
|
|
44
|
+
type FlowConfig = {
|
|
45
|
+
adapter: Adapter;
|
|
46
|
+
mode: "streaming";
|
|
47
|
+
generatePath?: never;
|
|
48
|
+
idPrefix?: string;
|
|
49
|
+
} | {
|
|
50
|
+
adapter?: Adapter;
|
|
51
|
+
mode: "static";
|
|
52
|
+
generatePath: (id: string) => string;
|
|
53
|
+
idPrefix?: string;
|
|
54
|
+
};
|
|
55
|
+
interface Negotiation {
|
|
56
|
+
headers?: HeadersInit;
|
|
57
|
+
mode?: "full" | "fragment";
|
|
58
|
+
target?: string;
|
|
59
|
+
}
|
|
60
|
+
type Negotiate = (req: Request) => Negotiation;
|
|
61
|
+
type StreamingAdapter = Adapter & {
|
|
62
|
+
capabilities: {
|
|
63
|
+
streaming: true;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/adapters/shared.d.ts
|
|
68
|
+
type Child = JSX.Element | RawString | string | null;
|
|
69
|
+
/**
|
|
70
|
+
* What `transformShell` sees of the flow state, not the full `FlowContext`.
|
|
71
|
+
* A shell transform has exactly one legitimate reason to look at flow state:
|
|
72
|
+
* deciding whether fragments are pending. The full context also carries
|
|
73
|
+
* `assets`, `nextId`, and `registerFragment`/the rest of `FragmentStore`,
|
|
74
|
+
* mutation hooks a *shell transform* has no business reaching, so it isn't
|
|
75
|
+
* handed the context that owns them.
|
|
76
|
+
*/
|
|
77
|
+
interface ShellContext {
|
|
78
|
+
/** Pending `<Defer>`/`<Slot>` fragments, after the shell has rendered. */
|
|
79
|
+
readonly fragments: {
|
|
80
|
+
readonly size: number;
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
type Adapter = {
|
|
84
|
+
Placeholder(props: {
|
|
85
|
+
id: string;
|
|
86
|
+
src: string | null;
|
|
87
|
+
children: Child;
|
|
88
|
+
}): JSX.Element;
|
|
89
|
+
Patch(props: {
|
|
90
|
+
id: string;
|
|
91
|
+
children: Child;
|
|
92
|
+
merge: MergeType;
|
|
93
|
+
}): JSX.Element;
|
|
94
|
+
Frame(props: {
|
|
95
|
+
id: string;
|
|
96
|
+
children: Child;
|
|
97
|
+
}): JSX.Element;
|
|
98
|
+
capabilities: AdapterCapabilities;
|
|
99
|
+
/**
|
|
100
|
+
* Post-process the shell before it enters the stream. Receives a
|
|
101
|
+
* `ShellContext`, so an adapter can decide based on the real fragment
|
|
102
|
+
* count: e.g. inject a client runtime only when `ctx.fragments.size >
|
|
103
|
+
* 0` (fragments exist). Always called inside the flow scope, after the
|
|
104
|
+
* shell node renders.
|
|
105
|
+
*/
|
|
106
|
+
transformShell?(shell: string, ctx: ShellContext): string;
|
|
107
|
+
};
|
|
108
|
+
declare const DEFAULT_CAPABILITIES: {
|
|
109
|
+
streaming: true;
|
|
110
|
+
merges: typeof ALL_MERGES;
|
|
111
|
+
};
|
|
112
|
+
type AdapterSpec<C extends AdapterCapabilities> = Omit<Adapter, "capabilities"> & {
|
|
113
|
+
capabilities?: C;
|
|
114
|
+
};
|
|
115
|
+
declare function createAdapter<const C extends AdapterCapabilities = typeof DEFAULT_CAPABILITIES>(spec: AdapterSpec<C>): Adapter & {
|
|
116
|
+
capabilities: C;
|
|
117
|
+
};
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/adapters/turbo.d.ts
|
|
120
|
+
declare const TurboAdapter: Adapter & {
|
|
121
|
+
capabilities: {
|
|
122
|
+
streaming: true;
|
|
123
|
+
merges: typeof ALL_MERGES;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/adapters/native-polyfill.d.ts
|
|
128
|
+
declare const NATIVE_POLYFILL = "(function(){\nvar ADJ={append:\"beforeend\",prepend:\"afterbegin\",before:\"beforebegin\",after:\"afterend\"};\nfunction fill(name,frag){\nvar it=document.createNodeIterator(document.body||document.documentElement,128),nd,s=null,e=null;\nwhile((nd=it.nextNode())){if(!s&&nd.nodeValue==='?start name=\"'+name+'\"'){s=nd;continue;}if(s&&nd.nodeValue==='?end'){e=nd;break;}}\nif(!s)return;var c=s.nextSibling;while(c&&c!==e){var x=c.nextSibling;c.remove();c=x;}s.after(frag);\n}\nfunction run(t){\nvar name=t.getAttribute(\"for\");if(!name)return;\nvar src=t.getAttribute(\"data-src\");\nif(src!=null&&src!==\"\"){try{var p=new URL(src,document.baseURI||location.href).protocol;if(p!==\"http:\"&&p!==\"https:\"){t.remove();return;}}catch(e){t.remove();return;}fetch(src).then(function(r){return r.text();}).then(function(h){var x=document.createElement(\"template\");x.innerHTML=h;fill(name,x.content);});t.remove();return;}\nvar merge=t.getAttribute(\"data-merge\");\nif(merge&&merge!==\"replace\"){var el=document.getElementById(name);if(el)el.insertAdjacentHTML(ADJ[merge],t.innerHTML);t.remove();return;}\nfill(name,t.content.cloneNode(true));t.remove();\n}\nfunction scan(r){var ts=r.querySelectorAll?r.querySelectorAll(\"template[for]\"):[];for(var i=0;i<ts.length;i++)run(ts[i]);}\nscan(document);\nnew MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeName==='TEMPLATE'&&n.getAttribute&&n.getAttribute('for'))run(n);});});}).observe(document.documentElement,{childList:true,subtree:true});\n})()";
|
|
129
|
+
declare function nativePolyfillHash(): Promise<string>;
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/adapters/native.d.ts
|
|
132
|
+
/**
|
|
133
|
+
* WICG Declarative Partial Updates wire format: no polyfill, zero JS.
|
|
134
|
+
*
|
|
135
|
+
* `merges: ["replace"]` only: `Patch` can write `data-merge`, but nothing reads
|
|
136
|
+
* it without a polyfill, and declaring the others would accept a merge the
|
|
137
|
+
* spec silently ignores. `withPolyfill` is what makes them real, so it's the
|
|
138
|
+
* one that declares them.
|
|
139
|
+
*/
|
|
140
|
+
declare const WebPlatformAdapter: Adapter & {
|
|
141
|
+
capabilities: {
|
|
142
|
+
readonly streaming: true;
|
|
143
|
+
readonly merges: readonly ["replace"];
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* What the polyfill can express: `insertAdjacentHTML`'s four positions, plus
|
|
148
|
+
* `replace`. `morph` is out: diffing two DOM trees is orders of magnitude past
|
|
149
|
+
* this budget, so the adapter refuses it rather than degrading it to a replace.
|
|
150
|
+
*/
|
|
151
|
+
declare const POLYFILL_MERGES: readonly ["replace", "append", "prepend", "before", "after"];
|
|
152
|
+
/**
|
|
153
|
+
* Decorate any adapter with the ~550 B inline polyfill for the WICG
|
|
154
|
+
* Declarative Partial Updates API. The polyfill is injected into `<head>`
|
|
155
|
+
* only when fragments are present (`ctx.fragments.size > 0`).
|
|
156
|
+
*
|
|
157
|
+
* Useful when you want to use `WebPlatformAdapter` in browsers that do
|
|
158
|
+
* not yet support `<template for>` natively.
|
|
159
|
+
*/
|
|
160
|
+
declare function withPolyfill<T extends Adapter>(adapter: T): Omit<T, "capabilities"> & {
|
|
161
|
+
capabilities: {
|
|
162
|
+
streaming: T["capabilities"]["streaming"];
|
|
163
|
+
merges: typeof POLYFILL_MERGES;
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
/** Default Native adapter: WICG format + inline polyfill (~550 B). */
|
|
167
|
+
declare const NativeAdapter: Omit<Adapter & {
|
|
168
|
+
capabilities: {
|
|
169
|
+
readonly streaming: true;
|
|
170
|
+
readonly merges: readonly ["replace"];
|
|
171
|
+
};
|
|
172
|
+
}, "capabilities"> & {
|
|
173
|
+
capabilities: {
|
|
174
|
+
streaming: true;
|
|
175
|
+
merges: typeof POLYFILL_MERGES;
|
|
176
|
+
};
|
|
177
|
+
};
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/adapters/htmx.d.ts
|
|
180
|
+
declare const HtmxAdapter: Adapter & {
|
|
181
|
+
capabilities: {
|
|
182
|
+
streaming: true;
|
|
183
|
+
merges: typeof ALL_MERGES;
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/adapters/esi.d.ts
|
|
188
|
+
/**
|
|
189
|
+
* ESI is CDN-level: the `<esi:include>` tags are resolved by an edge proxy, so
|
|
190
|
+
* there is no live stream to a browser. `capabilities.streaming: false` says
|
|
191
|
+
* so: `renderToStream` refuses it, `renderToStatic` (with `emitFragments`)
|
|
192
|
+
* is the path for ESI output.
|
|
193
|
+
*/
|
|
194
|
+
declare const EsiAdapter: Adapter & {
|
|
195
|
+
capabilities: {
|
|
196
|
+
readonly streaming: false;
|
|
197
|
+
readonly merges: readonly ["replace"];
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
//#endregion
|
|
201
|
+
export { Shell as C, OnError as S, FlowOptions as _, withPolyfill as a, Negotiate as b, TurboAdapter as c, createAdapter as d, AdapterCapabilities as f, FlowEvent as g, FlowErrorInfo as h, WebPlatformAdapter as i, Adapter as l, FlowConfig as m, HtmxAdapter as n, NATIVE_POLYFILL as o, DeferContent as p, NativeAdapter as r, nativePolyfillHash as s, EsiAdapter as t, ShellContext as u, Fragment as v, StreamingAdapter as w, Negotiation as x, MergeType as y };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { C as Shell, S as OnError, _ as FlowOptions, b as Negotiate, f as AdapterCapabilities, g as FlowEvent, h as FlowErrorInfo, l as Adapter, m as FlowConfig, p as DeferContent, v as Fragment, w as StreamingAdapter, x as Negotiation, y as MergeType } from "./index-M7xu3JXg.js";
|
|
2
|
+
import { JSX, VNode } from "@vincle/core";
|
|
3
|
+
//#region src/components/Slot.d.ts
|
|
4
|
+
interface SlotProps {
|
|
5
|
+
name: string;
|
|
6
|
+
children?: JSX.Element;
|
|
7
|
+
}
|
|
8
|
+
export declare function Slot(props: SlotProps): JSX.Element;
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/components/Defer.d.ts
|
|
11
|
+
interface DeferProps {
|
|
12
|
+
target?: string;
|
|
13
|
+
children: DeferContent;
|
|
14
|
+
merge?: MergeType;
|
|
15
|
+
timeout?: number;
|
|
16
|
+
onError?: OnError;
|
|
17
|
+
fallback?: JSX.Element;
|
|
18
|
+
}
|
|
19
|
+
export declare function Defer(props: DeferProps): JSX.Element | null;
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/components/Include.d.ts
|
|
22
|
+
type SchemeOf<S extends string> = S extends `${infer Head}:${string}` ? Head extends `${string}${"/" | "?" | "#"}${string}` ? null : Head extends "" ? null : Head : null;
|
|
23
|
+
type FetchUrl<S extends string> = SchemeOf<S> extends null ? S : Lowercase<SchemeOf<S> & string> extends "http" | "https" ? S : {
|
|
24
|
+
__error: "Include needs an HTML URL: only http(s): or a relative path";
|
|
25
|
+
};
|
|
26
|
+
interface IncludeProps<S extends string = string> {
|
|
27
|
+
src: S & FetchUrl<S>;
|
|
28
|
+
fallback?: VNode;
|
|
29
|
+
}
|
|
30
|
+
export declare function Include<const S extends string>(props: IncludeProps<S>): JSX.Element;
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/render.d.ts
|
|
33
|
+
/**
|
|
34
|
+
* Return a `ReadableStream<FlowEvent>` with proper backpressure and cancellation.
|
|
35
|
+
*/
|
|
36
|
+
export declare function renderToFlowEvents(node: () => JSX.Element, adapter: StreamingAdapter, opts?: FlowOptions & {
|
|
37
|
+
mode?: "full" | "fragment";
|
|
38
|
+
}): ReadableStream<FlowEvent>;
|
|
39
|
+
/**
|
|
40
|
+
* Render to a `ReadableStream<string>` of adapter-encoded HTML: the shell
|
|
41
|
+
* followed by each fragment as wire-format markup.
|
|
42
|
+
*
|
|
43
|
+
* The wire is the primitive's own serialization (`encodeWith`); an adapter
|
|
44
|
+
* only has to declare it can stream (`capabilities.streaming: true`). The
|
|
45
|
+
* runtime check keeps a plain-JS caller from running a static adapter (e.g.
|
|
46
|
+
* ESI) through the streaming path.
|
|
47
|
+
*/
|
|
48
|
+
export declare function renderToStream(node: () => JSX.Element, adapter: StreamingAdapter, opts?: FlowOptions & {
|
|
49
|
+
mode?: "full" | "fragment";
|
|
50
|
+
}): ReadableStream<string>;
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/static.d.ts
|
|
53
|
+
/**
|
|
54
|
+
* Static generation context for pure-static pages: no adapter, no fragment
|
|
55
|
+
* emission. When `renderToStatic` is called without options, the handler
|
|
56
|
+
* receives this type and cannot call `emitFragments`.
|
|
57
|
+
*/
|
|
58
|
+
interface PureStaticContext {
|
|
59
|
+
/** Render a page node, applying adapter.transformShell if present. */
|
|
60
|
+
renderPage(node: () => JSX.Element): Promise<string>;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Static generation context extended with fragment emission. When
|
|
64
|
+
* `renderToStatic` is called with `{ adapter }`, the handler receives
|
|
65
|
+
* this type and can call `emitFragments` after rendering all pages.
|
|
66
|
+
*/
|
|
67
|
+
interface StaticContext extends PureStaticContext {
|
|
68
|
+
/**
|
|
69
|
+
* Materialize every pending fragment as a standalone file. Each is
|
|
70
|
+
* wrapped with `adapter.Frame` and rendered, so `html` is ready to write as
|
|
71
|
+
* is; `url` is the path from `generatePath(id)`.
|
|
72
|
+
*/
|
|
73
|
+
emitFragments(cb: (id: string, url: string, html: string) => void | Promise<void>): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
interface StaticOptions {
|
|
76
|
+
/** Wire-format adapter for fragment framing. Required when using emitFragments. */
|
|
77
|
+
adapter: Adapter;
|
|
78
|
+
/** Fragment URL convention. Default: (id) => `/fragments/${id}.html`. */
|
|
79
|
+
generatePath?: (id: string) => string;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Static generation for pure-static sites (no lazy `<Defer>` content).
|
|
83
|
+
* Call without options: the handler receives a `PureStaticContext`
|
|
84
|
+
* without `emitFragments`.
|
|
85
|
+
*/
|
|
86
|
+
export declare function renderToStatic<T>(handler: (ctx: PureStaticContext) => T): Promise<T>;
|
|
87
|
+
/**
|
|
88
|
+
* Static generation with deferred fragments.
|
|
89
|
+
* Pass `{ adapter }`: the handler receives a `StaticContext` with
|
|
90
|
+
* `emitFragments` to materialize fragment files.
|
|
91
|
+
*/
|
|
92
|
+
export declare function renderToStatic<T>(handler: (ctx: StaticContext) => T, options: StaticOptions): Promise<T>;
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/fragment.d.ts
|
|
95
|
+
interface RenderFragmentOptions {
|
|
96
|
+
/** Wire-format adapter: the same one the site's full build uses. */
|
|
97
|
+
adapter: Adapter;
|
|
98
|
+
/**
|
|
99
|
+
* Fragment URL convention. Must match the full build's `generatePath`, or
|
|
100
|
+
* the file lands somewhere the shell's placeholder doesn't reference.
|
|
101
|
+
* Default: `(id) => \`/fragments/${id}.html\``.
|
|
102
|
+
*/
|
|
103
|
+
generatePath?: (id: string) => string;
|
|
104
|
+
/** Per-render timeout in ms, forwarded to the underlying `<Defer>`. */
|
|
105
|
+
timeout?: number;
|
|
106
|
+
onError?: OnError;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Render a single fragment on demand, outside a full site build: given
|
|
110
|
+
* fresh content, produces the exact bytes `emitFragments` would have written
|
|
111
|
+
* for this `id` during a full build, at the same URL. The page that includes
|
|
112
|
+
* it (via `Include`/`Frame`) never needs rebuilding.
|
|
113
|
+
*
|
|
114
|
+
* Framework- and host-agnostic: write `html` to `url` yourself, or hand both
|
|
115
|
+
* to whatever partial-update mechanism your host offers (Netlify Blobs,
|
|
116
|
+
* Vercel on-demand revalidation, a CDN purge + upload…).
|
|
117
|
+
*/
|
|
118
|
+
export declare function renderFragment(id: string, content: DeferContent, opts: RenderFragmentOptions): Promise<{
|
|
119
|
+
url: string;
|
|
120
|
+
html: string;
|
|
121
|
+
}>;
|
|
122
|
+
//#endregion
|
|
123
|
+
export type { AdapterCapabilities, DeferContent, DeferProps, FlowConfig, FlowErrorInfo, FlowEvent, FlowOptions, Fragment, IncludeProps, MergeType, Negotiate, Negotiation, OnError, PureStaticContext, RenderFragmentOptions, Shell, SlotProps, StaticContext, StaticOptions, StreamingAdapter };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as e,c as t,i as n,o as r,r as i,s as a,t as o}from"./context-DlzTTBsQ.js";import{a as s,d as c,g as l,h as u,i as d,l as f,p,s as m,v as h}from"./utils-DKRixAvn.js";import{n as g,r as _,t as v}from"./render-zCgGmdz0.js";import{Scope as y,raw as b,renderToString as x,snapshotContext as S}from"@vincle/core";import{Fragment as C,jsx as w,jsxs as T}from"@vincle/core/jsx-runtime";import{schemeOf as E}from"@vincle/core/html";function D(e){let{name:t,children:n}=e;return y.get(o).slots.names.add(t),r(t,n)}function O(e){let{registerFragment:t,nextId:i,slots:a}=y.get(o),{children:s,merge:c,timeout:l,onError:u,fallback:d}=e,f=e.target??i();if(t(f,{content:s,treeScope:S(),merge:c??`replace`,timeout:l,onError:u,fallback:d}),a.names.has(f))return null;let p=r(f,d);if(e.target===void 0)return p;let[m,h]=n(a,f);return/* @__PURE__ */ T(C,{children:[b(m),p,b(h)]})}function k(e){let t=E(e);return t===void 0||t===`http`||t===`https`}function A(e){let{nextId:t}=y.get(o);if(!k(e.src)){let t=E(e.src);throw h(`${d} <Include src="${e.src}">: forbidden scheme${t===void 0?``:` ${JSON.stringify(t)}`}: only http(s): or relative paths are allowed. Use an absolute http(s) URL or a path relative to the page, e.g. src="/fragments/hero.html".`,p)}return r(t(),e.fallback,e.src)}const j=e=>`/fragments/${e}.html`;function M(t,n,r){return{renderPage:r=>y.with(async()=>{i();let a=await e(r());return n?.transformShell?n.transformShell(a,t):a},y.snapshot()),emitFragments:async e=>{if(!n)throw h(`${d} emitFragments(): emitFragments requires an adapter: fragments cannot be framed into standalone files without one. Pass { adapter: ... } to renderToStatic. Example: renderToStatic(handler, { adapter: NativeAdapter })`,u);await y.with(async()=>{a(),await _(t,async t=>{if(t.type===`fragment`){let i=await x(n.Frame({id:t.id,children:b(t.html)}));await e(t.id,r(t.id),i)}})},y.snapshot()),t.fragments.clear()}}}async function N(e,n){let r=n?.adapter,i=n?.generatePath??j;if(s(r,`renderToStatic`),n?.generatePath!==void 0&&typeof n.generatePath!=`function`)throw h(`${d} renderToStatic: generatePath must be a function (id) => string, got ${f(n.generatePath)}. Example: (id) => \`/fragments/\${id}.html\`.`,c);return t(t=>e(M(t,r,i)),{adapter:r,mode:`static`,generatePath:i})}async function P(e,t,n){if(n.adapter===void 0)throw h(`${d} renderFragment: opts.adapter is required: pass the same adapter the site's full build uses (e.g. TurboAdapter).`,u);s(n.adapter,`renderFragment`),m(n,`renderFragment`);let r;if(await N(async i=>{await i.renderPage(()=>/* @__PURE__ */ w(O,{target:e,timeout:n.timeout,onError:n.onError,children:t})),await i.emitFragments((t,n,i)=>{t===e&&(r={url:n,html:i})})},{adapter:n.adapter,generatePath:n.generatePath}),!r)throw h(`${d} renderFragment("${e}"): produced no output for this id. The fragment was never registered or rendered: its content may have thrown or timed out (check the onError/console log), or the id differs from the <Defer target> that was rendered. Verify the id matches and that the content renders.`,l);return r}export{O as Defer,A as Include,D as Slot,P as renderFragment,v as renderToFlowEvents,N as renderToStatic,g as renderToStream};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import{a as e,c as t}from"./context-DlzTTBsQ.js";import{_ as n,a as r,i,s as a,v as o}from"./utils-DKRixAvn.js";import{raw as s,renderToString as c}from"@vincle/core";import{isAsyncIterable as l}from"@vincle/core/html";function u(e,t){let n=new AbortController,r=t?.signal?AbortSignal.any([t.signal,n.signal]):n.signal,i,a=[],o=()=>{for(let e of a.splice(0))e()};r.addEventListener(`abort`,o,{once:!0});let s=async e=>{if(!r.aborted){for(;(i.desiredSize??1)<=0&&!r.aborted;)await new Promise(e=>a.push(e));r.aborted||i.enqueue(e)}};return new ReadableStream({start(t){i=t,e(s,r).then(()=>{try{t.close()}catch{}},e=>{try{t.error(e)}catch{}})},pull(){o()},cancel(e){n.abort(e)}})}function d(e,t,n){let r=e;if(r==null)return{signal:t??new AbortController().signal,cleanup:()=>{}};let a=new AbortController,o=setTimeout(()=>a.abort(/* @__PURE__ */ Error(`${i} Fragment "${n}" timed out after ${r}ms: its content did not finish in time. Increase the timeout (the fragment's timeout prop or defaultTimeout), or make the content finish faster, e.g. by forwarding the abort signal to fetch().`)),r);return{signal:t?AbortSignal.any([t,a.signal]):a.signal,cleanup:()=>clearTimeout(o)}}const f=e=>typeof e==`function`;function p(e,t){try{let n=f(e.content)?e.content(t):e.content;return l(n)?{kind:`stream`,iterable:n}:{kind:`value`,value:n}}catch(e){return{kind:`sync-error`,error:e}}}async function m(t,n,r,i,a){console.error(`[vincle/flow] Error rendering ${i} "${r}"`,a);let o=n?.(a,{id:r,kind:i});o!=null&&await t({type:`fragment`,id:r,html:await e(o),merge:`replace`})}async function h(e,t,n,r,i){try{await m(e,t,n,r,i)}catch{throw i}}function g(e,t,n,r){return t.treeScope?t.treeScope(()=>_(e,t,n,r)):_(e,t,n,r)}function _(e,t,n,r){let i=t.onError??r.onError,{signal:a,cleanup:o}=d(t.timeout??r.defaultTimeout,r.signal,e),s=p(t,a);switch(s.kind){case`sync-error`:return o(),{isStreaming:!1,done:m(n,i,e,`fragment`,s.error)};case`stream`:return{isStreaming:!0,done:y(e,s.iterable,t.merge,n,i,a).finally(o)};case`value`:return{isStreaming:!1,done:v(e,s.value,t.merge,n,i,a,o)}}}async function v(t,n,r,i,a,o,s){let c;try{c=await e(n)}catch(e){await h(i,a,t,`fragment`,e);return}finally{s()}if(o.aborted){await h(i,a,t,`fragment`,o.reason);return}try{await i({type:`fragment`,id:t,html:c,merge:r})}catch(e){throw console.error(`[vincle/flow] Failed to emit fragment "${t}"`,e),e}}async function y(t,n,r,i,a,o){let s=n[Symbol.asyncIterator](),c=new Promise(e=>{let t=()=>e({done:!0,value:void 0});o.aborted?t():o.addEventListener(`abort`,t,{once:!0})}),l=!1;try{for(;;){let n=Promise.resolve(s.next());n.catch(()=>{});let o=await Promise.race([n,c]);if(o.done)break;let u;try{u=await e(o.value)}catch(e){await h(i,a,t,`stream`,e);continue}try{await i({type:`fragment`,id:t,html:u,merge:r})}catch(e){throw console.error(`[vincle/flow] Failed to emit stream chunk for "${t}"`,e),l=!0,e}}}catch(e){if(l)throw e;await h(i,a,t,`stream`,e)}finally{o.aborted&&await s.return?.(void 0).catch(()=>{})}}async function b(e){if(e.length===0)return;let t=await Promise.allSettled(e);for(let e of t)if(e.status===`rejected`)throw e.reason}function x(e){let t=Promise.resolve();return{emit:n=>{let r=t.then(()=>e(n));return t=r.catch(()=>{}),r},drain:()=>t}}async function S(e,t,n={}){let r=/* @__PURE__ */ new Set,i=[],a=x(t);for(;!n.signal?.aborted;){let t=e.fragments.outstanding(r);if(t.length>0){let e=[];for(let[o,s]of t){r.add(o);let{isStreaming:t,done:c}=g(o,s,a.emit,n);(t?i:e).push(c)}await b(e);continue}if(i.length===0||(await b(i),i.length=0,!e.fragments.hasOutstanding(r)))break}i.length>0&&await b(i),await a.drain()}function C(e){let t=e.trimEnd();if(!t.endsWith(`</html>`))return{body:e,closingTag:``};let n=t.slice(0,-7).trimEnd(),r=n.endsWith(`</body>`)?n.length-7:n.length;return{body:e.slice(0,r),closingTag:e.slice(r)}}function w(e){let t=!1;return new TransformStream({async transform(n,r){if(n.type===`shell`){t=!0,r.enqueue(n.html);return}let i=``;if(t&&(t=!1,i=`
|
|
2
|
+
`),n.type===`fragment`){let t=await c(e.Patch({id:n.id,children:s(n.html),merge:n.merge}));r.enqueue(i+t+`
|
|
3
|
+
`)}else r.enqueue(i+n.html+`
|
|
4
|
+
`)},flush(e){t&&e.enqueue(`
|
|
5
|
+
`)}})}async function T(t,n,r){let{body:i,closingTag:a}=C(await e(t()));return{shellBody:n.transformShell?n.transformShell(i,r):i,closingTag:a}}async function E(e,n,r,i,a){await t(async t=>{let{fragments:o}=t;try{if(n.aborted)return;let{shellBody:s,closingTag:c}=await T(r,i,t);a.mode!==`fragment`&&s!==``&&await e({type:`shell`,html:s}),await S({fragments:o},e,{...a,signal:n}),a.mode!==`fragment`&&c&&await e({type:`close`,html:c})}finally{o.clear()}},{adapter:i,mode:`streaming`})}function D(e,t,n){a(n,e),r(t,e)}function O(e,t,n={}){return D(`renderToFlowEvents`,t,n),u((r,i)=>E(r,i,e,t,n),{signal:n.signal})}function k(e,t,r){if(D(`renderToStream`,t,r),t.capabilities.streaming!==!0)throw o(`[vincle/flow] renderToStream(): this adapter does not stream: capabilities.streaming is false, so it can only produce static output. Use renderToStatic() with this adapter, or pass a streaming adapter (TurboAdapter, NativeAdapter, HtmxAdapter, WebPlatformAdapter).`,n);return O(e,t,r).pipeThrough(w(t))}export{k as n,S as r,O as t};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
const e=`ERR_VINCLE_FLOW_CONFIG`,t=`ERR_VINCLE_FLOW_NO_ADAPTER`,n=`ERR_VINCLE_FLOW_MERGE_UNSUPPORTED`,r=`ERR_VINCLE_FLOW_NO_FRAGMENT`,i=`ERR_VINCLE_FLOW_NO_STREAMING`,a=`ERR_VINCLE_FLOW_FORBIDDEN_SCHEME`,o=`ERR_VINCLE_FLOW_DUP_FRAGMENT`;function s(e,t){let n=Error(e);return n.code=t,Error.captureStackTrace?.(n,s),n}const c=[`replace`,`append`,`prepend`,`before`,`after`,`morph`],l=`[vincle/flow]`;function u(e){if(e===null)return`null`;if(typeof e==`string`)return JSON.stringify(e.length>80?e.slice(0,80)+`…`:e);if(typeof e==`number`||typeof e==`bigint`||typeof e==`boolean`)return String(e);if(typeof e==`function`)return`function ${e.name||`<anonymous>`}`;if(e instanceof Error)return`${e.name}: ${e.message}`;try{let t=JSON.stringify(e);return t===void 0?String(e):t.length>100?t.slice(0,100)+`…`:t}catch{return String(e)}}function d(e){return e instanceof AbortSignal||typeof e==`object`&&!!e&&typeof e.aborted==`boolean`&&typeof e.addEventListener==`function`&&typeof e.onabort==`object`}function f(t,n){if(t!=null){if(typeof t!=`object`)throw s(`${l} ${n}: options must be an object, got ${u(t)}. Example: { defaultTimeout: 5000, onError, signal }`,e);if(t.defaultTimeout!==void 0&&(!Number.isFinite(t.defaultTimeout)||t.defaultTimeout<0))throw s(`${l} ${n}: defaultTimeout must be a number of milliseconds >= 0, got ${u(t.defaultTimeout)}. Pass e.g. defaultTimeout: 5000, or omit it for no limit.`,e);if(t.onError!==void 0&&typeof t.onError!=`function`)throw s(`${l} ${n}: onError must be a function (error, { id, kind }) => JSX.Element | void, got ${u(t.onError)}. Omit it to log errors to the console instead.`,e);if(t.signal!==void 0&&!d(t.signal))throw s(`${l} ${n}: signal must be an AbortSignal, got ${u(t.signal)}. Example: const controller = new AbortController(); … { signal: controller.signal }`,e)}}const p=[`Placeholder`,`Patch`,`Frame`];function m(t,n){if(t===void 0)return;if(typeof t!=`object`||!t)throw s(`${l} ${n}: adapter must be an adapter object, got ${u(t)}. Use a built-in (TurboAdapter, NativeAdapter, HtmxAdapter, WebPlatformAdapter, EsiAdapter) or createAdapter().`,e);let r=t,i=p.filter(e=>typeof r[e]!=`function`);if(i.length>0)throw s(`${l} ${n}: the adapter is missing ${i.map(e=>`"${e}"`).join(`, `)}: an adapter needs Placeholder, Patch and Frame. Use createAdapter() or a built-in adapter.`,e);let a=r.capabilities;if(typeof a!=`object`||!a)throw s(`${l} ${n}: the adapter is missing capabilities: declare { streaming: boolean, merges: MergeType[] } (see createAdapter()).`,e);if(typeof a.streaming!=`boolean`)throw s(`${l} ${n}: adapter.capabilities.streaming must be a boolean, got ${u(a.streaming)}.`,e);let o=a.merges;if(!(Array.isArray(o)&&o.every(e=>typeof e==`string`&&c.includes(e))))throw s(`${l} ${n}: adapter.capabilities.merges must be an array of merge types (${c.join(`, `)}), got ${u(o)}.`,e);if(r.transformShell!==void 0&&typeof r.transformShell!=`function`)throw s(`${l} ${n}: adapter.transformShell must be a function (shell, ctx) => string, got ${u(r.transformShell)}.`,e)}function h(t){if(typeof t!=`object`||!t)throw s(`${l} FlowConfig: the flow config must be an object, got ${u(t)}. Example: { adapter: TurboAdapter, mode: "streaming" }`,e);let n=t,r=n.mode;if(r!==`streaming`&&r!==`static`)throw s(`${l} FlowConfig.mode must be "streaming" or "static", got ${u(r)}.`,e);let i=n.idPrefix;if(i!==void 0&&typeof i!=`string`)throw s(`${l} FlowConfig.idPrefix must be a string, got ${u(i)}. Default: "fragment-".`,e);let a=n.generatePath;if(a!==void 0&&typeof a!=`function`)throw s(`${l} FlowConfig.generatePath must be a function (id) => string, got ${u(a)}. Example: (id) => \`/fragments/\${id}.html\`.`,e);if(r===`static`){if(a===void 0)throw s(`${l} FlowConfig: static mode requires generatePath: (id) => string. Example: generatePath: (id) => \`/fragments/\${id}.html\`.`,e)}else if(a!==void 0)throw s(`${l} FlowConfig: generatePath is only used in static mode (renderToStatic): remove it from the streaming config, or drop mode: "streaming" if you meant static generation.`,e);m(n.adapter,`FlowConfig`)}function g(t,n){if(t!==void 0&&(!Number.isFinite(t)||t<0))throw s(`${l} ${n}: timeout must be a number of milliseconds >= 0, got ${u(t)}. Pass e.g. timeout={5000}, or omit it to use defaultTimeout.`,e)}const _=/^[a-zA-Z][a-zA-Z0-9_-]*$/;function v(e,t){if(!_.test(e))throw s(`${l} ${t}: "${e}" is not a valid fragment id: ids must start with a letter and contain only letters, digits, hyphens and underscores (e.g. "price-aapl"). Fragment ids become DOM ids and URL segments.`,`ERR_VINCLE_FLOW_FRAGMENT_ID`)}function y(e,t){let n=e.match(/<\/head\s*>/i);if(n){let r=n.index;return e.slice(0,r)+t+e.slice(r)}let r=`<head>${t}</head>`,i=e.match(/<html\b[^>]*>/i);if(i)return b(e,i.index+i[0].length,r);let a=e.match(/^\s*<!doctype\b[^>]*>/i);return a?b(e,a[0].length,r):r+e}const b=(e,t,n)=>e.slice(0,t)+n+e.slice(t);function x(...e){let t=e.filter(Boolean);return(e,n)=>t.reduce((e,t)=>t(e,n),e)}export{i as _,m as a,g as c,e as d,o as f,r as g,t as h,l as i,u as l,n as m,x as n,h as o,a as p,y as r,f as s,v as t,c as u,s as v};
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { u as ShellContext } from "./index-M7xu3JXg.js";
|
|
2
|
+
//#region src/utils.d.ts
|
|
3
|
+
export declare function assertFragmentId(id: string, label: string): void;
|
|
4
|
+
/**
|
|
5
|
+
* Injects `content` immediately before `</head>`, opening a `<head>` if the
|
|
6
|
+
* shell has none.
|
|
7
|
+
*
|
|
8
|
+
* Four placements, in decreasing order of what the shell tells us:
|
|
9
|
+
*
|
|
10
|
+
* 1. before `</head>`: the shell said where its head is;
|
|
11
|
+
* 2. just after `<html …>`: wherever it sits, not only at offset 0;
|
|
12
|
+
* 3. just after a leading doctype: a fragment shell that still declares one;
|
|
13
|
+
* 4. at the front: a bare fragment, nothing to preserve.
|
|
14
|
+
*
|
|
15
|
+
* Case 3 is why a leading doctype is matched on its own rather than folded
|
|
16
|
+
* into case 2. Nothing may precede a doctype; a browser that sees markup
|
|
17
|
+
* first ignores the doctype and renders the page in quirks mode.
|
|
18
|
+
*/
|
|
19
|
+
export declare function injectIntoHead(html: string, content: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Compose shell transforms left-to-right into a single `transformShell`. Each
|
|
22
|
+
* transform receives the output of the previous one; falsy entries are skipped,
|
|
23
|
+
* so an adapter's own (possibly `undefined`) transform can be spliced in:
|
|
24
|
+
*
|
|
25
|
+
* Each transform receives the active `ShellContext` as its second argument, so a
|
|
26
|
+
* fragment-aware transform (e.g. NativeAdapter's polyfill injection) still sees
|
|
27
|
+
* the pending fragment count; transforms that don't need it simply ignore it.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* createAdapter({
|
|
31
|
+
* ...NativeAdapter,
|
|
32
|
+
* transformShell: composeShell(NativeAdapter.transformShell, metadata(), assets()),
|
|
33
|
+
* });
|
|
34
|
+
*/
|
|
35
|
+
export declare function composeShell(...transforms: Array<((shell: string, ctx: ShellContext) => string) | undefined | null | false>): (shell: string, ctx: ShellContext) => string;
|
|
36
|
+
//#endregion
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{n as e,r as t,t as n}from"./utils-DKRixAvn.js";export{n as assertFragmentId,e as composeShell,t as injectIntoHead};
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vincle/flow",
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "HTML document orchestration layer for @vincle/core",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deferred",
|
|
7
|
+
"dom-patching",
|
|
8
|
+
"esi",
|
|
9
|
+
"fragments",
|
|
10
|
+
"html",
|
|
11
|
+
"htmx",
|
|
12
|
+
"jsx",
|
|
13
|
+
"ssr",
|
|
14
|
+
"streaming",
|
|
15
|
+
"turbo"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://vincle.cjean.fr",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/cjean-fr/vincle/issues"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Christophe Jean",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/cjean-fr/vincle.git",
|
|
26
|
+
"directory": "packages/flow"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"type": "module",
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"types": "./dist/index.d.ts",
|
|
36
|
+
"exports": {
|
|
37
|
+
".": {
|
|
38
|
+
"types": "./dist/index.d.ts",
|
|
39
|
+
"default": "./dist/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./adapters": {
|
|
42
|
+
"types": "./dist/adapters/index.d.ts",
|
|
43
|
+
"default": "./dist/adapters/index.js"
|
|
44
|
+
},
|
|
45
|
+
"./components": {
|
|
46
|
+
"types": "./dist/components/index.d.ts",
|
|
47
|
+
"default": "./dist/components/index.js"
|
|
48
|
+
},
|
|
49
|
+
"./http": {
|
|
50
|
+
"types": "./dist/http.d.ts",
|
|
51
|
+
"default": "./dist/http.js"
|
|
52
|
+
},
|
|
53
|
+
"./utils": {
|
|
54
|
+
"types": "./dist/utils.d.ts",
|
|
55
|
+
"default": "./dist/utils.js"
|
|
56
|
+
},
|
|
57
|
+
"./context": {
|
|
58
|
+
"types": "./dist/context.d.ts",
|
|
59
|
+
"default": "./dist/context.js"
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "tsdown",
|
|
67
|
+
"check": "tsc --noEmit",
|
|
68
|
+
"coverage": "bun test --coverage --coverage-reporter=lcov",
|
|
69
|
+
"format": "oxfmt",
|
|
70
|
+
"mutation": "stryker run",
|
|
71
|
+
"test": "bun test"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@stryker-mutator/core": "^10.0.0",
|
|
75
|
+
"@types/bun": "^1.4.2",
|
|
76
|
+
"@vincle/core": "0.9.0",
|
|
77
|
+
"oxc-parser": "^0.150.0",
|
|
78
|
+
"tsdown": "^0.23.0",
|
|
79
|
+
"typescript": "^6.0.3"
|
|
80
|
+
},
|
|
81
|
+
"peerDependencies": {
|
|
82
|
+
"@vincle/core": "^0.9.0"
|
|
83
|
+
},
|
|
84
|
+
"engines": {
|
|
85
|
+
"node": ">=22.0.0"
|
|
86
|
+
}
|
|
87
|
+
}
|