@barefootjs/hono 0.1.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/dist/adapter/hono-adapter.d.ts +141 -0
- package/dist/adapter/hono-adapter.d.ts.map +1 -0
- package/dist/adapter/index.d.ts +6 -0
- package/dist/adapter/index.d.ts.map +1 -0
- package/dist/adapter/index.js +632 -0
- package/dist/app.d.ts +131 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +139 -0
- package/dist/async.d.ts +15 -0
- package/dist/async.d.ts.map +1 -0
- package/dist/async.js +12 -0
- package/dist/build.d.ts +65 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +785 -0
- package/dist/client-shim.d.ts +59 -0
- package/dist/client-shim.d.ts.map +1 -0
- package/dist/client-shim.js +90 -0
- package/dist/dev-worker.d.ts +25 -0
- package/dist/dev-worker.d.ts.map +1 -0
- package/dist/dev-worker.js +65 -0
- package/dist/dev.d.ts +36 -0
- package/dist/dev.d.ts.map +1 -0
- package/dist/dev.js +418 -0
- package/dist/dialog-context.d.ts +13 -0
- package/dist/dialog-context.d.ts.map +1 -0
- package/dist/dialog-context.js +10 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +632 -0
- package/dist/jsx/jsx-dev-runtime/index.d.ts +9 -0
- package/dist/jsx/jsx-dev-runtime/index.d.ts.map +1 -0
- package/dist/jsx/jsx-dev-runtime/index.js +6 -0
- package/dist/jsx/jsx-runtime/index.d.ts +32 -0
- package/dist/jsx/jsx-runtime/index.d.ts.map +1 -0
- package/dist/jsx/jsx-runtime/index.js +10 -0
- package/dist/portal-ssr.d.ts +22 -0
- package/dist/portal-ssr.d.ts.map +1 -0
- package/dist/portal-ssr.js +73 -0
- package/dist/portals.d.ts +26 -0
- package/dist/portals.d.ts.map +1 -0
- package/dist/portals.js +41 -0
- package/dist/preload.d.ts +56 -0
- package/dist/preload.d.ts.map +1 -0
- package/dist/preload.js +51 -0
- package/dist/scripts.d.ts +80 -0
- package/dist/scripts.d.ts.map +1 -0
- package/dist/scripts.js +198 -0
- package/dist/test-render.d.ts +28 -0
- package/dist/test-render.d.ts.map +1 -0
- package/dist/utils.d.ts +16 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +16 -0
- package/package.json +116 -0
- package/src/__tests__/async.test.tsx +106 -0
- package/src/__tests__/bfscripts-entry-roots.test.tsx +135 -0
- package/src/__tests__/build.test.ts +299 -0
- package/src/__tests__/dev.test.tsx +123 -0
- package/src/__tests__/hydration-props-type.test.ts +141 -0
- package/src/__tests__/manifest-scripts.test.ts +87 -0
- package/src/__tests__/scaffold.test.ts +209 -0
- package/src/__tests__/ssr-context-bridge.test.ts +110 -0
- package/src/__tests__/string-literal-css-var-prop.test.ts +84 -0
- package/src/__tests__/stub-deps-scripts.test.ts +183 -0
- package/src/adapter/hono-adapter.ts +1114 -0
- package/src/adapter/index.ts +6 -0
- package/src/app.ts +220 -0
- package/src/async.tsx +55 -0
- package/src/build.ts +230 -0
- package/src/client-shim.ts +164 -0
- package/src/dev-worker.ts +93 -0
- package/src/dev.tsx +146 -0
- package/src/dialog-context.tsx +44 -0
- package/src/index.ts +26 -0
- package/src/jsx/jsx-dev-runtime/index.ts +9 -0
- package/src/jsx/jsx-runtime/index.ts +40 -0
- package/src/portal-ssr.tsx +92 -0
- package/src/portals.tsx +98 -0
- package/src/preload.tsx +166 -0
- package/src/scripts.tsx +220 -0
- package/src/test-render.ts +143 -0
- package/src/utils.ts +26 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS Hono integration
|
|
3
|
+
*
|
|
4
|
+
* Runtime-agnostic by design — no `node:fs`, no `process.env`, no
|
|
5
|
+
* implicit conventions about URL paths or the dev-reload gate. The
|
|
6
|
+
* caller hands every component / middleware its configuration
|
|
7
|
+
* explicitly so the same module works under Node, Bun, Workers, and
|
|
8
|
+
* Deno without surprises.
|
|
9
|
+
*
|
|
10
|
+
* Two pieces:
|
|
11
|
+
*
|
|
12
|
+
* - **JSX components** (`<BfImportMap />`, `<BfScripts />`,
|
|
13
|
+
* `<BfDevReload />`) — return raw HTML the caller composes inside
|
|
14
|
+
* the Layout passed to Hono's `jsxRenderer`. All URL/data inputs
|
|
15
|
+
* are required props.
|
|
16
|
+
*
|
|
17
|
+
* - **Middleware** (`barefootDevReload`) — registers the SSE endpoint
|
|
18
|
+
* paired with `<BfDevReload />`. Both `endpoint` and `enabled` are
|
|
19
|
+
* required so the runtime gate happens in the caller's code, not
|
|
20
|
+
* here.
|
|
21
|
+
*
|
|
22
|
+
* Components are defined as plain functions returning
|
|
23
|
+
* `HtmlEscapedString` (via `html`/`raw` from `hono/html`) so this file
|
|
24
|
+
* stays `.ts` — `tsx`'s per-file `@jsxImportSource` pragma doesn't
|
|
25
|
+
* always propagate when transpiling `.tsx` from `node_modules` and
|
|
26
|
+
* would otherwise crash with `ReferenceError: React is not defined`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { MiddlewareHandler } from 'hono'
|
|
30
|
+
import { html, raw } from 'hono/html'
|
|
31
|
+
import type { HtmlEscapedString } from 'hono/utils/html'
|
|
32
|
+
import { useRequestContext } from 'hono/jsx-renderer'
|
|
33
|
+
import { createDevReloader } from './dev-worker'
|
|
34
|
+
|
|
35
|
+
const DEV_RELOAD_ENDPOINT_KEY = 'bfDevReloadEndpoint'
|
|
36
|
+
|
|
37
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build manifest shape produced by `bf build`. Each compiled
|
|
41
|
+
* component is keyed by its manifest name; `__barefoot__` is the
|
|
42
|
+
* runtime entry. `clientJs` is a path under `dist/`, e.g.
|
|
43
|
+
* `"components/Counter.client.js"`.
|
|
44
|
+
*
|
|
45
|
+
* `stubDeps` lists the manifest keys of every `'use client'` sibling
|
|
46
|
+
* this bundle reaches via a stub rewrite (i.e. via an imperative
|
|
47
|
+
* `createComponent(name, ...)` call rather than a JSX render). The
|
|
48
|
+
* per-page script collector follows these edges so pages that only
|
|
49
|
+
* touch a child through a stub still ship its `.client.js`. See
|
|
50
|
+
* issue #1243.
|
|
51
|
+
*
|
|
52
|
+
* Note: the entries are manifest keys (e.g. `"ui/button/index"` for
|
|
53
|
+
* `ui/button/index.tsx`), not the runtime registry name passed to
|
|
54
|
+
* `createComponent(...)` (e.g. `"Button"`). For top-level
|
|
55
|
+
* single-component files the two coincide; for nested layouts they
|
|
56
|
+
* differ. `build.ts` does the path → manifest-key conversion before
|
|
57
|
+
* writing this field.
|
|
58
|
+
*/
|
|
59
|
+
export interface BarefootBuildManifest {
|
|
60
|
+
__barefoot__?: { clientJs?: string }
|
|
61
|
+
[componentName: string]: { clientJs?: string; stubDeps?: string[] } | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Turn a build manifest into the ordered list of script URLs the page
|
|
66
|
+
* should load — runtime first, then each component. Pure: same input
|
|
67
|
+
* gives same output, no I/O.
|
|
68
|
+
*/
|
|
69
|
+
export function manifestToScriptUrls(
|
|
70
|
+
manifest: BarefootBuildManifest,
|
|
71
|
+
base: string,
|
|
72
|
+
): string[] {
|
|
73
|
+
const out: string[] = []
|
|
74
|
+
const prefix = `${base.replace(/\/$/, '')}/`
|
|
75
|
+
if (manifest.__barefoot__?.clientJs) {
|
|
76
|
+
out.push(prefix + relPathFromComponentsBase(manifest.__barefoot__.clientJs))
|
|
77
|
+
}
|
|
78
|
+
for (const [name, entry] of Object.entries(manifest)) {
|
|
79
|
+
if (name === '__barefoot__') continue
|
|
80
|
+
if (entry?.clientJs) out.push(prefix + relPathFromComponentsBase(entry.clientJs))
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function relPathFromComponentsBase(p: string): string {
|
|
86
|
+
return p.startsWith('components/') ? p.slice('components/'.length) : p
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── JSX components ─────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
export interface BfImportMapProps {
|
|
92
|
+
/** Base URL where the runtime + component bundles are served. */
|
|
93
|
+
base: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Emits the `<script type="importmap">` that maps the bare
|
|
98
|
+
* `@barefootjs/client` / `@barefootjs/client/runtime` specifiers to
|
|
99
|
+
* the runtime bundle. Place in `<head>`.
|
|
100
|
+
*/
|
|
101
|
+
export function BfImportMap(props: BfImportMapProps): HtmlEscapedString | Promise<HtmlEscapedString> {
|
|
102
|
+
const base = props.base.replace(/\/$/, '')
|
|
103
|
+
const json = JSON.stringify({
|
|
104
|
+
imports: {
|
|
105
|
+
'@barefootjs/client': `${base}/barefoot.js`,
|
|
106
|
+
'@barefootjs/client/runtime': `${base}/barefoot.js`,
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
return html`<script type="importmap">${raw(json)}</script>`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface BfScriptsProps {
|
|
113
|
+
/** Base URL where the runtime + component bundles are served. */
|
|
114
|
+
base: string
|
|
115
|
+
/** Build manifest (from `dist/components/manifest.json`). */
|
|
116
|
+
manifest: BarefootBuildManifest
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Emit the empty-manifest warning at most once per server process so
|
|
120
|
+
// repeated requests don't spam the console. Reset by reloading the
|
|
121
|
+
// process (which is what tsx watch does whenever the manifest is
|
|
122
|
+
// regenerated, so a real build clears the warning naturally).
|
|
123
|
+
let __bfEmptyManifestWarned = false
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Emits one `<script type="module" src=...>` per entry in the build
|
|
127
|
+
* manifest, runtime first. Place at the end of `<body>`.
|
|
128
|
+
*
|
|
129
|
+
* Logs a one-time warning when the manifest is empty — a strong
|
|
130
|
+
* signal the user is running the server before `bf build` has
|
|
131
|
+
* produced anything, which would otherwise present as a silent
|
|
132
|
+
* "page renders but nothing is interactive."
|
|
133
|
+
*/
|
|
134
|
+
export function BfScripts(props: BfScriptsProps): HtmlEscapedString | Promise<HtmlEscapedString> {
|
|
135
|
+
const urls = manifestToScriptUrls(props.manifest, props.base)
|
|
136
|
+
if (urls.length === 0 && !__bfEmptyManifestWarned) {
|
|
137
|
+
__bfEmptyManifestWarned = true
|
|
138
|
+
console.warn(
|
|
139
|
+
'[barefootjs] BfScripts: manifest is empty — no <script> tags emitted. ' +
|
|
140
|
+
'Run `bf build` to compile components and rebuild the manifest.',
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
const tags = urls
|
|
144
|
+
.map((src) => `<script type="module" src="${src}"></script>`)
|
|
145
|
+
.join('')
|
|
146
|
+
return html`${raw(tags)}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface BfDevReloadProps {
|
|
150
|
+
/**
|
|
151
|
+
* Override the SSE endpoint published by `barefootDevReload`. Almost
|
|
152
|
+
* always omitted: the middleware sets the endpoint on the request
|
|
153
|
+
* context and `<BfDevReload />` reads it. Setting this prop forces
|
|
154
|
+
* the snippet to point at the given endpoint regardless of whether
|
|
155
|
+
* the middleware is mounted.
|
|
156
|
+
*/
|
|
157
|
+
endpoint?: string
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Emits the inline EventSource snippet that connects to the SSE
|
|
162
|
+
* endpoint served by `barefootDevReload`. Renders nothing when the
|
|
163
|
+
* middleware isn't mounted (or is mounted with `enabled: false`),
|
|
164
|
+
* so the dev-reload script never lands on production pages — no
|
|
165
|
+
* "two gates to keep in sync" problem in the renderer.
|
|
166
|
+
*/
|
|
167
|
+
export function BfDevReload(props: BfDevReloadProps = {}): HtmlEscapedString | null {
|
|
168
|
+
let endpoint = props.endpoint
|
|
169
|
+
if (!endpoint) {
|
|
170
|
+
try {
|
|
171
|
+
endpoint = useRequestContext().get(DEV_RELOAD_ENDPOINT_KEY) as string | undefined
|
|
172
|
+
} catch {
|
|
173
|
+
// No request context (e.g. static rendering) and no explicit prop.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (!endpoint) return null
|
|
177
|
+
const ep = JSON.stringify(endpoint)
|
|
178
|
+
const snippet = `(()=>{if(window.__bfDevReload)return;window.__bfDevReload=1;try{var s=sessionStorage.getItem('__bf_devreload_scroll');if(s){sessionStorage.removeItem('__bf_devreload_scroll');var y=parseInt(s,10);if(!isNaN(y)){var restore=function(){window.scrollTo(0,y)};if(document.readyState==='loading'){addEventListener('DOMContentLoaded',restore,{once:true})}else{restore()}}}}catch(e){}var es=new EventSource(${ep});es.addEventListener('reload',function(){try{sessionStorage.setItem('__bf_devreload_scroll',String(window.scrollY))}catch(e){}location.reload()});es.addEventListener('error',function(){})})();`
|
|
179
|
+
// Tagged-template return type is a union with Promise; the `script`
|
|
180
|
+
// tag has no async children so the actual value is sync.
|
|
181
|
+
return html`<script>${raw(snippet)}</script>` as HtmlEscapedString
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── middleware ─────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export interface BarefootDevReloadOptions {
|
|
187
|
+
/** SSE endpoint path. */
|
|
188
|
+
endpoint: string
|
|
189
|
+
/**
|
|
190
|
+
* Whether to wire the endpoint up. When `false` the middleware is a
|
|
191
|
+
* complete no-op — no SSE handler, no context publishing — and
|
|
192
|
+
* `<BfDevReload />` (which reads the endpoint off the context) also
|
|
193
|
+
* renders nothing. The runtime gate (e.g. `NODE_ENV !== 'production'`)
|
|
194
|
+
* lives in the caller.
|
|
195
|
+
*/
|
|
196
|
+
enabled: boolean
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Hono middleware that serves the dev-reload SSE stream and publishes
|
|
201
|
+
* its endpoint on the request context so `<BfDevReload />` knows
|
|
202
|
+
* whether and where to wire up. Mount at the root; place
|
|
203
|
+
* `<BfDevReload />` somewhere in `<body>`. There's no separate
|
|
204
|
+
* "render the snippet?" gate to keep in sync — toggling `enabled`
|
|
205
|
+
* controls both.
|
|
206
|
+
*/
|
|
207
|
+
export function barefootDevReload(opts: BarefootDevReloadOptions): MiddlewareHandler {
|
|
208
|
+
if (!opts.enabled) {
|
|
209
|
+
return async (_c, next) => next()
|
|
210
|
+
}
|
|
211
|
+
const reloader = createDevReloader()
|
|
212
|
+
const endpoint = opts.endpoint
|
|
213
|
+
return async (c, next) => {
|
|
214
|
+
c.set(DEV_RELOAD_ENDPOINT_KEY, endpoint)
|
|
215
|
+
if (c.req.method === 'GET' && c.req.path === endpoint) {
|
|
216
|
+
return reloader(c as never)
|
|
217
|
+
}
|
|
218
|
+
await next()
|
|
219
|
+
}
|
|
220
|
+
}
|
package/src/async.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @jsxRuntime automatic
|
|
2
|
+
// @jsxImportSource hono/jsx
|
|
3
|
+
//
|
|
4
|
+
// Pragmas must be line-comments at the very top of the file (above
|
|
5
|
+
// any JSDoc) so esbuild honours them when this module is bundled into
|
|
6
|
+
// a downstream project. See packages/adapter-hono/src/scripts.tsx for
|
|
7
|
+
// the full rationale.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* BfAsync - Streaming async boundary for Hono
|
|
11
|
+
*
|
|
12
|
+
* Wraps Hono's Suspense for streaming SSR with BarefootJS integration.
|
|
13
|
+
* When the renderer uses `{ stream: true }`, this leverages Hono's native
|
|
14
|
+
* Suspense/streaming. The BarefootJS hydration runtime automatically picks
|
|
15
|
+
* up components rendered inside async boundaries via requestAnimationFrame.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* ```tsx
|
|
19
|
+
* import { BfAsync } from '@barefootjs/hono/async'
|
|
20
|
+
*
|
|
21
|
+
* app.get('/products/:id', (c) => {
|
|
22
|
+
* return c.render(
|
|
23
|
+
* <BfAsync fallback={<ProductSkeleton />}>
|
|
24
|
+
* <ProductDetail id={c.req.param('id')} />
|
|
25
|
+
* </BfAsync>
|
|
26
|
+
* )
|
|
27
|
+
* })
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* Requires the renderer to be configured with `{ stream: true }`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { Suspense } from 'hono/jsx/streaming'
|
|
34
|
+
import type { Child } from 'hono/jsx'
|
|
35
|
+
|
|
36
|
+
export interface BfAsyncProps {
|
|
37
|
+
/** Content to display while the async children are loading. */
|
|
38
|
+
fallback: Child
|
|
39
|
+
/** Async children that will be streamed when resolved. */
|
|
40
|
+
children: Child
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Async streaming boundary component.
|
|
45
|
+
*
|
|
46
|
+
* Renders fallback content immediately (sent in the initial HTTP response
|
|
47
|
+
* for fast TTFB), then streams the resolved children when ready.
|
|
48
|
+
*/
|
|
49
|
+
export function BfAsync(props: BfAsyncProps) {
|
|
50
|
+
return (
|
|
51
|
+
<Suspense fallback={props.fallback}>
|
|
52
|
+
{props.children}
|
|
53
|
+
</Suspense>
|
|
54
|
+
)
|
|
55
|
+
}
|
package/src/build.ts
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Hono build config factory for barefoot.config.ts
|
|
2
|
+
|
|
3
|
+
import type { BuildOptions } from '@barefootjs/jsx'
|
|
4
|
+
import { HonoAdapter } from './adapter'
|
|
5
|
+
import type { HonoAdapterOptions } from './adapter'
|
|
6
|
+
|
|
7
|
+
export interface HonoBuildOptions extends BuildOptions {
|
|
8
|
+
/** Inject Hono script collection wrapper (default: true) */
|
|
9
|
+
scriptCollection?: boolean
|
|
10
|
+
/** Base path for client JS script URLs (default: '/static/components/') */
|
|
11
|
+
scriptBasePath?: string
|
|
12
|
+
/** Adapter-specific options passed to HonoAdapter */
|
|
13
|
+
adapterOptions?: HonoAdapterOptions
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a BarefootBuildConfig for Hono projects.
|
|
18
|
+
*
|
|
19
|
+
* Uses structural typing — does not import BarefootBuildConfig to avoid
|
|
20
|
+
* circular dependency between @barefootjs/hono and @barefootjs/cli.
|
|
21
|
+
*/
|
|
22
|
+
export function createConfig(options: HonoBuildOptions = {}) {
|
|
23
|
+
const useScriptCollection = options.scriptCollection ?? true
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
adapter: new HonoAdapter(options.adapterOptions),
|
|
27
|
+
paths: options.paths,
|
|
28
|
+
components: options.components,
|
|
29
|
+
outDir: options.outDir,
|
|
30
|
+
minify: options.minify,
|
|
31
|
+
contentHash: options.contentHash,
|
|
32
|
+
externals: options.externals,
|
|
33
|
+
externalsBasePath: options.externalsBasePath,
|
|
34
|
+
bundleEntries: options.bundleEntries,
|
|
35
|
+
localImportPrefixes: options.localImportPrefixes,
|
|
36
|
+
transformMarkedTemplate: useScriptCollection
|
|
37
|
+
? (content: string, componentId: string, clientJsPath: string) =>
|
|
38
|
+
addScriptCollection(content, componentId, clientJsPath, options.scriptBasePath)
|
|
39
|
+
: undefined,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Add Hono script collection wrapper to an SSR marked template.
|
|
45
|
+
* Injects imports, a helper function, and script collector into each
|
|
46
|
+
* exported component function.
|
|
47
|
+
*/
|
|
48
|
+
export function addScriptCollection(content: string, componentId: string, clientJsPath: string, scriptBasePath: string = '/static/components/'): string {
|
|
49
|
+
const basePath = scriptBasePath.endsWith('/') ? scriptBasePath : scriptBasePath + '/'
|
|
50
|
+
const importStatement = "import { useRequestContext } from 'hono/jsx-renderer'\nimport { Fragment } from 'hono/jsx'\n"
|
|
51
|
+
|
|
52
|
+
// Find the last import statement and add our import after it
|
|
53
|
+
const importMatch = content.match(/^([\s\S]*?)((?:import[^\n]+\n)*)/m)
|
|
54
|
+
if (!importMatch) {
|
|
55
|
+
return content
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const beforeImports = importMatch[1]
|
|
59
|
+
const existingImports = importMatch[2]
|
|
60
|
+
const restOfFile = content.slice(importMatch[0].length)
|
|
61
|
+
|
|
62
|
+
// Helper function to wrap JSX with inline script tags (for Suspense streaming)
|
|
63
|
+
const helperFn = `
|
|
64
|
+
function __bfWrap(jsx: any, scripts: string[]) {
|
|
65
|
+
if (scripts.length === 0) return jsx
|
|
66
|
+
return <Fragment>{jsx}{scripts.map(s => <script type="module" src={s} />)}</Fragment>
|
|
67
|
+
}
|
|
68
|
+
`
|
|
69
|
+
|
|
70
|
+
// Script collection code to insert at the start of each component function.
|
|
71
|
+
// When BfScripts has already rendered (e.g., inside Suspense boundaries),
|
|
72
|
+
// scripts are output inline instead of being collected.
|
|
73
|
+
const scriptCollector = `
|
|
74
|
+
let __bfInlineScripts: string[] = []
|
|
75
|
+
// Script collection for client JS hydration
|
|
76
|
+
try {
|
|
77
|
+
const __c = useRequestContext()
|
|
78
|
+
const __scripts: { src: string }[] = __c.get('bfCollectedScripts') || []
|
|
79
|
+
const __outputScripts: Set<string> = __c.get('bfOutputScripts') || new Set()
|
|
80
|
+
const __bfRendered = __c.get('bfScriptsRendered')
|
|
81
|
+
if (!__outputScripts.has('__barefoot__')) {
|
|
82
|
+
__outputScripts.add('__barefoot__')
|
|
83
|
+
if (__bfRendered) __bfInlineScripts.push('${basePath}barefoot.js')
|
|
84
|
+
else __scripts.push({ src: '${basePath}barefoot.js' })
|
|
85
|
+
}
|
|
86
|
+
if (!__outputScripts.has('${componentId}')) {
|
|
87
|
+
__outputScripts.add('${componentId}')
|
|
88
|
+
if (__bfRendered) __bfInlineScripts.push('${basePath}${clientJsPath}')
|
|
89
|
+
else __scripts.push({ src: '${basePath}${clientJsPath}' })
|
|
90
|
+
}
|
|
91
|
+
__c.set('bfCollectedScripts', __scripts)
|
|
92
|
+
__c.set('bfOutputScripts', __outputScripts)
|
|
93
|
+
} catch {}
|
|
94
|
+
`
|
|
95
|
+
|
|
96
|
+
// Insert script collector at the start of each component function body.
|
|
97
|
+
// Matches both exported and non-exported PascalCase components (#786).
|
|
98
|
+
// Uses paren counting instead of regex to correctly handle nested
|
|
99
|
+
// delimiters in destructured params (e.g. `onInput = () => {}`).
|
|
100
|
+
//
|
|
101
|
+
// The regex matches against a comment-masked copy so a docstring
|
|
102
|
+
// example like `function MyNode(this: HTMLElement, props)` is NOT
|
|
103
|
+
// misread as a real function declaration (#1236). The paren counter
|
|
104
|
+
// still walks the ORIGINAL `restOfFile` and keeps the quote-skip
|
|
105
|
+
// logic — TS parameter type annotations contain balanced strings
|
|
106
|
+
// (e.g. `"data-key"?: string`) that the skip handles correctly.
|
|
107
|
+
let modifiedRest = restOfFile
|
|
108
|
+
const maskedRest = maskComments(restOfFile)
|
|
109
|
+
const exportFuncPattern = /(?:export )?function ([A-Z]\w*)\s*\(/g
|
|
110
|
+
const insertions: Array<{ index: number; text: string }> = []
|
|
111
|
+
let efMatch: RegExpExecArray | null
|
|
112
|
+
while ((efMatch = exportFuncPattern.exec(maskedRest)) !== null) {
|
|
113
|
+
const openParenPos = efMatch.index + efMatch[0].length - 1
|
|
114
|
+
// Count parens to find matching ')'
|
|
115
|
+
let depth = 1
|
|
116
|
+
let i = openParenPos + 1
|
|
117
|
+
while (i < restOfFile.length && depth > 0) {
|
|
118
|
+
const ch = restOfFile[i]
|
|
119
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
120
|
+
i++
|
|
121
|
+
while (i < restOfFile.length) {
|
|
122
|
+
if (restOfFile[i] === '\\') { i += 2; continue }
|
|
123
|
+
if (restOfFile[i] === ch) { i++; break }
|
|
124
|
+
i++
|
|
125
|
+
}
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
if (ch === '(') depth++
|
|
129
|
+
else if (ch === ')') depth--
|
|
130
|
+
i++
|
|
131
|
+
}
|
|
132
|
+
// i is now right after matching ')'; find the next '{' for function body
|
|
133
|
+
while (i < restOfFile.length && restOfFile[i] !== '{') i++
|
|
134
|
+
if (i < restOfFile.length) {
|
|
135
|
+
insertions.push({ index: i + 1, text: scriptCollector })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Apply insertions from back to front to preserve indices
|
|
139
|
+
for (let ii = insertions.length - 1; ii >= 0; ii--) {
|
|
140
|
+
const ins = insertions[ii]
|
|
141
|
+
modifiedRest = modifiedRest.slice(0, ins.index) + ins.text + modifiedRest.slice(ins.index)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Wrap each return (...) with __bfWrap((...), __bfInlineScripts).
|
|
145
|
+
//
|
|
146
|
+
// Intentionally NO comment/string masking here. JSX bodies routinely
|
|
147
|
+
// contain unbalanced apostrophes in text content (`Hey! How's it
|
|
148
|
+
// going`) which a string-aware scanner misreads as an open quote and
|
|
149
|
+
// ends up blanking everything until the next stray `'`, breaking
|
|
150
|
+
// paren counting. Plain `(` / `)` counting works for JSX returns
|
|
151
|
+
// because JSX text cannot contain literal parens — those only appear
|
|
152
|
+
// inside `{expr}` slots, which are balanced JS.
|
|
153
|
+
const returnPattern = /return\s*\(/g
|
|
154
|
+
const returnMatches: Array<{ index: number; length: number }> = []
|
|
155
|
+
let m: RegExpExecArray | null
|
|
156
|
+
while ((m = returnPattern.exec(modifiedRest)) !== null) {
|
|
157
|
+
returnMatches.push({ index: m.index, length: m[0].length })
|
|
158
|
+
}
|
|
159
|
+
// Process from last to first to keep earlier offsets valid
|
|
160
|
+
for (let ri = returnMatches.length - 1; ri >= 0; ri--) {
|
|
161
|
+
const rm = returnMatches[ri]
|
|
162
|
+
const afterOpen = rm.index + rm.length // position after 'return ('
|
|
163
|
+
let depth = 1
|
|
164
|
+
let ci = afterOpen
|
|
165
|
+
while (ci < modifiedRest.length && depth > 0) {
|
|
166
|
+
if (modifiedRest[ci] === '(') depth++
|
|
167
|
+
else if (modifiedRest[ci] === ')') depth--
|
|
168
|
+
ci++
|
|
169
|
+
}
|
|
170
|
+
// ci is right after the matching ')'; insert wrap closing there
|
|
171
|
+
modifiedRest = modifiedRest.slice(0, ci) + ', __bfInlineScripts)' + modifiedRest.slice(ci)
|
|
172
|
+
// Replace 'return (' with 'return __bfWrap(('
|
|
173
|
+
modifiedRest = modifiedRest.slice(0, rm.index) + 'return __bfWrap((' + modifiedRest.slice(rm.index + rm.length)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return beforeImports + existingImports + importStatement + helperFn + modifiedRest
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Replace comment contents with spaces (preserving length and newlines
|
|
181
|
+
* so indices computed against the masked text are valid in the
|
|
182
|
+
* original). Used by `addScriptCollection` so its `function Foo(`
|
|
183
|
+
* regex ignores JSDoc / inline comments — a docstring example like
|
|
184
|
+
* `function MyNode(this: HTMLElement, props)` previously masqueraded
|
|
185
|
+
* as a real function declaration (#1236).
|
|
186
|
+
*
|
|
187
|
+
* Handles `//` line comments and `/* ... *\/` block comments (incl.
|
|
188
|
+
* JSDoc). String literals are intentionally NOT masked: JSX text
|
|
189
|
+
* content routinely contains unbalanced apostrophes (`How's`) that a
|
|
190
|
+
* string-aware masker would misread as an open quote, blanking the
|
|
191
|
+
* rest of the file and hiding later function declarations.
|
|
192
|
+
*
|
|
193
|
+
* Strings inside comments are handled implicitly: the whole comment
|
|
194
|
+
* (including any quotes it contains) is blanked.
|
|
195
|
+
*
|
|
196
|
+
* **Known limitation**: this function does NOT track string
|
|
197
|
+
* boundaries, so a `//` or `/*` appearing INSIDE a string literal is
|
|
198
|
+
* still treated as a comment delimiter. Example: in
|
|
199
|
+
* `const u = "https://x.y" ; export function Foo() {}` the `//` in
|
|
200
|
+
* `https://` is misread as a line comment and the rest of the line is
|
|
201
|
+
* blanked — a `function Foo()` on that same line would be hidden from
|
|
202
|
+
* the regex. SSR template output (the only caller) does not embed
|
|
203
|
+
* such cases in practice. If a future caller can produce them, swap
|
|
204
|
+
* in a real lexer rather than extending this helper.
|
|
205
|
+
*/
|
|
206
|
+
export function maskComments(s: string): string {
|
|
207
|
+
let out = ''
|
|
208
|
+
let i = 0
|
|
209
|
+
while (i < s.length) {
|
|
210
|
+
const ch = s[i]
|
|
211
|
+
const next = s[i + 1]
|
|
212
|
+
if (ch === '/' && next === '*') {
|
|
213
|
+
const end = s.indexOf('*/', i + 2)
|
|
214
|
+
const stop = end === -1 ? s.length : end + 2
|
|
215
|
+
for (let j = i; j < stop; j++) out += s[j] === '\n' ? '\n' : ' '
|
|
216
|
+
i = stop
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (ch === '/' && next === '/') {
|
|
220
|
+
const end = s.indexOf('\n', i + 2)
|
|
221
|
+
const stop = end === -1 ? s.length : end
|
|
222
|
+
for (let j = i; j < stop; j++) out += ' '
|
|
223
|
+
i = stop
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
out += ch
|
|
227
|
+
i++
|
|
228
|
+
}
|
|
229
|
+
return out
|
|
230
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR shim for `@barefootjs/client` when targeting the Hono adapter.
|
|
3
|
+
*
|
|
4
|
+
* The compiler rewrites `@barefootjs/client` imports inside SSR templates to
|
|
5
|
+
* this module. The shim provides:
|
|
6
|
+
*
|
|
7
|
+
* - `useContext` / `provideContext` bridged to Hono's per-render context
|
|
8
|
+
* stack, so context values flow through `<Context.Provider value=...>`
|
|
9
|
+
* during SSR. The compiler emits provider IR via `provideContextSSR`.
|
|
10
|
+
* - Pure helpers (`createContext`, `splitProps`, `forwardProps`, `unwrap`,
|
|
11
|
+
* `__slot`) re-exported from `@barefootjs/client`.
|
|
12
|
+
* - Reactive primitives (`createSignal`, `createMemo`, etc.) replaced with
|
|
13
|
+
* stubs that throw if called — the compiler is expected to rewrite all
|
|
14
|
+
* reachable call sites to plain values during SSR codegen.
|
|
15
|
+
* - Portal helpers as no-ops; portals are realized at hydration time.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @jsxImportSource hono/jsx */
|
|
19
|
+
|
|
20
|
+
import { createContext as honoCreateContext, useContext as honoUseContext, jsx } from 'hono/jsx'
|
|
21
|
+
import type { Context as HonoContext } from 'hono/jsx'
|
|
22
|
+
import type { Context } from '@barefootjs/client'
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
createContext,
|
|
26
|
+
splitProps,
|
|
27
|
+
forwardProps,
|
|
28
|
+
unwrap,
|
|
29
|
+
__slot,
|
|
30
|
+
} from '@barefootjs/client'
|
|
31
|
+
|
|
32
|
+
export type {
|
|
33
|
+
Context,
|
|
34
|
+
Reactive,
|
|
35
|
+
Signal,
|
|
36
|
+
Memo,
|
|
37
|
+
CleanupFn,
|
|
38
|
+
EffectFn,
|
|
39
|
+
SlotMarker,
|
|
40
|
+
Portal,
|
|
41
|
+
PortalChildren,
|
|
42
|
+
PortalOptions,
|
|
43
|
+
Renderable,
|
|
44
|
+
} from '@barefootjs/client'
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Context bridge: BarefootJS Context → Hono Context
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
const honoCtxBridge = new WeakMap<Context<unknown>, HonoContext<unknown>>()
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Lazily map a BarefootJS Context object to a Hono context. The mapping is
|
|
54
|
+
* stable (WeakMap keyed by the Context object itself), so providers and
|
|
55
|
+
* consumers in the same render see the same Hono context.
|
|
56
|
+
*/
|
|
57
|
+
export function getHonoContext<T>(bfCtx: Context<T>): HonoContext<T | undefined> {
|
|
58
|
+
let hc = honoCtxBridge.get(bfCtx as Context<unknown>) as HonoContext<T | undefined> | undefined
|
|
59
|
+
if (!hc) {
|
|
60
|
+
hc = honoCreateContext<T | undefined>(bfCtx.defaultValue)
|
|
61
|
+
honoCtxBridge.set(bfCtx as Context<unknown>, hc as HonoContext<unknown>)
|
|
62
|
+
}
|
|
63
|
+
return hc
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* SSR `useContext`: read from Hono's per-render stack, falling back to the
|
|
68
|
+
* BarefootJS Context's default value, then to `undefined`. Mirrors client
|
|
69
|
+
* semantics — no provider returns `undefined` rather than throwing.
|
|
70
|
+
*/
|
|
71
|
+
export function useContext<T>(bfCtx: Context<T>): T {
|
|
72
|
+
const hc = getHonoContext(bfCtx)
|
|
73
|
+
const v = honoUseContext(hc) as T | undefined
|
|
74
|
+
if (v !== undefined) return v
|
|
75
|
+
return bfCtx.defaultValue as T
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* SSR `provideContext`: imperative provider calls inside init code are
|
|
80
|
+
* unreachable from SSR templates (they live in client JS). At SSR, the
|
|
81
|
+
* `<Context.Provider value=...>` JSX is compiled to `provideContextSSR`
|
|
82
|
+
* instead, which uses Hono's stack-scoped Provider.
|
|
83
|
+
*/
|
|
84
|
+
export function provideContext<T>(_bfCtx: Context<T>, _value: T): void {
|
|
85
|
+
// intentional no-op
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Compiler-emitted helper for `<Context.Provider value=...>{children}</...>`
|
|
90
|
+
* at SSR. Wraps children with the bridged Hono Provider so that descendants
|
|
91
|
+
* resolving the same BarefootJS Context via `useContext` see this value.
|
|
92
|
+
*/
|
|
93
|
+
export function provideContextSSR<T>(
|
|
94
|
+
bfCtx: Context<T>,
|
|
95
|
+
value: T,
|
|
96
|
+
children: unknown,
|
|
97
|
+
): unknown {
|
|
98
|
+
const HonoCtx = getHonoContext(bfCtx)
|
|
99
|
+
return jsx(HonoCtx.Provider as unknown as Function, { value, children })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Reactive primitives — never reached at SSR (compiler rewrites call sites)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function calledAtSSR(name: string): never {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`[barefootjs] ${name}() reached SSR. The compiler should have rewritten this call site — please report a bug.`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createSignal<T>(_initial?: T): never {
|
|
113
|
+
return calledAtSSR('createSignal')
|
|
114
|
+
}
|
|
115
|
+
export function createMemo<T>(_fn: () => T): never {
|
|
116
|
+
return calledAtSSR('createMemo')
|
|
117
|
+
}
|
|
118
|
+
export function createEffect(_fn: () => void): never {
|
|
119
|
+
return calledAtSSR('createEffect')
|
|
120
|
+
}
|
|
121
|
+
export function createDisposableEffect(_fn: () => void): never {
|
|
122
|
+
return calledAtSSR('createDisposableEffect')
|
|
123
|
+
}
|
|
124
|
+
export function createRoot<T>(_fn: (dispose: () => void) => T): never {
|
|
125
|
+
return calledAtSSR('createRoot')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function onMount(_fn: () => void): void {
|
|
129
|
+
// no-op at SSR
|
|
130
|
+
}
|
|
131
|
+
export function onCleanup(_fn: () => void): void {
|
|
132
|
+
// no-op at SSR
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function untrack<T>(fn: () => T): T {
|
|
136
|
+
return fn()
|
|
137
|
+
}
|
|
138
|
+
export function batch<T>(fn: () => T): T {
|
|
139
|
+
return fn()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Portal stubs — portals are realized at hydration time, not at SSR
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export function createPortal(
|
|
147
|
+
_children: unknown,
|
|
148
|
+
_container?: unknown,
|
|
149
|
+
_options?: unknown,
|
|
150
|
+
): never {
|
|
151
|
+
return calledAtSSR('createPortal')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function isSSRPortal(_element: unknown): boolean {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function findSiblingSlot(_el: unknown, _slotSelector: string): null {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function cleanupPortalPlaceholder(_portalId: string): void {
|
|
163
|
+
// no-op
|
|
164
|
+
}
|