@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
package/package.json ADDED
@@ -0,0 +1,116 @@
1
+ {
2
+ "name": "@barefootjs/hono",
3
+ "version": "0.1.0",
4
+ "description": "Hono integration for BarefootJS",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./adapter": {
14
+ "types": "./dist/adapter/index.d.ts",
15
+ "import": "./dist/adapter/index.js"
16
+ },
17
+ "./scripts": {
18
+ "types": "./dist/scripts.d.ts",
19
+ "import": "./dist/scripts.js"
20
+ },
21
+ "./portals": {
22
+ "types": "./dist/portals.d.ts",
23
+ "import": "./dist/portals.js"
24
+ },
25
+ "./portal": {
26
+ "types": "./dist/portal-ssr.d.ts",
27
+ "import": "./dist/portal-ssr.js"
28
+ },
29
+ "./dialog-context": {
30
+ "types": "./dist/dialog-context.d.ts",
31
+ "import": "./dist/dialog-context.js"
32
+ },
33
+ "./client-shim": {
34
+ "types": "./dist/client-shim.d.ts",
35
+ "import": "./dist/client-shim.js"
36
+ },
37
+ "./preload": {
38
+ "types": "./dist/preload.d.ts",
39
+ "import": "./dist/preload.js"
40
+ },
41
+ "./dev": {
42
+ "types": "./dist/dev.d.ts",
43
+ "import": "./dist/dev.js"
44
+ },
45
+ "./dev-worker": {
46
+ "types": "./dist/dev-worker.d.ts",
47
+ "import": "./dist/dev-worker.js"
48
+ },
49
+ "./jsx/jsx-runtime": {
50
+ "types": "./dist/jsx/jsx-runtime/index.d.ts",
51
+ "import": "./dist/jsx/jsx-runtime/index.js"
52
+ },
53
+ "./jsx/jsx-dev-runtime": {
54
+ "types": "./dist/jsx/jsx-dev-runtime/index.d.ts",
55
+ "import": "./dist/jsx/jsx-dev-runtime/index.js"
56
+ },
57
+ "./async": {
58
+ "types": "./dist/async.d.ts",
59
+ "import": "./dist/async.js"
60
+ },
61
+ "./utils": {
62
+ "types": "./dist/utils.d.ts",
63
+ "import": "./dist/utils.js"
64
+ },
65
+ "./test-render": {
66
+ "bun": "./src/test-render.ts"
67
+ },
68
+ "./build": {
69
+ "types": "./dist/build.d.ts",
70
+ "import": "./dist/build.js"
71
+ },
72
+ "./app": {
73
+ "types": "./dist/app.d.ts",
74
+ "import": "./dist/app.js"
75
+ }
76
+ },
77
+ "files": [
78
+ "dist",
79
+ "src"
80
+ ],
81
+ "scripts": {
82
+ "build": "bun run build:js && bun run build:types",
83
+ "build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/scripts.tsx ./src/portals.tsx ./src/portal-ssr.tsx ./src/dialog-context.tsx ./src/client-shim.ts ./src/preload.tsx ./src/dev.tsx ./src/dev-worker.ts ./src/jsx/jsx-runtime/index.ts ./src/jsx/jsx-dev-runtime/index.ts ./src/async.tsx ./src/utils.ts ./src/build.ts ./src/app.ts --root ./src --outdir ./dist --format esm --external hono --external @barefootjs/client --external @barefootjs/jsx --external @barefootjs/shared",
84
+ "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
85
+ "test": "bun test",
86
+ "clean": "rm -rf dist",
87
+ "prepack": "node ../../scripts/swap-publish-config.mjs pack",
88
+ "postpack": "node ../../scripts/swap-publish-config.mjs unpack"
89
+ },
90
+ "keywords": [
91
+ "hono",
92
+ "jsx",
93
+ "barefoot",
94
+ "ssr"
95
+ ],
96
+ "author": "kobaken <kentafly88@gmail.com>",
97
+ "license": "MIT",
98
+ "repository": {
99
+ "type": "git",
100
+ "url": "https://github.com/piconic-ai/barefootjs",
101
+ "directory": "packages/adapter-hono"
102
+ },
103
+ "peerDependencies": {
104
+ "@barefootjs/client": "workspace:*",
105
+ "@barefootjs/jsx": "workspace:*",
106
+ "@barefootjs/shared": "workspace:*",
107
+ "hono": "^4.0.0"
108
+ },
109
+ "devDependencies": {
110
+ "@barefootjs/adapter-tests": "workspace:*",
111
+ "@types/jsdom": "^27.0.0",
112
+ "hono": "^4.6.0",
113
+ "jsdom": "^27.3.0",
114
+ "typescript": "^5.0.0"
115
+ }
116
+ }
@@ -0,0 +1,106 @@
1
+ /** @jsxImportSource hono/jsx */
2
+ /**
3
+ * BfAsync Component Tests
4
+ *
5
+ * Verifies that BfAsync properly wraps Hono's Suspense
6
+ * for streaming SSR with BarefootJS components.
7
+ */
8
+ import { describe, it, expect } from 'bun:test'
9
+ import { renderToReadableStream } from 'hono/jsx/streaming'
10
+ import type { HtmlEscapedString } from 'hono/utils/html'
11
+ import { BfAsync } from '../async'
12
+
13
+ async function collectStream(stream: ReadableStream): Promise<string[]> {
14
+ const chunks: string[] = []
15
+ const decoder = new TextDecoder()
16
+ for await (const chunk of stream as AsyncIterable<Uint8Array>) {
17
+ chunks.push(decoder.decode(chunk))
18
+ }
19
+ return chunks
20
+ }
21
+
22
+ describe('BfAsync', () => {
23
+ it('streams fallback then resolved content', async () => {
24
+ const AsyncContent = () => {
25
+ return new Promise<HtmlEscapedString>((resolve) =>
26
+ setTimeout(() => resolve(<div>Loaded!</div>), 10)
27
+ )
28
+ }
29
+
30
+ const stream = renderToReadableStream(
31
+ <BfAsync fallback={<p>Loading...</p>}>
32
+ <AsyncContent />
33
+ </BfAsync>
34
+ )
35
+
36
+ const chunks = await collectStream(stream)
37
+
38
+ // First chunk has fallback
39
+ expect(chunks[0]).toContain('Loading...')
40
+ // Later chunk has resolved content
41
+ const fullOutput = chunks.join('')
42
+ expect(fullOutput).toContain('Loaded!')
43
+ })
44
+
45
+ it('renders multiple async boundaries independently', async () => {
46
+ const SlowContent = ({ id }: { id: number }) => {
47
+ return new Promise<HtmlEscapedString>((resolve) =>
48
+ setTimeout(() => resolve(<span>Content {id}</span>), 10 * id)
49
+ )
50
+ }
51
+
52
+ const stream = renderToReadableStream(
53
+ <div>
54
+ <BfAsync fallback={<p>Loading 1...</p>}>
55
+ <SlowContent id={1} />
56
+ </BfAsync>
57
+ <BfAsync fallback={<p>Loading 2...</p>}>
58
+ <SlowContent id={2} />
59
+ </BfAsync>
60
+ </div>
61
+ )
62
+
63
+ const chunks = await collectStream(stream)
64
+ const fullOutput = chunks.join('')
65
+
66
+ expect(fullOutput).toContain('Content 1')
67
+ expect(fullOutput).toContain('Content 2')
68
+ })
69
+
70
+ it('preserves BarefootJS hydration markers in async content', async () => {
71
+ const AsyncComponent = () => {
72
+ return new Promise<HtmlEscapedString>((resolve) =>
73
+ setTimeout(() => resolve(
74
+ <div bf-s="Counter_abc" bf-p='{"count":0}'>0</div>
75
+ ), 10)
76
+ )
77
+ }
78
+
79
+ const stream = renderToReadableStream(
80
+ <BfAsync fallback={<p>Loading...</p>}>
81
+ <AsyncComponent />
82
+ </BfAsync>
83
+ )
84
+
85
+ const chunks = await collectStream(stream)
86
+ const fullOutput = chunks.join('')
87
+
88
+ expect(fullOutput).toContain('bf-s="Counter_abc"')
89
+ expect(fullOutput).toContain('bf-p=')
90
+ })
91
+
92
+ it('renders synchronous children without streaming', async () => {
93
+ const SyncContent = () => <div>Already here</div>
94
+
95
+ const stream = renderToReadableStream(
96
+ <BfAsync fallback={<p>Loading...</p>}>
97
+ <SyncContent />
98
+ </BfAsync>
99
+ )
100
+
101
+ const chunks = await collectStream(stream)
102
+
103
+ // Synchronous content should be in the first chunk (no fallback needed)
104
+ expect(chunks[0]).toContain('Already here')
105
+ })
106
+ })
@@ -0,0 +1,135 @@
1
+ /** @jsxImportSource hono/jsx */
2
+ /**
3
+ * BfScripts `entryRoots` prop (#1431).
4
+ *
5
+ * Background: `<BfScripts manifest base />` walks `stubDeps` only for
6
+ * components whose SSR template ran during the request (the
7
+ * `bfOutputScripts` set populated by `addScriptCollection`'s injected
8
+ * snippet). A page that renders a `'use client'` component via a
9
+ * manual `<script type="module">import "X.client.js"; render(root, "X", props)`
10
+ * bootstrap — instead of SSR'ing `<X />` — never lands `X` in
11
+ * `bfOutputScripts`. BfScripts has no root to walk, and `X`'s
12
+ * `stubDeps` (children reached only through the imperative
13
+ * `createComponent` stub rewrite, #1240) never ship as `<script>` tags.
14
+ *
15
+ * `entryRoots` lets the caller declare those manually-mounted entry
16
+ * components so their `stubDeps` get walked. The caller's own inline
17
+ * `<script type="module">` handles `X.client.js`, so `X` itself stays
18
+ * in the `excluded` set — only its deps get emitted.
19
+ *
20
+ * Real-world: piconic-ai/desk #86 — `DeskCanvasPage` skips SSR of
21
+ * `<DeskCanvas />` (its template body calls `useYjs()` / `fetch()` —
22
+ * fatal on Cloudflare Workers) and manually mounts via the inline
23
+ * script. Without `entryRoots`, `IssueCardNodeImpl.client.js` (reached
24
+ * via `DeskCanvas → IssueCardNode (.ts) → IssueCardNodeImpl (.tsx)`)
25
+ * doesn't ship, and the runtime renders the red `[IssueCardNodeImpl]`
26
+ * placeholder for every card.
27
+ */
28
+
29
+ import { describe, test, expect } from 'bun:test'
30
+ import { Hono } from 'hono'
31
+ import { jsxRenderer } from 'hono/jsx-renderer'
32
+ import { BfScripts } from '../scripts'
33
+ import type { BarefootBuildManifest } from '../app'
34
+
35
+ const MANIFEST: BarefootBuildManifest = {
36
+ __barefoot__: { clientJs: 'components/barefoot.js' },
37
+ 'canvas/DeskCanvas': {
38
+ clientJs: 'components/canvas/DeskCanvas.client.js',
39
+ stubDeps: ['canvas/nodes/IssueCardNodeImpl'],
40
+ },
41
+ 'canvas/nodes/IssueCardNodeImpl': {
42
+ clientJs: 'components/canvas/nodes/IssueCardNodeImpl.client.js',
43
+ },
44
+ 'canvas/catalog/IssueCardCatalog': {
45
+ clientJs: 'components/canvas/catalog/IssueCardCatalog.client.js',
46
+ stubDeps: ['canvas/nodes/IssueCardNodeImpl'],
47
+ },
48
+ }
49
+
50
+ function mountApp(entryRoots?: string[]) {
51
+ const app = new Hono()
52
+ app.use(
53
+ '*',
54
+ jsxRenderer(({ children }) => (
55
+ <html lang="en">
56
+ <body>
57
+ {children}
58
+ <BfScripts manifest={MANIFEST} base="/static/components/" entryRoots={entryRoots} />
59
+ </body>
60
+ </html>
61
+ )),
62
+ )
63
+ app.get('/', (c) => c.render(<div id="canvas-root" />))
64
+ return app
65
+ }
66
+
67
+ describe('BfScripts entryRoots (#1431)', () => {
68
+ test('omitted entryRoots → manually-mounted components miss their stubDeps (baseline regression)', async () => {
69
+ const res = await mountApp().fetch(new Request('http://localhost/'))
70
+ const html = await res.text()
71
+ // DeskCanvas isn't in `bfOutputScripts` (no SSR), and we passed no
72
+ // entryRoots, so its stubDep IssueCardNodeImpl is NOT emitted.
73
+ expect(html).not.toContain('IssueCardNodeImpl.client.js')
74
+ })
75
+
76
+ test('entryRoots: ["canvas/DeskCanvas"] → walks its stubDeps even though it was not SSR-rendered', async () => {
77
+ const res = await mountApp(['canvas/DeskCanvas']).fetch(new Request('http://localhost/'))
78
+ const html = await res.text()
79
+ // The whole point of #1431: the manually-mounted root's stubDeps
80
+ // get emitted as `<script type="module" src=...>` tags.
81
+ expect(html).toContain('/static/components/canvas/nodes/IssueCardNodeImpl.client.js')
82
+ })
83
+
84
+ test('entryRoots does NOT emit a script for the root itself (caller already does that via the inline mount)', async () => {
85
+ const res = await mountApp(['canvas/DeskCanvas']).fetch(new Request('http://localhost/'))
86
+ const html = await res.text()
87
+ // The caller's own inline `<script type="module">import "DeskCanvas.client.js"`
88
+ // already loads the root; BfScripts must NOT add a duplicate
89
+ // `<script src=...DeskCanvas.client.js>` that would re-run hydration.
90
+ expect(html).not.toContain('/static/components/canvas/DeskCanvas.client.js')
91
+ })
92
+
93
+ test('multiple entryRoots are all walked', async () => {
94
+ const res = await mountApp(['canvas/DeskCanvas', 'canvas/catalog/IssueCardCatalog']).fetch(
95
+ new Request('http://localhost/'),
96
+ )
97
+ const html = await res.text()
98
+ // Both roots share the same stubDep; it must be emitted (once is fine
99
+ // because the underlying walker dedupes against `excluded`/`visited`).
100
+ const occurrences = html.match(/IssueCardNodeImpl\.client\.js/g) ?? []
101
+ expect(occurrences.length).toBe(1)
102
+ })
103
+
104
+ test('an entry name listed in BOTH outputSet and entryRoots is not double-emitted as a script tag', async () => {
105
+ // Simulate the case where the caller's manually-mounted root ALSO
106
+ // happens to be SSR'd elsewhere (unusual but legal). The script
107
+ // for the root itself stays out (the caller's inline mount handles
108
+ // it / outputSet excludes it), and the stubDep emits once.
109
+ const app = new Hono()
110
+ app.use(
111
+ '*',
112
+ jsxRenderer(({ children }) => (
113
+ <html lang="en">
114
+ <body>
115
+ {children}
116
+ <BfScripts manifest={MANIFEST} base="/static/components/" entryRoots={['canvas/DeskCanvas']} />
117
+ </body>
118
+ </html>
119
+ )),
120
+ )
121
+ app.get('/', (c) => {
122
+ // Simulate addScriptCollection having pushed DeskCanvas during SSR.
123
+ const set: Set<string> = c.get('bfOutputScripts') || new Set()
124
+ set.add('canvas/DeskCanvas')
125
+ c.set('bfOutputScripts', set)
126
+ return c.render(<div id="canvas-root" />)
127
+ })
128
+ const res = await app.fetch(new Request('http://localhost/'))
129
+ const html = await res.text()
130
+ const deskMatches = html.match(/DeskCanvas\.client\.js/g) ?? []
131
+ const implMatches = html.match(/IssueCardNodeImpl\.client\.js/g) ?? []
132
+ expect(deskMatches.length).toBe(0)
133
+ expect(implMatches.length).toBe(1)
134
+ })
135
+ })
@@ -0,0 +1,299 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { addScriptCollection, createConfig, maskComments } from '../build'
3
+
4
+ // ── addScriptCollection ──────────────────────────────────────────────
5
+
6
+ describe('addScriptCollection', () => {
7
+ test('injects imports and script collector into exported function', () => {
8
+ const input = `import { jsx } from 'hono/jsx'
9
+
10
+ export function Counter(props: CounterProps) {
11
+ return (<div>hello</div>)
12
+ }`
13
+
14
+ const result = addScriptCollection(input, 'Counter', 'Counter.client.js')
15
+
16
+ expect(result).toContain("import { useRequestContext } from 'hono/jsx-renderer'")
17
+ expect(result).toContain("import { Fragment } from 'hono/jsx'")
18
+ expect(result).toContain('__bfWrap')
19
+ expect(result).toContain('bfCollectedScripts')
20
+ expect(result).toContain("'Counter'")
21
+ expect(result).toContain('Counter.client.js')
22
+ })
23
+
24
+ test('preserves content when no import match', () => {
25
+ const input = 'const x = 1'
26
+ // Should not throw, returns unchanged or minimally modified
27
+ const result = addScriptCollection(input, 'Test', 'Test.client.js')
28
+ expect(result).toBeDefined()
29
+ })
30
+
31
+ test('uses custom scriptBasePath', () => {
32
+ const input = `import { jsx } from 'hono/jsx'
33
+
34
+ export function Counter() {
35
+ return (<div>hello</div>)
36
+ }`
37
+
38
+ const result = addScriptCollection(input, 'Counter', 'Counter.client.js', '/assets/js/')
39
+ expect(result).toContain('/assets/js/barefoot.js')
40
+ expect(result).toContain('/assets/js/Counter.client.js')
41
+ expect(result).not.toContain('/static/components/')
42
+ })
43
+
44
+ test('normalizes scriptBasePath without trailing slash', () => {
45
+ const input = `import { jsx } from 'hono/jsx'
46
+
47
+ export function Counter() {
48
+ return (<div>hello</div>)
49
+ }`
50
+
51
+ const result = addScriptCollection(input, 'Counter', 'Counter.client.js', '/assets/js')
52
+ expect(result).toContain('/assets/js/barefoot.js')
53
+ expect(result).toContain('/assets/js/Counter.client.js')
54
+ })
55
+
56
+ test('ignores `function PascalCase(` text inside JSDoc / inline comments (#1236)', () => {
57
+ // A docstring example previously triggered a bogus insertion when
58
+ // the function-pattern regex matched inside the comment, after
59
+ // which the paren counter walked into the wrong `{` and corrupted
60
+ // a real function further down.
61
+ const input = `import { jsx } from 'hono/jsx'
62
+
63
+ export interface MyProps {
64
+ /**
65
+ * Example imperative signature for the docs:
66
+ * function MyNode(this: HTMLElement, props): void
67
+ */
68
+ nodeTypes?: Record<string, unknown>
69
+ }
70
+
71
+ // also: function FakeFromLineComment(this: any) {} should not match
72
+
73
+ export function Counter(props: MyProps) {
74
+ return (<div>hello</div>)
75
+ }`
76
+
77
+ const result = addScriptCollection(input, 'Counter', 'Counter.client.js')
78
+
79
+ // The real Counter must be wrapped exactly once.
80
+ const collectorCount = (result.match(/let __bfInlineScripts/g) || []).length
81
+ expect(collectorCount).toBe(1)
82
+
83
+ // And the collector must land inside Counter's body, immediately after
84
+ // its opening brace — the same shape the destructured-params test
85
+ // above verifies. If the comment matches had fired, the collector
86
+ // would be misplaced inside the interface or the JSDoc.
87
+ const counterBodyMatch = result.match(/function Counter\(props: MyProps\)\s*\{/)
88
+ expect(counterBodyMatch).not.toBeNull()
89
+ if (counterBodyMatch) {
90
+ const after = result.slice(result.indexOf(counterBodyMatch[0]) + counterBodyMatch[0].length)
91
+ expect(after.trimStart().startsWith('let __bfInlineScripts')).toBe(true)
92
+ }
93
+ })
94
+
95
+ test('still finds function declarations after JSX text with apostrophes (#1236)', () => {
96
+ // Defensive: an unbalanced `'` inside JSX text content (e.g.
97
+ // `How's it going`) used to cause an over-aggressive string-mask
98
+ // to blank everything until the next stray `'`, hiding later
99
+ // function declarations from the regex. Keep apostrophe-containing
100
+ // JSX text unmasked so subsequent functions still get instrumented.
101
+ const input = `import { jsx } from 'hono/jsx'
102
+
103
+ export function Greeting() {
104
+ return (<div>Hey! How's it going?</div>)
105
+ }
106
+
107
+ export function Footer() {
108
+ return (<div>Bye</div>)
109
+ }`
110
+
111
+ const result = addScriptCollection(input, 'page', 'page-abc.js')
112
+
113
+ // BOTH functions must be wrapped.
114
+ const collectorCount = (result.match(/let __bfInlineScripts/g) || []).length
115
+ expect(collectorCount).toBe(2)
116
+ expect(result).toMatch(/function Greeting\(\)\s*\{\s*\n?\s*let __bfInlineScripts/)
117
+ expect(result).toMatch(/function Footer\(\)\s*\{\s*\n?\s*let __bfInlineScripts/)
118
+ })
119
+
120
+ test('handles destructured params with arrow function defaults', () => {
121
+ const input = `import { jsx } from 'hono/jsx'
122
+
123
+ export function Textarea({ className = '', onInput = () => {}, onChange = () => {}, ...props }: TextareaProps) {
124
+ return (<textarea class={className} {...props} />)
125
+ }`
126
+
127
+ const result = addScriptCollection(input, 'textarea', 'textarea-abc123.js')
128
+
129
+ // Script collector must be inside the Textarea function body, NOT inside a default param
130
+ expect(result).toContain('__bfInlineScripts')
131
+ expect(result).toContain('__bfWrap')
132
+
133
+ // Verify __bfInlineScripts is declared AFTER the function opening brace,
134
+ // not inside an arrow function default value
135
+ const funcBodyMatch = result.match(/\.\.\.props\s*\}\s*:\s*TextareaProps\)\s*\{/)
136
+ expect(funcBodyMatch).not.toBeNull()
137
+ // After the function body opening, the next thing should be the script collector
138
+ if (funcBodyMatch) {
139
+ const afterFuncBody = result.slice(result.indexOf(funcBodyMatch[0]) + funcBodyMatch[0].length)
140
+ expect(afterFuncBody.trimStart().startsWith('let __bfInlineScripts')).toBe(true)
141
+ }
142
+ })
143
+ })
144
+
145
+ // ── createConfig() factory ──────────────────────────────────────────
146
+
147
+ describe('createConfig()', () => {
148
+ test('creates config with HonoAdapter', () => {
149
+ const config = createConfig()
150
+ expect(config.adapter.name).toBe('hono')
151
+ })
152
+
153
+ test('sets transformMarkedTemplate by default', () => {
154
+ const config = createConfig()
155
+ expect(typeof config.transformMarkedTemplate).toBe('function')
156
+ })
157
+
158
+ test('disables transformMarkedTemplate when scriptCollection is false', () => {
159
+ const config = createConfig({ scriptCollection: false })
160
+ expect(config.transformMarkedTemplate).toBeUndefined()
161
+ })
162
+
163
+ test('uses custom scriptBasePath in transformMarkedTemplate', () => {
164
+ const config = createConfig({ scriptBasePath: '/assets/js/' })
165
+ const input = `import { jsx } from 'hono/jsx'
166
+
167
+ export function Counter() {
168
+ return (<div>hello</div>)
169
+ }`
170
+ const result = config.transformMarkedTemplate!(input, 'Counter', 'Counter.client.js')
171
+ expect(result).toContain('/assets/js/barefoot.js')
172
+ expect(result).toContain('/assets/js/Counter.client.js')
173
+ expect(result).not.toContain('/static/components/')
174
+ })
175
+
176
+ test('uses default scriptBasePath in transformMarkedTemplate', () => {
177
+ const config = createConfig()
178
+ const input = `import { jsx } from 'hono/jsx'
179
+
180
+ export function Counter() {
181
+ return (<div>hello</div>)
182
+ }`
183
+ const result = config.transformMarkedTemplate!(input, 'Counter', 'Counter.client.js')
184
+ expect(result).toContain('/static/components/barefoot.js')
185
+ expect(result).toContain('/static/components/Counter.client.js')
186
+ })
187
+
188
+ test('passes through build options', () => {
189
+ const config = createConfig({
190
+ components: ['src'],
191
+ outDir: 'build',
192
+ minify: true,
193
+ contentHash: true,
194
+ })
195
+ expect(config.components).toEqual(['src'])
196
+ expect(config.outDir).toBe('build')
197
+ expect(config.minify).toBe(true)
198
+ expect(config.contentHash).toBe(true)
199
+ })
200
+
201
+ test('passes through externals and externalsBasePath', () => {
202
+ const externals = { react: { url: 'https://cdn.example.com/react.js' } }
203
+ const config = createConfig({
204
+ externals,
205
+ externalsBasePath: '/cdn/',
206
+ })
207
+ expect(config.externals).toBe(externals)
208
+ expect(config.externalsBasePath).toBe('/cdn/')
209
+ })
210
+
211
+ test('externals and externalsBasePath default to undefined', () => {
212
+ const config = createConfig()
213
+ expect(config.externals).toBeUndefined()
214
+ expect(config.externalsBasePath).toBeUndefined()
215
+ })
216
+
217
+ test('passes through localImportPrefixes', () => {
218
+ const config = createConfig({ localImportPrefixes: ['@/', '@ui/'] })
219
+ expect(config.localImportPrefixes).toEqual(['@/', '@ui/'])
220
+ })
221
+
222
+ test('localImportPrefixes defaults to undefined', () => {
223
+ const config = createConfig()
224
+ expect(config.localImportPrefixes).toBeUndefined()
225
+ })
226
+ })
227
+
228
+ // ── maskComments ────────────────────────────────────────────────────
229
+
230
+ describe('maskComments', () => {
231
+ test('preserves length and newlines so indices stay valid in the original', () => {
232
+ const src = '/* foo */ x // bar\ny\n/* multi\nline */ z'
233
+ const masked = maskComments(src)
234
+ expect(masked).toHaveLength(src.length)
235
+ // Every newline position in the original is preserved in the masked
236
+ // copy, so line counts (and therefore line:column error reporting
237
+ // from downstream tools) line up.
238
+ const newlinePositions = (s: string) => [...s].flatMap((c, i) => c === '\n' ? [i] : [])
239
+ expect(newlinePositions(masked)).toEqual(newlinePositions(src))
240
+ })
241
+
242
+ test('blanks JSDoc / block comments including any quotes inside', () => {
243
+ const comment = "/** has 'apostrophe' inside */"
244
+ const tail = ' const x = 1'
245
+ const src = comment + tail
246
+ const masked = maskComments(src)
247
+ // The whole `/** ... */` is replaced with spaces; the apostrophes
248
+ // inside cannot re-open as strings later.
249
+ expect(masked).toBe(' '.repeat(comment.length) + tail)
250
+ })
251
+
252
+ test('blanks `//` line comments up to (but not including) the newline', () => {
253
+ const comment = '// ignored'
254
+ const tail = '\nconst x = 1'
255
+ const src = comment + tail
256
+ const masked = maskComments(src)
257
+ expect(masked).toBe(' '.repeat(comment.length) + tail)
258
+ })
259
+
260
+ test('handles unclosed block comment by masking through end of input', () => {
261
+ const src = 'a /* never closed\nfunction Real() {}'
262
+ const masked = maskComments(src)
263
+ // Without `*/`, the masker blanks to EOF. `function Real()` is
264
+ // hidden — this matches the JS lexer's behaviour for unterminated
265
+ // comments and is the conservative thing to do for the
266
+ // function-pattern regex.
267
+ expect(masked.startsWith('a ')).toBe(true)
268
+ expect(masked).not.toContain('function Real')
269
+ expect(masked).toHaveLength(src.length)
270
+ })
271
+
272
+ test('leaves comment-free code untouched (no false positives on JSX text)', () => {
273
+ // Plain code with no comment delimiters round-trips identically.
274
+ // JSX text with apostrophes (`How's`) is the hot path: a
275
+ // string-aware masker would treat the `'` as an open quote and
276
+ // blank the rest of the file, hiding later function declarations
277
+ // (#1236 follow-up).
278
+ const src = `export function Greeting() {\n return (<div>Hey! How's it going?</div>)\n}\nexport function Footer() {}`
279
+ expect(maskComments(src)).toBe(src)
280
+ })
281
+
282
+ test('KNOWN LIMITATION: `//` inside a string is still treated as a line comment', () => {
283
+ // Documented in `maskComments` jsdoc: this helper does not track
284
+ // string boundaries, so a `//` appearing inside a string literal
285
+ // is still treated as a comment delimiter. SSR template output
286
+ // (the only current caller) does not produce such cases, so the
287
+ // simpler implementation is acceptable. If a future caller can
288
+ // produce them, swap in a real lexer — this test will start to
289
+ // fail and force the conversation.
290
+ const prefix = `const u = "https:`
291
+ const blanked = `//example.com" ; const x = 1`
292
+ const src = prefix + blanked
293
+ const masked = maskComments(src)
294
+ // The `//` in `https://` is treated as a line-comment delimiter
295
+ // and the rest of the line gets blanked.
296
+ expect(masked).toBe(prefix + ' '.repeat(blanked.length))
297
+ expect(masked).toHaveLength(src.length)
298
+ })
299
+ })