@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.
Files changed (81) hide show
  1. package/dist/adapter/hono-adapter.d.ts +141 -0
  2. package/dist/adapter/hono-adapter.d.ts.map +1 -0
  3. package/dist/adapter/index.d.ts +6 -0
  4. package/dist/adapter/index.d.ts.map +1 -0
  5. package/dist/adapter/index.js +632 -0
  6. package/dist/app.d.ts +131 -0
  7. package/dist/app.d.ts.map +1 -0
  8. package/dist/app.js +139 -0
  9. package/dist/async.d.ts +15 -0
  10. package/dist/async.d.ts.map +1 -0
  11. package/dist/async.js +12 -0
  12. package/dist/build.d.ts +65 -0
  13. package/dist/build.d.ts.map +1 -0
  14. package/dist/build.js +785 -0
  15. package/dist/client-shim.d.ts +59 -0
  16. package/dist/client-shim.d.ts.map +1 -0
  17. package/dist/client-shim.js +90 -0
  18. package/dist/dev-worker.d.ts +25 -0
  19. package/dist/dev-worker.d.ts.map +1 -0
  20. package/dist/dev-worker.js +65 -0
  21. package/dist/dev.d.ts +36 -0
  22. package/dist/dev.d.ts.map +1 -0
  23. package/dist/dev.js +418 -0
  24. package/dist/dialog-context.d.ts +13 -0
  25. package/dist/dialog-context.d.ts.map +1 -0
  26. package/dist/dialog-context.js +10 -0
  27. package/dist/index.d.ts +13 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +632 -0
  30. package/dist/jsx/jsx-dev-runtime/index.d.ts +9 -0
  31. package/dist/jsx/jsx-dev-runtime/index.d.ts.map +1 -0
  32. package/dist/jsx/jsx-dev-runtime/index.js +6 -0
  33. package/dist/jsx/jsx-runtime/index.d.ts +32 -0
  34. package/dist/jsx/jsx-runtime/index.d.ts.map +1 -0
  35. package/dist/jsx/jsx-runtime/index.js +10 -0
  36. package/dist/portal-ssr.d.ts +22 -0
  37. package/dist/portal-ssr.d.ts.map +1 -0
  38. package/dist/portal-ssr.js +73 -0
  39. package/dist/portals.d.ts +26 -0
  40. package/dist/portals.d.ts.map +1 -0
  41. package/dist/portals.js +41 -0
  42. package/dist/preload.d.ts +56 -0
  43. package/dist/preload.d.ts.map +1 -0
  44. package/dist/preload.js +51 -0
  45. package/dist/scripts.d.ts +80 -0
  46. package/dist/scripts.d.ts.map +1 -0
  47. package/dist/scripts.js +198 -0
  48. package/dist/test-render.d.ts +28 -0
  49. package/dist/test-render.d.ts.map +1 -0
  50. package/dist/utils.d.ts +16 -0
  51. package/dist/utils.d.ts.map +1 -0
  52. package/dist/utils.js +16 -0
  53. package/package.json +116 -0
  54. package/src/__tests__/async.test.tsx +106 -0
  55. package/src/__tests__/bfscripts-entry-roots.test.tsx +135 -0
  56. package/src/__tests__/build.test.ts +299 -0
  57. package/src/__tests__/dev.test.tsx +123 -0
  58. package/src/__tests__/hydration-props-type.test.ts +141 -0
  59. package/src/__tests__/manifest-scripts.test.ts +87 -0
  60. package/src/__tests__/scaffold.test.ts +209 -0
  61. package/src/__tests__/ssr-context-bridge.test.ts +110 -0
  62. package/src/__tests__/string-literal-css-var-prop.test.ts +84 -0
  63. package/src/__tests__/stub-deps-scripts.test.ts +183 -0
  64. package/src/adapter/hono-adapter.ts +1114 -0
  65. package/src/adapter/index.ts +6 -0
  66. package/src/app.ts +220 -0
  67. package/src/async.tsx +55 -0
  68. package/src/build.ts +230 -0
  69. package/src/client-shim.ts +164 -0
  70. package/src/dev-worker.ts +93 -0
  71. package/src/dev.tsx +146 -0
  72. package/src/dialog-context.tsx +44 -0
  73. package/src/index.ts +26 -0
  74. package/src/jsx/jsx-dev-runtime/index.ts +9 -0
  75. package/src/jsx/jsx-runtime/index.ts +40 -0
  76. package/src/portal-ssr.tsx +92 -0
  77. package/src/portals.tsx +98 -0
  78. package/src/preload.tsx +166 -0
  79. package/src/scripts.tsx +220 -0
  80. package/src/test-render.ts +143 -0
  81. package/src/utils.ts +26 -0
@@ -0,0 +1,6 @@
1
+ /**
2
+ * BarefootJS Hono Adapter
3
+ */
4
+
5
+ export { HonoAdapter, honoAdapter } from './hono-adapter'
6
+ export type { HonoAdapterOptions } from './hono-adapter'
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
+ }