@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,166 @@
1
+ // @jsxRuntime automatic
2
+ // @jsxImportSource hono/jsx
3
+ //
4
+ // Pragmas as line-comments above the JSDoc; see scripts.tsx for the rationale.
5
+
6
+ /**
7
+ * BfPreload Component
8
+ *
9
+ * Renders modulepreload link tags in the document head for faster module loading.
10
+ * Modulepreload hints tell the browser to fetch and parse JavaScript modules early,
11
+ * reducing the critical path latency.
12
+ *
13
+ * Usage:
14
+ * ```tsx
15
+ * import { BfPreload } from '@barefootjs/hono/preload'
16
+ * import manifest from './dist/components/manifest.json'
17
+ *
18
+ * <html>
19
+ * <head>
20
+ * <BfPreload />
21
+ * {/* or with additional scripts *\/}
22
+ * <BfPreload scripts={['/static/components/button.js']} />
23
+ * {/* or with manifest-based dependency preloading *\/}
24
+ * <BfPreload manifest={manifest} components={['Button', 'TodoApp']} />
25
+ * </head>
26
+ * <body>
27
+ * {children}
28
+ * <BfScripts />
29
+ * </body>
30
+ * </html>
31
+ * ```
32
+ */
33
+
34
+ import { Fragment } from 'hono/jsx'
35
+
36
+ /**
37
+ * Manifest entry type for dependency tracking.
38
+ */
39
+ export interface ManifestEntry {
40
+ markedTemplate: string
41
+ clientJs?: string
42
+ props?: Array<{ name: string; type: string; optional: boolean }>
43
+ dependencies?: string[]
44
+ }
45
+
46
+ /**
47
+ * Manifest type mapping component names to their metadata.
48
+ */
49
+ export type Manifest = Record<string, ManifestEntry>
50
+
51
+ export interface BfPreloadProps {
52
+ /**
53
+ * Path to static files directory.
54
+ * @default '/static'
55
+ */
56
+ staticPath?: string
57
+
58
+ /**
59
+ * Additional script URLs to preload.
60
+ * These are added in addition to the barefoot runtime.
61
+ */
62
+ scripts?: string[]
63
+
64
+ /**
65
+ * Whether to preload the barefoot runtime.
66
+ * @default true
67
+ */
68
+ includeRuntime?: boolean
69
+
70
+ /**
71
+ * Component manifest with dependency information.
72
+ * Used for automatic dependency chain preloading.
73
+ */
74
+ manifest?: Manifest
75
+
76
+ /**
77
+ * Component names to preload with their dependencies.
78
+ * Requires manifest to be provided.
79
+ */
80
+ components?: string[]
81
+ }
82
+
83
+ /**
84
+ * Resolves the full dependency chain for given components.
85
+ * Uses a visited set to prevent infinite loops from circular dependencies.
86
+ *
87
+ * @param components - Component names to resolve
88
+ * @param manifest - Component manifest with dependency information
89
+ * @param visited - Set of already visited component names (for cycle detection)
90
+ * @returns Array of clientJs paths for all dependencies
91
+ */
92
+ function resolveDependencyChain(
93
+ components: string[],
94
+ manifest: Manifest,
95
+ visited = new Set<string>()
96
+ ): string[] {
97
+ const result: string[] = []
98
+
99
+ for (const compName of components) {
100
+ if (visited.has(compName)) continue
101
+ visited.add(compName)
102
+
103
+ const entry = manifest[compName]
104
+ if (!entry) continue
105
+
106
+ // Add this component's clientJs
107
+ if (entry.clientJs) {
108
+ result.push(entry.clientJs)
109
+ }
110
+
111
+ // Recursively add dependencies
112
+ if (entry.dependencies && entry.dependencies.length > 0) {
113
+ const childScripts = resolveDependencyChain(entry.dependencies, manifest, visited)
114
+ result.push(...childScripts)
115
+ }
116
+ }
117
+
118
+ return result
119
+ }
120
+
121
+ /**
122
+ * Renders modulepreload link tags for BarefootJS scripts.
123
+ * Place this component in your <head> element.
124
+ *
125
+ * By default, preloads the barefoot.js runtime which is required
126
+ * by all BarefootJS components.
127
+ *
128
+ * When manifest and components props are provided, automatically
129
+ * preloads the full dependency chain for those components.
130
+ */
131
+ export function BfPreload({
132
+ staticPath = '/static',
133
+ scripts = [],
134
+ includeRuntime = true,
135
+ manifest,
136
+ components = [],
137
+ }: BfPreloadProps = {}) {
138
+ const urls: string[] = []
139
+
140
+ // Always preload the barefoot runtime first (most critical)
141
+ if (includeRuntime) {
142
+ urls.push(`${staticPath}/components/barefoot.js`)
143
+ }
144
+
145
+ // Auto-preload component dependencies from manifest
146
+ if (manifest && components.length > 0) {
147
+ const dependencyScripts = resolveDependencyChain(components, manifest)
148
+ for (const script of dependencyScripts) {
149
+ urls.push(`${staticPath}/${script}`)
150
+ }
151
+ }
152
+
153
+ // Add additional scripts
154
+ urls.push(...scripts)
155
+
156
+ // Deduplicate URLs while preserving order
157
+ const uniqueUrls = [...new Set(urls)]
158
+
159
+ return (
160
+ <Fragment>
161
+ {uniqueUrls.map((url) => (
162
+ <link rel="modulepreload" href={url} />
163
+ ))}
164
+ </Fragment>
165
+ )
166
+ }
@@ -0,0 +1,220 @@
1
+ // @jsxRuntime automatic
2
+ // @jsxImportSource hono/jsx
3
+ //
4
+ // Both pragmas must stay as the first lines of the file. The runtime
5
+ // pragma enables the React-17-style import transform — without it
6
+ // esbuild drops the import-source pragma on the floor and the JSX
7
+ // compiles against the consumer's tsconfig runtime. In the scaffold's
8
+ // case that's `@barefootjs/hono/jsx` (the marked-template adapter),
9
+ // which doesn't register Hono's request context, so
10
+ // `useRequestContext()` throws, the catch below swallows it, and
11
+ // BfScripts returns null on every request — pages render server HTML
12
+ // but never hydrate. esbuild also only looks for these pragmas in the
13
+ // FIRST comment block in the file; a JSDoc above them silently wins.
14
+
15
+ /**
16
+ * BfScripts Component
17
+ *
18
+ * Renders collected script tags at the end of the document body.
19
+ * BarefootJS components collect their script URLs during SSR render,
20
+ * and this component outputs them all at once to avoid DOM traversal issues.
21
+ *
22
+ * Usage:
23
+ * ```tsx
24
+ * import { BfScripts } from '@barefoot/hono/scripts'
25
+ *
26
+ * <html>
27
+ * <body>
28
+ * {children}
29
+ * <BfScripts manifest={manifest} base="/static/components/" />
30
+ * </body>
31
+ * </html>
32
+ * ```
33
+ *
34
+ * Pass `manifest` + `base` to follow stub references emitted by the
35
+ * 'use client' import rewriter (#1241). Without those props the
36
+ * component falls back to the JSX-driven script set, which misses
37
+ * components reached only through imperative `createComponent()`
38
+ * stub calls — see issue #1243.
39
+ */
40
+
41
+ import { useRequestContext } from 'hono/jsx-renderer'
42
+ import { Fragment } from 'hono/jsx'
43
+ import { relPathFromComponentsBase, type BarefootBuildManifest } from './app'
44
+
45
+ export type CollectedScript = {
46
+ src: string
47
+ }
48
+
49
+ export interface BfScriptsProps {
50
+ /**
51
+ * Build manifest from `dist/components/manifest.json`. When supplied
52
+ * alongside `base`, the component follows each rendered entry's
53
+ * `stubDeps` transitively and emits a `<script>` for every reachable
54
+ * `.client.js` — necessary for pages that only touch a child
55
+ * component through an imperative stub call (issue #1243).
56
+ *
57
+ * When omitted, behavior matches the pre-#1243 collector: only
58
+ * components whose SSR function executed get a script tag.
59
+ */
60
+ manifest?: BarefootBuildManifest
61
+ /**
62
+ * URL base where the component bundles are served (e.g.
63
+ * `/static/components/`). Required when `manifest` is supplied —
64
+ * stubDep entries store dist-relative paths and the component
65
+ * needs the URL prefix to emit a working `<script src>`.
66
+ */
67
+ base?: string
68
+ /**
69
+ * Extra manifest keys to treat as walk roots in addition to the
70
+ * SSR-rendered set. Use this for pages that mount a `'use client'`
71
+ * component via an inline `<script type="module">import "X.client.js"; render(root, "X", …)`
72
+ * instead of SSR'ing `<X />` directly. Without `entryRoots`, the
73
+ * walker has no anchor for `X`'s `stubDeps` and any sibling `'use
74
+ * client'` `.tsx` reached only through the imperative
75
+ * `createComponent` stub rewrite (#1240) never ships as a
76
+ * `<script>`, leaving the runtime registry empty and rendering
77
+ * `[ComponentName]` placeholders.
78
+ *
79
+ * The caller's inline `<script type="module">import …</script>`
80
+ * already loads the root bundle, so the root itself is *not*
81
+ * emitted as a separate `<script src>` — only the transitively
82
+ * reached `stubDeps` are. See #1431.
83
+ */
84
+ entryRoots?: string[]
85
+ }
86
+
87
+ /**
88
+ * Renders all collected BarefootJS script tags.
89
+ * Place this component at the end of your <body> element.
90
+ *
91
+ * After rendering, sets 'bfScriptsRendered' flag to true.
92
+ * Components rendered after BfScripts (e.g., inside Suspense boundaries)
93
+ * will check this flag and output their scripts inline instead of
94
+ * collecting them here.
95
+ */
96
+ export function BfScripts(props: BfScriptsProps = {}) {
97
+ try {
98
+ const c = useRequestContext()
99
+
100
+ // Mark that BfScripts has been rendered.
101
+ // Components rendered after this point (e.g., inside Suspense)
102
+ // should output their scripts inline.
103
+ c.set('bfScriptsRendered', true)
104
+
105
+ const scripts: CollectedScript[] = c.get('bfCollectedScripts') || []
106
+ const outputSet: Set<string> = c.get('bfOutputScripts') || new Set()
107
+ const { manifest, base, entryRoots } = props
108
+ // `entryRoots` extends both the walk-root set AND the `excluded` set.
109
+ // Walk-root so the root's `stubDeps` get visited (the manually-mounted
110
+ // component never SSR'd, so it isn't in `bfOutputScripts`). Excluded
111
+ // so the root's own `.client.js` isn't re-emitted — the caller's
112
+ // inline `<script type="module">import "X.client.js"; render(...)`
113
+ // already loaded it. See #1431.
114
+ const roots = entryRoots && entryRoots.length > 0
115
+ ? new Set([...outputSet, ...entryRoots])
116
+ : outputSet
117
+ if (entryRoots) for (const r of entryRoots) outputSet.add(r)
118
+ const stubScripts = manifest && base
119
+ ? collectStubDepScripts(manifest, base, roots, outputSet)
120
+ : new Map<string, CollectedScript>()
121
+ // Record stub-derived names on the same context flag so any later
122
+ // SSR pass (e.g. a Suspense boundary that renders after this point)
123
+ // won't double-emit the same `.client.js`. The flag's name keeps
124
+ // it symmetric with the snippet in `addScriptCollection`.
125
+ if (stubScripts.size > 0) c.set('bfOutputScripts', outputSet)
126
+
127
+ // Reverse script order so child components load before parents.
128
+ // During SSR, parent components render first and collect their scripts,
129
+ // then child components add their scripts. But for hydration, children
130
+ // need to register their templates before parents try to use createComponent().
131
+ // barefoot.js must stay first since it provides the runtime.
132
+ //
133
+ // Stub-derived scripts must come BEFORE the component scripts in the
134
+ // emitted order — `<script type="module">` tags evaluate in document
135
+ // order with microtask checkpoints between them, so a parent's
136
+ // `hydrate()`-scheduled walk fires (and its `init` calls
137
+ // `createComponent(...)` against the stub) before any later module
138
+ // script has registered. Within stubScripts, `collectStubDepScripts`
139
+ // already returns DFS post-order (deps before their dependent), so
140
+ // a chain A→B→C ships C, B, then the component bundle that stubs A.
141
+ const barefootScript = scripts.find(s => s.src.includes('barefoot.js'))
142
+ const componentScripts = scripts.filter(s => !s.src.includes('barefoot.js'))
143
+ const finalScripts = [
144
+ ...(barefootScript ? [barefootScript] : []),
145
+ ...stubScripts.values(),
146
+ ...componentScripts.reverse(),
147
+ ]
148
+
149
+ return (
150
+ <Fragment>
151
+ {finalScripts.map(({ src }) => (
152
+ <script type="module" src={src} />
153
+ ))}
154
+ </Fragment>
155
+ )
156
+ } catch {
157
+ // Context unavailable (e.g., not using jsxRenderer)
158
+ return null
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Walk stub-rewrite edges from each manifest entry in `roots`,
164
+ * returning the script URLs for every transitively reachable
165
+ * `.client.js` in **DFS post-order** — every dep precedes its
166
+ * dependent in iteration order. Skips any name already present in
167
+ * `excluded` (these have a script tag elsewhere). Mutates `excluded`
168
+ * to record every dep that's been resolved so the caller can pass
169
+ * it to the next SSR pass without double-emitting. Exported for tests.
170
+ *
171
+ * Typical call shape: `roots` ⊆ `excluded`. The caller passes the
172
+ * set of components whose SSR function already pushed a `<script>`
173
+ * (`bfOutputScripts`) as BOTH arguments — "these are already
174
+ * emitted, now walk their stubDeps." A `roots` value already in
175
+ * `excluded` is still walked (we need its `stubDeps`); a `dep`
176
+ * already in `excluded` is recorded as visited but not re-emitted.
177
+ *
178
+ * Why post-order: `<script type="module">` tags evaluate in document
179
+ * order with microtask checkpoints between them, so the first
180
+ * script's `hydrate()`-scheduled walk fires before any later
181
+ * module loads. A chain A→B→C must ship C, B, then A's bundle —
182
+ * BFS order (B, C) would let B's hydration call
183
+ * `createComponent('C', ...)` against an empty registry. Post-order
184
+ * also handles DAG edges like A→B, A→C, C→B correctly
185
+ * (deepest-first, dependencies-first).
186
+ *
187
+ * Cycle-safe: a visited set short-circuits any A → B → A loop.
188
+ */
189
+ export function collectStubDepScripts(
190
+ manifest: BarefootBuildManifest,
191
+ base: string,
192
+ roots: Iterable<string>,
193
+ excluded: Set<string>,
194
+ ): Map<string, CollectedScript> {
195
+ const result = new Map<string, CollectedScript>()
196
+ const prefix = base.endsWith('/') ? base : base + '/'
197
+ const visited = new Set<string>()
198
+
199
+ function visit(name: string): void {
200
+ if (name === '__barefoot__') return
201
+ if (visited.has(name)) return
202
+ visited.add(name)
203
+ const entry = manifest[name]
204
+ if (entry?.stubDeps) {
205
+ // Recurse FIRST so deps are emitted before this node — that's
206
+ // what produces post-order. Cycles short-circuit at the
207
+ // `visited.has(name)` check at the top of each call.
208
+ for (const dep of entry.stubDeps) visit(dep)
209
+ }
210
+ if (excluded.has(name)) return
211
+ excluded.add(name)
212
+ if (entry?.clientJs) {
213
+ const src = prefix + relPathFromComponentsBase(entry.clientJs)
214
+ result.set(name, { src })
215
+ }
216
+ }
217
+
218
+ for (const name of roots) visit(name)
219
+ return result
220
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Hono test renderer
3
+ *
4
+ * Compiles JSX source with HonoAdapter and renders to HTML via Hono's app.request().
5
+ * Used by adapter-tests conformance runner.
6
+ */
7
+
8
+ import { compileJSX } from '@barefootjs/jsx'
9
+ import type { TemplateAdapter } from '@barefootjs/jsx'
10
+ import { Hono } from 'hono'
11
+ import { mkdir, rm } from 'node:fs/promises'
12
+ import { resolve } from 'node:path'
13
+
14
+ // Place temp files inside the hono package so hono/jsx resolves correctly
15
+ const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
16
+
17
+ export interface RenderOptions {
18
+ /** JSX source code */
19
+ source: string
20
+ /** Template adapter to use */
21
+ adapter: TemplateAdapter
22
+ /** Props to inject (optional) */
23
+ props?: Record<string, unknown>
24
+ /** Additional component files (filename → source) */
25
+ components?: Record<string, string>
26
+ /**
27
+ * Explicit component to render when the source declares multiple
28
+ * exports. When omitted, the first function-valued export in
29
+ * `Object.keys(mod)` iteration order is picked — that order is
30
+ * alphabetical for dynamically imported ES modules in Bun/V8, so
31
+ * relying on declaration order can pick the wrong component
32
+ * (e.g. `PropsReactivityComparison` before `ReactiveProps`).
33
+ */
34
+ componentName?: string
35
+ }
36
+
37
+ export async function renderHonoComponent(options: RenderOptions): Promise<string> {
38
+ const { source, adapter, props, components, componentName: requestedName } = options
39
+
40
+ // Compile child components first
41
+ const childCodes: string[] = []
42
+ const componentKeys = new Set<string>()
43
+ if (components) {
44
+ for (const [filename, childSource] of Object.entries(components)) {
45
+ componentKeys.add(filename)
46
+ const childResult = compileJSX(childSource, filename, { adapter })
47
+ const childErrors = childResult.errors.filter(e => e.severity === 'error')
48
+ if (childErrors.length > 0) {
49
+ throw new Error(`Compilation errors in ${filename}:\n${childErrors.map(e => e.message).join('\n')}`)
50
+ }
51
+ const childTemplate = childResult.files.find(f => f.type === 'markedTemplate')
52
+ if (!childTemplate) throw new Error(`No marked template for ${filename}`)
53
+ // Strip export keywords so only the parent component is exported
54
+ const localCode = childTemplate.content.replace(/\bexport\s+(default\s+)?/g, '')
55
+ childCodes.push(localCode)
56
+ }
57
+ }
58
+
59
+ // Compile parent source
60
+ const result = compileJSX(source, 'component.tsx', { adapter })
61
+
62
+ const errors = result.errors.filter(e => e.severity === 'error')
63
+ if (errors.length > 0) {
64
+ throw new Error(`Compilation errors:\n${errors.map(e => e.message).join('\n')}`)
65
+ }
66
+
67
+ const templateFile = result.files.find(f => f.type === 'markedTemplate')
68
+ if (!templateFile) throw new Error('No marked template in compile output')
69
+
70
+ let parentCode = templateFile.content
71
+ // Strip import lines that reference component files
72
+ if (componentKeys.size > 0) {
73
+ parentCode = parentCode
74
+ .split('\n')
75
+ .filter(line => {
76
+ const importMatch = line.match(/^\s*import\s+.*from\s+['"](.+?)['"]/)
77
+ if (!importMatch) return true
78
+ const importPath = importMatch[1]
79
+ // Match against component keys: './badge' matches './badge.tsx'
80
+ for (const key of componentKeys) {
81
+ const keyWithoutExt = key.replace(/\.tsx?$/, '')
82
+ if (importPath === keyWithoutExt || importPath === key) return false
83
+ }
84
+ return true
85
+ })
86
+ .join('\n')
87
+ }
88
+
89
+ // Combine: JSX pragma + child compiled functions + parent compiled code
90
+ const codeParts = ['/** @jsxImportSource hono/jsx */']
91
+ for (const childCode of childCodes) {
92
+ codeParts.push(childCode)
93
+ }
94
+ codeParts.push(parentCode)
95
+ const code = codeParts.join('\n')
96
+
97
+ await mkdir(RENDER_TEMP_DIR, { recursive: true })
98
+ // Unique filename per render to avoid Bun's process-level module cache
99
+ // (bun#12371: re-importing the same path returns stale module)
100
+ const tempFile = resolve(
101
+ RENDER_TEMP_DIR,
102
+ `render-${Date.now()}-${Math.random().toString(36).slice(2)}.tsx`,
103
+ )
104
+ await Bun.write(tempFile, code)
105
+
106
+ try {
107
+ const mod = await import(tempFile)
108
+
109
+ // Explicit `componentName` wins; otherwise pick the first
110
+ // function-valued export. `Object.keys` for dynamically imported
111
+ // modules iterates alphabetically in Bun/V8, so the fallback can
112
+ // surprise multi-component files — pass `componentName` to pin.
113
+ let resolvedName: string | undefined = requestedName
114
+ if (resolvedName) {
115
+ if (typeof mod[resolvedName] !== 'function') {
116
+ const available = Object.keys(mod).filter(k => typeof mod[k] === 'function')
117
+ throw new Error(
118
+ `Requested component "${resolvedName}" not found in compiled module. Available: ${available.join(', ')}`,
119
+ )
120
+ }
121
+ } else {
122
+ resolvedName = Object.keys(mod).find(k => typeof mod[k] === 'function')
123
+ if (!resolvedName) throw new Error('No component function found in compiled module')
124
+ }
125
+
126
+ const Component = mod[resolvedName]
127
+
128
+ // Render using Hono's app.request()
129
+ const app = new Hono()
130
+ app.get('/', (c) =>
131
+ c.html(Component({ __instanceId: 'test', __bfChild: false, ...props })),
132
+ )
133
+
134
+ const res = await app.request('/')
135
+ if (!res.ok) {
136
+ const body = await res.text()
137
+ throw new Error(`Render failed with status ${res.status}: ${body}`)
138
+ }
139
+ return await res.text()
140
+ } finally {
141
+ await rm(tempFile, { force: true }).catch(() => {})
142
+ }
143
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { raw } from 'hono/html'
2
+
3
+ /**
4
+ * Output HTML comment marker for conditional reconciliation.
5
+ * Same signature as Go template bfComment function.
6
+ */
7
+ export function bfComment(key: string) {
8
+ return raw(`<!--bf-${key}-->`)
9
+ }
10
+
11
+ /**
12
+ * Output opening comment marker for reactive text expressions.
13
+ * Renders <!--bf:slotId-->
14
+ */
15
+ export function bfText(slotId: string) {
16
+ return raw(`<!--bf:${slotId}-->`)
17
+ }
18
+
19
+ /**
20
+ * Output closing comment marker for reactive text expressions.
21
+ * Renders <!--/-->
22
+ */
23
+ export function bfTextEnd() {
24
+ return raw('<!--/-->')
25
+ }
26
+