@coherent.js/integrations 1.1.2 → 2.0.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/README.md CHANGED
@@ -12,6 +12,12 @@ Framework integration adapters for Coherent.js — bridges between your chosen H
12
12
  - `@coherent.js/integrations/remix` — Remix adapter (added in 1.0.0)
13
13
  - `@coherent.js/integrations/sveltekit` — SvelteKit adapter (added in 1.0.0)
14
14
 
15
+ **Stability:** the Astro, Remix and SvelteKit adapters are young. Their tests run a real
16
+ `astro build`, compile and server-render a component with the Svelte compiler, and server-render
17
+ the Remix wrapper with React, but the adapters have seen little production use. The Express,
18
+ Fastify and Koa adapters are tested over real HTTP requests, and the Next.js adapter with Web
19
+ `Request`/`Response` objects and `react-dom/server`.
20
+
15
21
  ## Migration from pre-1.0
16
22
 
17
23
  Each framework previously shipped as its own package (`@coherent.js/express`, etc.). Migrate by changing import paths:
@@ -32,6 +38,31 @@ pnpm add express # or fastify / koa / next / etc. — only the ones you use
32
38
 
33
39
  Framework peer dependencies are declared optional, so consumers only install the framework(s) they actually use.
34
40
 
41
+ ## Rendering components (Express, Fastify, Koa)
42
+
43
+ Render explicitly; plain objects are sent as JSON:
44
+
45
+ ```js
46
+ // Express
47
+ setupCoherent(app, { template: '<!DOCTYPE html>\n{{content}}' });
48
+ app.get('/', (req, res) => res.coherent(HomePage()));
49
+ app.get('/api/users', (req, res) => res.send({ users })); // JSON
50
+
51
+ // Fastify
52
+ await fastify.register(setupCoherent, { template });
53
+ fastify.get('/', async (request, reply) => reply.coherent(HomePage()));
54
+
55
+ // Koa
56
+ setupCoherent(app, { template });
57
+ router.get('/', (ctx) => ctx.coherent(HomePage()));
58
+ ```
59
+
60
+ `autoRender: true` restores the earlier behavior of rendering any
61
+ component-shaped object passed to `res.send` / returned from a Fastify handler
62
+ / assigned to `ctx.body`. It is off by default because "component-shaped"
63
+ means "has exactly one key", which matches JSON such as `{ ok: true }` or
64
+ `{ error: 'Invalid credentials' }` too.
65
+
35
66
  ## License
36
67
 
37
68
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coherent.js/integrations",
3
- "version": "1.1.2",
3
+ "version": "2.0.0",
4
4
  "description": "Framework integration adapters for Coherent.js: Express, Fastify, Koa, Next.js, Astro, Remix, SvelteKit.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,7 @@
13
13
  "default": "./src/fastify/index.js"
14
14
  },
15
15
  "./koa": {
16
+ "types": "./types/koa/index.d.ts",
16
17
  "default": "./src/koa/index.js"
17
18
  },
18
19
  "./nextjs": {
@@ -20,12 +21,19 @@
20
21
  "default": "./src/nextjs/index.js"
21
22
  },
22
23
  "./astro": {
24
+ "types": "./types/astro/index.d.ts",
23
25
  "default": "./src/astro/index.js"
24
26
  },
27
+ "./astro/server": {
28
+ "types": "./types/astro/server.d.ts",
29
+ "default": "./src/astro/server.js"
30
+ },
25
31
  "./remix": {
32
+ "types": "./types/remix/index.d.ts",
26
33
  "default": "./src/remix/index.js"
27
34
  },
28
35
  "./sveltekit": {
36
+ "types": "./types/sveltekit/index.d.ts",
29
37
  "default": "./src/sveltekit/index.js"
30
38
  }
31
39
  },
@@ -51,20 +59,16 @@
51
59
  "fastify-plugin": "^6.0.0"
52
60
  },
53
61
  "peerDependencies": {
54
- "@remix-run/server-runtime": ">=2.0.0",
62
+ "@coherent.js/core": "^2.0.0",
55
63
  "@sveltejs/kit": ">=2.0.0",
56
64
  "astro": ">=4.0.0",
57
65
  "express": ">=4.18.0 < 6.0.0",
58
66
  "fastify": ">=4.0.0 < 6.0.0",
59
67
  "koa": ">=2.13.0 < 4.0.0",
60
68
  "next": ">=13.0.0",
61
- "react": ">=18.0.0",
62
- "@coherent.js/core": "^1.1.2"
69
+ "react": ">=18.0.0"
63
70
  },
64
71
  "peerDependenciesMeta": {
65
- "@remix-run/server-runtime": {
66
- "optional": true
67
- },
68
72
  "@sveltejs/kit": {
69
73
  "optional": true
70
74
  },
@@ -30,7 +30,8 @@ export function createAstroIntegration(options = {}) {
30
30
  'astro:config:setup': ({ addRenderer, updateConfig }) => {
31
31
  addRenderer({
32
32
  name: '@coherent.js/integrations/astro',
33
- serverEntrypoint: '@coherent.js/integrations/astro',
33
+ // Astro imports this module's default export as the SSR renderer.
34
+ serverEntrypoint: '@coherent.js/integrations/astro/server',
34
35
  });
35
36
 
36
37
  if (options.hydrate) {
@@ -0,0 +1,12 @@
1
+ // src/astro/server.js
2
+ //
3
+ // Astro server entrypoint for @coherent.js/integrations/astro.
4
+ //
5
+ // `createAstroIntegration()` registers this module with `addRenderer({
6
+ // serverEntrypoint })`. Astro imports its *default export* as the SSR
7
+ // renderer (`{ name, check, renderToStaticMarkup }`), so it must have one —
8
+ // without it `astro build` fails with `"default" is not exported`.
9
+
10
+ import { createRenderer } from './index.js';
11
+
12
+ export default createRenderer();
@@ -3,54 +3,90 @@
3
3
  * Provides middleware and utilities for using Coherent.js with Express
4
4
  */
5
5
 
6
+ import { pathToFileURL } from 'node:url';
6
7
  import {
7
8
  render,
8
9
  importPeerDependency,
9
10
  renderWithTemplate,
10
- renderComponentFactory,
11
11
  isCoherentComponent
12
12
  } from '@coherent.js/core';
13
13
 
14
+ const DEFAULT_TEMPLATE = '<!DOCTYPE html>\n{{content}}';
15
+
14
16
  /**
15
- * Coherent.js Express middleware
16
- * Automatically renders Coherent.js components and handles errors
17
+ * Coherent.js Express middleware.
18
+ *
19
+ * Adds `res.coherent(component, renderOptions?)`, which renders a Coherent.js
20
+ * component (wrapped in `template`) and sends it as `text/html`. Rendering
21
+ * errors are forwarded to Express error handling, like `res.render` does.
22
+ *
23
+ * With `autoRender: true` it additionally overrides `res.send` so that any
24
+ * object that looks like a component is rendered to HTML. That detection is
25
+ * a heuristic -- every single-key object qualifies, so `res.send({ ok: true })`
26
+ * or `res.json({ users })` routed through `res.send` would be rendered as
27
+ * `<ok>` / `<users>` instead of serialized as JSON. It is off by default; only
28
+ * enable it for apps that never send single-key JSON objects.
17
29
  *
18
30
  * @param {Object} options - Configuration options
19
- * @param {boolean} options.enablePerformanceMonitoring - Enable performance monitoring
20
- * @param {string} options.template - HTML template with {{content}} placeholder
31
+ * @param {boolean} [options.enablePerformanceMonitoring=false] - Enable performance monitoring
32
+ * @param {string} [options.template] - HTML template with {{content}} placeholder
33
+ * @param {boolean} [options.autoRender=false] - Render component-shaped objects passed to `res.send`
21
34
  * @returns {Function} Express middleware function
22
35
  */
23
36
  export function coherentMiddleware(options = {}) {
24
37
  const {
25
38
  enablePerformanceMonitoring = false,
26
- template = '<!DOCTYPE html>\n{{content}}'
39
+ template = DEFAULT_TEMPLATE,
40
+ autoRender = false
27
41
  } = options;
28
42
 
29
43
  return (req, res, next) => {
30
44
  // Store original send method
31
45
  const originalSend = res.send;
32
46
 
33
- // Override send method to handle Coherent.js objects
34
- res.send = function(data) {
35
- // If data is a Coherent.js object (plain object with a single key), render it
36
- if (isCoherentComponent(data)) {
37
- try {
38
- // Use shared rendering utility
39
- const finalHtml = renderWithTemplate(data, { enablePerformanceMonitoring, template });
40
-
41
- // Set content type and send HTML
42
- res.set('Content-Type', 'text/html');
43
- return originalSend.call(this, finalHtml);
44
- } catch (_error) {
45
- console.error('Coherent.js rendering _error:', _error);
46
- return next(_error);
47
- }
47
+ // Explicit rendering: res.coherent(component, { template?, enablePerformanceMonitoring? })
48
+ res.coherent = function coherent(component, renderOptions = {}) {
49
+ let finalHtml;
50
+ try {
51
+ finalHtml = renderWithTemplate(component, {
52
+ enablePerformanceMonitoring: renderOptions.enablePerformanceMonitoring ?? enablePerformanceMonitoring,
53
+ template: renderOptions.template ?? template
54
+ });
55
+ } catch (_error) {
56
+ // Same contract as res.render(): hand the error to the app's error
57
+ // middleware. req.next is the router's current `next`, so this works
58
+ // from sync, async (Express 4 and 5) and callback code alike.
59
+ (req.next ?? next)(_error);
60
+ return this;
48
61
  }
49
62
 
50
- // For non-Coherent.js data, use original send method
51
- return originalSend.call(this, data);
63
+ this.set('Content-Type', 'text/html; charset=utf-8');
64
+ return originalSend.call(this, finalHtml);
52
65
  };
53
66
 
67
+ if (autoRender) {
68
+ // Override send method to handle Coherent.js objects
69
+ res.send = function(data) {
70
+ // If data looks like a Coherent.js object (plain object with a single key), render it
71
+ if (isCoherentComponent(data)) {
72
+ try {
73
+ // Use shared rendering utility
74
+ const finalHtml = renderWithTemplate(data, { enablePerformanceMonitoring, template });
75
+
76
+ // Set content type and send HTML
77
+ res.set('Content-Type', 'text/html');
78
+ return originalSend.call(this, finalHtml);
79
+ } catch (_error) {
80
+ console.error('Coherent.js rendering error:', _error);
81
+ return next(_error);
82
+ }
83
+ }
84
+
85
+ // For non-Coherent.js data, use original send method
86
+ return originalSend.call(this, data);
87
+ };
88
+ }
89
+
54
90
  next();
55
91
  };
56
92
  }
@@ -58,6 +94,9 @@ export function coherentMiddleware(options = {}) {
58
94
  /**
59
95
  * Create an Express route handler for Coherent.js components
60
96
  *
97
+ * The factory receives `(req, res, next)`. If it answers the request itself
98
+ * (`res.redirect()`, `res.json()`, ...) nothing further is sent.
99
+ *
61
100
  * @param {Function} componentFactory - Function that returns a Coherent.js component
62
101
  * @param {Object} options - Handler options
63
102
  * @returns {Function} Express route handler
@@ -65,63 +104,130 @@ export function coherentMiddleware(options = {}) {
65
104
  export function createCoherentHandler(componentFactory, options = {}) {
66
105
  return async (req, res, next) => {
67
106
  try {
68
- // Use shared rendering utility
69
- const finalHtml = await renderComponentFactory(
70
- componentFactory,
71
- [req, res, next],
72
- options
73
- );
107
+ const component = await componentFactory(req, res, next);
108
+
109
+ // The factory already responded: sending again would throw
110
+ // ERR_HTTP_HEADERS_SENT.
111
+ if (res.headersSent) return;
112
+
113
+ if (!component) {
114
+ throw new Error('Component factory returned null/undefined');
115
+ }
116
+
117
+ const finalHtml = renderWithTemplate(component, options);
74
118
 
75
119
  // Send HTML response
76
120
  res.set('Content-Type', 'text/html');
77
121
  res.send(finalHtml);
78
122
  } catch (_error) {
79
- console.error('Coherent.js handler _error:', _error);
123
+ console.error('Coherent.js handler error:', _error);
80
124
  next(_error);
81
125
  }
82
126
  };
83
127
  }
84
128
 
129
+ /** Keys Express merges into the view-engine options besides the locals. */
130
+ const EXPRESS_VIEW_OPTION_KEYS = new Set(['settings', '_locals', 'cache']);
131
+
132
+ /** View files that are loaded as ES modules rather than used as markers. */
133
+ const VIEW_MODULE_EXTENSION = /\.(?:js|mjs|cjs)$/;
134
+
85
135
  /**
86
- * Enhanced Express engine for Coherent.js views
136
+ * Resolve the component for a view.
87
137
  *
88
- * @param {string} filePath - Path to view file (not used in Coherent.js)
89
- * @param {Object} options - View options containing Coherent.js component
138
+ * A JavaScript view module's default export is the component: a function is
139
+ * called with the render locals, anything else is used as-is. Any other view
140
+ * file (e.g. an empty `home.coherent`) only satisfies Express's lookup, and
141
+ * the locals themselves are the component: `res.render('home', { div: ... })`.
142
+ */
143
+ async function resolveViewComponent(filePath, locals) {
144
+ if (!VIEW_MODULE_EXTENSION.test(filePath)) {
145
+ return locals;
146
+ }
147
+
148
+ const viewModule = await import(pathToFileURL(filePath).href);
149
+ if (!('default' in viewModule)) {
150
+ throw new Error(`Coherent.js view "${filePath}" has no default export`);
151
+ }
152
+
153
+ const view = viewModule.default;
154
+ return typeof view === 'function' ? view(locals) : view;
155
+ }
156
+
157
+ /**
158
+ * Express view engine for Coherent.js views.
159
+ *
160
+ * Register it with `setupCoherent(app, { useEngine: true })`, or directly with
161
+ * `app.engine('js', enhancedExpressEngine)` to render view modules
162
+ * (`views/home.js` exporting a component or `(locals) => component` as
163
+ * default). Express's own `settings`, `_locals` and `cache` keys are removed
164
+ * before the locals reach the component.
165
+ *
166
+ * @param {string} filePath - Absolute path of the view file Express resolved
167
+ * @param {Object} options - Render locals merged by Express
90
168
  * @param {Function} callback - Callback function
91
169
  */
92
170
  export function enhancedExpressEngine(filePath, options, callback) {
93
- try {
94
- // Render Coherent.js component from options
95
- const html = render(options);
96
- callback(null, html);
97
- } catch (_error) {
98
- callback(_error);
171
+ const locals = {};
172
+ for (const [key, value] of Object.entries(options ?? {})) {
173
+ if (!EXPRESS_VIEW_OPTION_KEYS.has(key)) locals[key] = value;
99
174
  }
175
+
176
+ resolveViewComponent(filePath, locals).then(
177
+ (component) => {
178
+ let html;
179
+ try {
180
+ html = render(component);
181
+ } catch (_error) {
182
+ callback(_error);
183
+ return;
184
+ }
185
+ callback(null, html);
186
+ },
187
+ (_error) => callback(_error)
188
+ );
100
189
  }
101
190
 
102
191
  /**
103
192
  * Setup Coherent.js with Express app
104
193
  *
194
+ * Installs {@link coherentMiddleware} (so routes can call `res.coherent()`)
195
+ * and, when asked to, registers {@link enhancedExpressEngine} as a view
196
+ * engine. The engine is opt-in so it does not take over an app's existing
197
+ * `view engine`; it only becomes the default engine when none is set.
198
+ *
105
199
  * @param {Object} app - Express app instance
106
200
  * @param {Object} options - Setup options
201
+ * @param {boolean} [options.useMiddleware=true] - Install coherentMiddleware
202
+ * @param {boolean} [options.useEngine=false] - Register the view engine
203
+ * @param {string} [options.engineName='coherent'] - View engine name / file extension
204
+ * (use 'js' to render `views/*.js` modules)
205
+ * @param {boolean} [options.enablePerformanceMonitoring=false] - Enable performance monitoring
206
+ * @param {string} [options.template] - HTML template with {{content}} placeholder
207
+ * @param {boolean} [options.autoRender=false] - Render component-shaped objects passed to `res.send`
208
+ * (see coherentMiddleware for why this is opt-in)
107
209
  */
108
210
  export function setupCoherent(app, options = {}) {
109
211
  const {
110
212
  useMiddleware = true,
111
- useEngine = true,
213
+ useEngine = false,
112
214
  engineName = 'coherent',
113
- enablePerformanceMonitoring = false
215
+ enablePerformanceMonitoring = false,
216
+ template,
217
+ autoRender = false
114
218
  } = options;
115
219
 
116
- // Register enhanced engine
220
+ // Register the view engine (opt-in); never override an existing default engine
117
221
  if (useEngine) {
118
222
  app.engine(engineName, enhancedExpressEngine);
119
- app.set('view engine', engineName);
223
+ if (!app.get('view engine')) {
224
+ app.set('view engine', engineName);
225
+ }
120
226
  }
121
227
 
122
- // Use middleware for automatic rendering
228
+ // Install the middleware (res.coherent, plus res.send auto-rendering when opted in)
123
229
  if (useMiddleware) {
124
- app.use(coherentMiddleware({ enablePerformanceMonitoring }));
230
+ app.use(coherentMiddleware({ enablePerformanceMonitoring, template, autoRender }));
125
231
  }
126
232
  }
127
233
 
@@ -8,7 +8,7 @@
8
8
  // compatibility with consumers that imported it from the legacy
9
9
  // @coherent.js/express package.
10
10
 
11
- import { render } from '@coherent.js/core';
11
+ import { enhancedExpressEngine } from './coherent-express.js';
12
12
 
13
13
  export {
14
14
  coherentMiddleware,
@@ -18,19 +18,14 @@ export {
18
18
  createExpressIntegration
19
19
  } from './coherent-express.js';
20
20
 
21
+ export { default } from './coherent-express.js';
22
+
21
23
  /**
22
- * Factory returning a classic Express view engine that renders a Coherent.js
23
- * component tree passed via the `options` argument.
24
+ * Factory returning a classic Express view engine for Coherent.js views
25
+ * (see `enhancedExpressEngine`): `app.engine('js', expressEngine())`.
24
26
  *
25
27
  * @returns {(filePath: string, options: unknown, callback: (err: Error | null, html?: string) => void) => void}
26
28
  */
27
29
  export function expressEngine() {
28
- return (filePath, options, callback) => {
29
- try {
30
- const html = render(options);
31
- callback(null, html);
32
- } catch (_error) {
33
- callback(_error);
34
- }
35
- };
30
+ return enhancedExpressEngine;
36
31
  }
@@ -24,12 +24,22 @@ import {
24
24
  * @param {Object} options - Plugin options
25
25
  * @param {boolean} [options.enablePerformanceMonitoring] - Enable performance monitoring
26
26
  * @param {string} [options.template] - HTML template with {{content}} placeholder
27
+ * @param {boolean} [options.autoRender] - Render component-shaped handler return values
28
+ * (see the preSerialization hook below for why this is opt-in)
27
29
  * @param {Function} done - Callback to signal plugin registration completion
28
30
  */
29
31
  function coherentFastifyImpl(fastify, options = {}, done) {
32
+ if (typeof done !== 'function') {
33
+ throw new TypeError(
34
+ 'coherentFastify/setupCoherent is a Fastify plugin and cannot be called directly: ' +
35
+ 'use `await fastify.register(setupCoherent, options)`.'
36
+ );
37
+ }
38
+
30
39
  const {
31
40
  enablePerformanceMonitoring = false,
32
- template = '<!DOCTYPE html>\n{{content}}'
41
+ template = '<!DOCTYPE html>\n{{content}}',
42
+ autoRender = false
33
43
  } = options;
34
44
 
35
45
  // Add decorator to check if an object is a Coherent.js component
@@ -48,39 +58,47 @@ function coherentFastifyImpl(fastify, options = {}, done) {
48
58
  template: renderTemplate = template
49
59
  } = renderOptions;
50
60
 
61
+ let finalHtml;
51
62
  try {
52
- const finalHtml = renderWithTemplate(component, {
63
+ finalHtml = renderWithTemplate(component, {
53
64
  enablePerformanceMonitoring: renderPerformanceMonitoring,
54
65
  template: renderTemplate
55
66
  });
56
- this.header('Content-Type', 'text/html; charset=utf-8');
57
- this.send(finalHtml);
58
67
  } catch (_error) {
59
- console.error('Coherent.js rendering _error:', _error);
60
- this.status(500).send({
61
- _error: 'Internal Server Error',
62
- message: _error.message
63
- });
68
+ // Sending an Error runs Fastify's error pipeline (onError hooks, the
69
+ // app's setErrorHandler, its logger) instead of answering with the raw
70
+ // message.
71
+ return this.send(_error);
64
72
  }
73
+
74
+ this.header('Content-Type', 'text/html; charset=utf-8');
75
+ return this.send(finalHtml);
65
76
  });
66
77
 
67
- // Auto-render: if a handler returns a Coherent.js component object,
68
- // intercept before serialization and replace the payload with HTML.
78
+ // Auto-render (opt-in): if a handler returns a Coherent.js component
79
+ // object, intercept before serialization and replace the payload with HTML.
80
+ //
81
+ // Off by default because detection is a heuristic -- every single-key
82
+ // object qualifies, so `return { ok: true }` or a 401 `{ error: '...' }`
83
+ // would be rendered as `<ok>` / `<error>` HTML even with a JSON response
84
+ // schema. Use `reply.coherent(component)` for explicit rendering instead.
69
85
  //
70
86
  // - `onSend` runs after JSON serialization (payload is already a string),
71
87
  // so the component object would never be detected there.
72
88
  // - `preSerialization` runs before serialization. We render to HTML and
73
89
  // install an identity serializer for this reply, so Fastify doesn't
74
90
  // JSON-stringify the HTML string we just produced.
75
- fastify.addHook('preSerialization', async (request, reply, payload) => {
76
- if (reply.isCoherentObject?.(payload)) {
77
- const finalHtml = renderWithTemplate(payload, { enablePerformanceMonitoring, template });
78
- reply.header('Content-Type', 'text/html; charset=utf-8');
79
- reply.serializer((p) => p);
80
- return finalHtml;
81
- }
82
- return payload;
83
- });
91
+ if (autoRender) {
92
+ fastify.addHook('preSerialization', async (request, reply, payload) => {
93
+ if (reply.isCoherentObject?.(payload)) {
94
+ const finalHtml = renderWithTemplate(payload, { enablePerformanceMonitoring, template });
95
+ reply.header('Content-Type', 'text/html; charset=utf-8');
96
+ reply.serializer((p) => p);
97
+ return finalHtml;
98
+ }
99
+ return payload;
100
+ });
101
+ }
84
102
 
85
103
  done();
86
104
  }
@@ -88,10 +106,14 @@ function coherentFastifyImpl(fastify, options = {}, done) {
88
106
  /**
89
107
  * Fastify plugin for Coherent.js — wrapped with fastify-plugin so decorators
90
108
  * and hooks apply to the parent (root) context. Register at the top of your
91
- * app, then define routes that return Coherent.js component objects:
109
+ * app, then render components explicitly with `reply.coherent()`:
92
110
  *
93
111
  * await fastify.register(coherentFastify, { template: APP_HTML_TEMPLATE });
94
- * fastify.get('/', async () => HomePage({}));
112
+ * fastify.get('/', async (request, reply) => reply.coherent(HomePage({})));
113
+ *
114
+ * Pass `autoRender: true` to also render component objects returned from
115
+ * handlers (`fastify.get('/', async () => HomePage({}))`); see the
116
+ * preSerialization hook above for why that is not the default.
95
117
  */
96
118
  export const coherentFastify = fp(coherentFastifyImpl, {
97
119
  name: 'coherent-fastify',
@@ -123,7 +145,7 @@ export function createHandler(componentFactory, options = {}) {
123
145
  reply.header('Content-Type', 'text/html; charset=utf-8');
124
146
  return finalHtml;
125
147
  } catch (_error) {
126
- console.error('Coherent.js handler _error:', _error);
148
+ console.error('Coherent.js handler error:', _error);
127
149
  throw _error;
128
150
  }
129
151
  };
@@ -11,25 +11,48 @@ import {
11
11
  } from '@coherent.js/core';
12
12
 
13
13
  /**
14
- * Coherent.js Koa middleware
15
- * Automatically renders Coherent.js components and handles errors
14
+ * Coherent.js Koa middleware.
15
+ *
16
+ * Adds `ctx.coherent(component, renderOptions?)`, which renders a Coherent.js
17
+ * component (wrapped in `template`) into `ctx.body` as `text/html`. Rendering
18
+ * errors are thrown, so they reach the app's error-handling middleware.
19
+ *
20
+ * With `autoRender: true` it additionally renders any `ctx.body` that looks
21
+ * like a component once downstream middleware has finished. That detection
22
+ * is a heuristic -- every single-key object qualifies, so
23
+ * `ctx.body = { ok: true }` would be rendered as `<ok>` instead of serialized
24
+ * as JSON. It is off by default; only enable it for apps that never respond
25
+ * with single-key JSON objects.
16
26
  *
17
27
  * @param {Object} options - Configuration options
18
- * @param {boolean} options.enablePerformanceMonitoring - Enable performance monitoring
19
- * @param {string} options.template - HTML template with {{content}} placeholder
28
+ * @param {boolean} [options.enablePerformanceMonitoring=false] - Enable performance monitoring
29
+ * @param {string} [options.template] - HTML template with {{content}} placeholder
30
+ * @param {boolean} [options.autoRender=false] - Render component-shaped `ctx.body` values
20
31
  * @returns {Function} Koa middleware function
21
32
  */
22
33
  export function coherentKoaMiddleware(options = {}) {
23
34
  const {
24
35
  enablePerformanceMonitoring = false,
25
- template = '<!DOCTYPE html>\n{{content}}'
36
+ template = '<!DOCTYPE html>\n{{content}}',
37
+ autoRender = false
26
38
  } = options;
27
39
 
28
40
  return async (ctx, next) => {
41
+ // Explicit rendering: ctx.coherent(component, { template?, enablePerformanceMonitoring? })
42
+ ctx.coherent = (component, renderOptions = {}) => {
43
+ const finalHtml = renderWithTemplate(component, {
44
+ enablePerformanceMonitoring: renderOptions.enablePerformanceMonitoring ?? enablePerformanceMonitoring,
45
+ template: renderOptions.template ?? template
46
+ });
47
+ ctx.type = 'html';
48
+ ctx.body = finalHtml;
49
+ return finalHtml;
50
+ };
51
+
29
52
  await next();
30
53
 
31
- // If response body is a Coherent.js object, render it
32
- if (isCoherentComponent(ctx.body)) {
54
+ // Opt-in: if the response body looks like a Coherent.js object, render it
55
+ if (autoRender && isCoherentComponent(ctx.body)) {
33
56
  try {
34
57
  // Use shared rendering utility
35
58
  const finalHtml = renderWithTemplate(ctx.body, { enablePerformanceMonitoring, template });
@@ -38,7 +61,7 @@ export function coherentKoaMiddleware(options = {}) {
38
61
  ctx.type = 'text/html';
39
62
  ctx.body = finalHtml;
40
63
  } catch (_error) {
41
- console.error('Coherent.js rendering _error:', _error);
64
+ console.error('Coherent.js rendering error:', _error);
42
65
  throw _error;
43
66
  }
44
67
  }
@@ -66,7 +89,7 @@ export function createHandler(componentFactory, options = {}) {
66
89
  ctx.type = 'text/html';
67
90
  ctx.body = finalHtml;
68
91
  } catch (_error) {
69
- console.error('Coherent.js handler _error:', _error);
92
+ console.error('Coherent.js handler error:', _error);
70
93
  throw _error;
71
94
  }
72
95
  };
@@ -75,8 +98,13 @@ export function createHandler(componentFactory, options = {}) {
75
98
  /**
76
99
  * Setup Coherent.js with Koa app
77
100
  *
101
+ * Installs {@link coherentKoaMiddleware}, so downstream middleware can call
102
+ * `ctx.coherent(component)`. Every option except `useMiddleware` is forwarded
103
+ * to the middleware, including `template` and `autoRender`.
104
+ *
78
105
  * @param {Object} app - Koa app instance
79
106
  * @param {Object} options - Setup options
107
+ * @param {boolean} [options.useMiddleware=true] - Install coherentKoaMiddleware
80
108
  */
81
109
  export function setupCoherent(app, options = {}) {
82
110
  const { useMiddleware = true, ...middlewareOptions } = options;