diff --git a/Island.cfm b/Island.cfm
index ab6269b..2530ab4 100644
--- a/Island.cfm
+++ b/Island.cfm
@@ -10,6 +10,11 @@
props (struct, optional) - props passed to the component (default {})
strategy (string, optional) - "load" | "idle" | "visible" | "client" (default "load")
"client" skips SSR entirely (no server HTML, no CSS pre-render)
+
+ Body / slots:
+ Anything between and is captured (after CFML
+ evaluation) and passed to the component as its default slot. Use a
+ in your Vue component (or {children} in React) to render it.
--->
@@ -17,11 +22,18 @@
-// Custom tags execute twice (start + end). Only emit on start.
-if (thisTag.executionMode neq "start") {
- exit "exitTag";
+// Run on the END pass so thisTag.generatedContent contains the rendered
+// children (the "default slot"). The start pass exits early via exitTemplate
+// so the body still executes and CF still invokes us again in end mode.
+// (exit "exitTag" would skip both the body AND the end-mode invocation.)
+if (thisTag.executionMode neq "end") {
+ exit "exitTemplate";
}
+slotHtml = trim(thisTag.generatedContent);
+// Reset; we'll write the full island markup back at the end.
+thisTag.generatedContent = "";
+
// --- validate strategy
validStrategies = ["load", "idle", "visible", "client"];
if (!arrayFindNoCase(validStrategies, attributes.strategy)) {
@@ -31,10 +43,8 @@ if (!arrayFindNoCase(validStrategies, attributes.strategy)) {
);
}
-// "client" strategy is client-only: skip SSR entirely.
clientOnly = (attributes.strategy == "client");
-// --- validate renderer shape
if (!structKeyExists(attributes.framework, "render") || !isCustomFunction(attributes.framework.render)) {
throw(
type = "Coldspa.InvalidRenderer",
@@ -42,24 +52,17 @@ if (!structKeyExists(attributes.framework, "render") || !isCustomFunction(attrib
);
}
-// --- resolve config (lazy fallback if Application.cfc didn't wire it)
if (!structKeyExists(application, "coldspaConfig")) {
application.coldspaConfig = new coldspa.ColdspaConfig().get();
}
cfg = application.coldspaConfig;
-// --- resolve asset path (dev: vite server URL; prod: manifest lookup)
function resolveAsset(required string path) {
if (cfg.isDev) {
- // Strip leading "./" so we get a clean URL join
var clean = reReplace(arguments.path, "^\./", "");
- // viteUrl override (from COLDSPA_VITE_URL env) lets the browser reach
- // a Vite dev server that isn't on localhost (e.g. host.docker.internal).
var viteBase = cfg.viteUrl ?: ("http://localhost:" & cfg.vitePort);
return viteBase & "/" & clean;
}
-
- // Production: look up content-hashed file in vite manifest
var manifestPath = expandPath("/dist/.vite/manifest.json");
if (!fileExists(manifestPath)) {
throw(
@@ -81,32 +84,23 @@ function resolveAsset(required string path) {
uid = lcase(replace(createUUID(), "-", "", "all"));
mountId = "island-" & uid; // DOM id (hyphens fine)
jsId = "island_" & uid; // JS-identifier-safe (no hyphens)
+slotId = "slot-" & uid; // id for client slot retrieval
-// Component is loaded dynamically by the framework's client entry via
-// import.meta.glob, so we don't resolve it through Vite ourselves -- we just
-// normalize the path to a glob key (e.g. "./src/App.vue" -> "/src/App.vue").
componentGlobKey = reReplace(attributes.path, "^\./", "/");
-
propsJson = serializeJSON(attributes.props);
-// The client entry (the JS shim with the bare `vue` import) IS resolved through
-// Vite, since it's a real JS file Vite serves/bundles.
resolvedClientEntry = "";
if (structKeyExists(attributes.framework, "clientEntry")) {
resolvedClientEntry = resolveAsset(attributes.framework.clientEntry);
}
-// Server-side rendering. If the renderer supports ssrRender(), call the SSR
-// sidecar and embed the returned HTML inside the mount div. The client uses
-// the hydrate flag (computed from whether SSR actually produced HTML) to pick
-// between hydrate-mode and fresh client-mount APIs. If SSR fails or isn't
-// available, the page still works -- just no pre-rendered markup.
+// Server-side rendering. Slots are passed through so the SSR output and the
+// client hydration agree byte-for-byte.
ssrHtml = "";
ssrCss = "";
ssrError = "";
if (!clientOnly && structKeyExists(attributes.framework, "ssrRender")) {
- ssrResult = attributes.framework.ssrRender(componentGlobKey, attributes.props);
- // Tolerate older renderers that returned a plain string.
+ ssrResult = attributes.framework.ssrRender(componentGlobKey, attributes.props, slotHtml);
if (isStruct(ssrResult)) {
ssrHtml = ssrResult.html ?: "";
ssrCss = ssrResult.css ?: "";
@@ -116,26 +110,24 @@ if (!clientOnly && structKeyExists(attributes.framework, "ssrRender")) {
}
}
-// Build the options object passed to the client's mount() function. The
-// client uses `strategy` to decide between hydrate-mode and fresh-mount APIs;
-// SSR is skipped entirely above when strategy="client", so anything else
-// implies SSR markup is present.
+// Options the client mount() sees. slotId tells the client where to find the
+// stash containing the slot HTML. hasSlot is a quick check so the
+// client can skip DOM lookup when there's nothing to slot.
mountOptionsJson = serializeJSON({
- "strategy": attributes.strategy
+ "strategy": attributes.strategy,
+ "slotId": slotId,
+ "hasSlot": len(slotHtml) gt 0
});
rendered = attributes.framework.render(mountId, componentGlobKey, propsJson, resolvedClientEntry, mountOptionsJson);
-// Backwards-compat: allow renderers that still return a plain string (treated as body, no imports)
if (isSimpleValue(rendered)) {
rendered = { "imports": "", "body": rendered };
}
bootImports = rendered.imports;
bootBody = rendered.body;
-// In prod the SSR sidecar can't easily inline component CSS (the SSR build
-// doesn't emit CSS), so we surface it as tags from the client manifest.
-// Each chunk in the manifest carries a `css` array of hashed asset filenames.
+// In prod the SSR build doesn't emit CSS; surface client-bundle CSS as .
ssrCssLinks = [];
if (!cfg.isDev && len(ssrHtml) && structKeyExists(attributes.framework, "clientEntry")) {
try {
@@ -150,46 +142,21 @@ if (!cfg.isDev && len(ssrHtml) && structKeyExists(attributes.framework, "clientE
}
}
} catch (any e) {
- // non-fatal: we'll just have a brief FOUC
+ // non-fatal: brief FOUC is acceptable
}
}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
#ssrHtml#
-
-
-
-
+
+#slotHtml#
#ssrHtml#
-
-
-
+
-
-
-
+
-
-
-
+
+
+
diff --git a/coldspa/renderers/Vue.cfm b/coldspa/renderers/Vue.cfm
index 51ff04c..c3a43c3 100644
--- a/coldspa/renderers/Vue.cfm
+++ b/coldspa/renderers/Vue.cfm
@@ -12,12 +12,13 @@ Vue = {
* Returns { html: string, error: string } so Island.cfm can surface
* diagnostic info in dev mode.
*/
- "ssrRender": function(componentGlobKey, props) {
+ "ssrRender": function(componentGlobKey, props, slotHtml) {
var ssrUrl = (application.coldspaConfig.ssrUrl ?: "http://127.0.0.1:5174")
& "/render/vue";
var payload = serializeJSON({
"componentPath": arguments.componentGlobKey,
- "props": arguments.props
+ "props": arguments.props,
+ "slotHtml": arguments.slotHtml ?: ""
});
try {
cfhttp(url=ssrUrl, method="POST", timeout=5, result="local.resp") {
diff --git a/coldspa/vite/clients/react-client.js b/coldspa/vite/clients/react-client.js
index f51c7d1..bd3457c 100644
--- a/coldspa/vite/clients/react-client.js
+++ b/coldspa/vite/clients/react-client.js
@@ -3,6 +3,10 @@
//
// `options.strategy === 'client'` -> createRoot (fresh client render)
// otherwise -> hydrateRoot (hydrates SSR markup)
+//
+// `options.hasSlot` + `options.slotId` -> read raw HTML from
+// and pass it as `children` via dangerouslySetInnerHTML so the component can
+// render the CF-emitted slot HTML.
import React from 'react';
import { createRoot, hydrateRoot } from 'react-dom/client';
@@ -19,7 +23,20 @@ export async function mount(componentPath, el, props, options) {
return;
}
const mod = await loader();
- const node = React.createElement(mod.default, props);
+
+ let children;
+ if (options && options.hasSlot && options.slotId) {
+ const tpl = document.getElementById(options.slotId);
+ const slotHtml = tpl ? tpl.innerHTML : '';
+ if (slotHtml) {
+ // Wrapper span so the component can decide where children go.
+ children = React.createElement('span', {
+ dangerouslySetInnerHTML: { __html: slotHtml }
+ });
+ }
+ }
+
+ const node = React.createElement(mod.default, props, children);
if (options && options.strategy === 'client') {
createRoot(el).render(node);
} else {
diff --git a/coldspa/vite/clients/vue-client.js b/coldspa/vite/clients/vue-client.js
index f794dd4..e336f64 100644
--- a/coldspa/vite/clients/vue-client.js
+++ b/coldspa/vite/clients/vue-client.js
@@ -3,7 +3,12 @@
//
// `options.strategy === 'client'` -> createApp (fresh client render)
// otherwise -> createSSRApp (hydrates SSR markup)
-import { createApp, createSSRApp } from 'vue';
+//
+// `options.hasSlot` + `options.slotId` -> read raw HTML from a sibling
+// and inject it as the component's default slot via
+// createStaticVNode. The same path runs server-side (vue-ssr.js) so SSR
+// markup matches the client render byte-for-byte.
+import { createApp, createSSRApp, h, createStaticVNode } from 'vue';
const components = import.meta.glob('__COLDSPA_GLOB__');
@@ -18,6 +23,18 @@ export async function mount(componentPath, el, props, options) {
return;
}
const mod = await loader();
+
+ let slots;
+ if (options && options.hasSlot && options.slotId) {
+ const tpl = document.getElementById(options.slotId);
+ const slotHtml = tpl ? tpl.innerHTML : '';
+ if (slotHtml) {
+ slots = { default: () => createStaticVNode(slotHtml, 1) };
+ }
+ }
+
const factory = options && options.strategy === 'client' ? createApp : createSSRApp;
- factory(mod.default, props).mount(el);
+ factory({
+ render: () => h(mod.default, props, slots)
+ }).mount(el);
}
diff --git a/coldspa/vite/clients/vue-ssr.js b/coldspa/vite/clients/vue-ssr.js
index 225b385..9308a9c 100644
--- a/coldspa/vite/clients/vue-ssr.js
+++ b/coldspa/vite/clients/vue-ssr.js
@@ -1,16 +1,21 @@
// Coldspa Vue SSR entry. Bundled by Vite with build.ssr=true via the coldspa plugin.
// __COLDSPA_GLOB__ is replaced at build time.
-import { createSSRApp } from 'vue';
+import { createSSRApp, h, createStaticVNode } from 'vue';
import { renderToString } from 'vue/server-renderer';
const components = import.meta.glob('__COLDSPA_GLOB__');
-export async function render(componentPath, props) {
+export async function render(componentPath, props, slotHtml) {
const loader = components[componentPath];
if (!loader) {
throw new Error(`[Coldspa SSR] No Vue component registered for path "${componentPath}".`);
}
const mod = await loader();
- const app = createSSRApp(mod.default, props);
+ const slots = slotHtml
+ ? { default: () => createStaticVNode(slotHtml, 1) }
+ : undefined;
+ const app = createSSRApp({
+ render: () => h(mod.default, props, slots)
+ });
return await renderToString(app);
}
diff --git a/coldspa/vite/ssr-server.js b/coldspa/vite/ssr-server.js
index 331adbd..8b9e745 100644
--- a/coldspa/vite/ssr-server.js
+++ b/coldspa/vite/ssr-server.js
@@ -156,9 +156,9 @@ const server = createHttpServer(async (req, res) => {
const framework = m[1];
try {
- const { componentPath, props } = await readJsonBody(req);
+ const { componentPath, props, slotHtml } = await readJsonBody(req);
const renderer = await getRenderer(framework);
- const html = await renderer.render(componentPath, props ?? {});
+ const html = await renderer.render(componentPath, props ?? {}, slotHtml ?? '');
// Dev: collect inline CSS so the page has component styles before
// hydration. Prod: handled via from the client manifest.
const css = IS_PROD ? '' : await collectCssDev(componentPath);
diff --git a/docs/slots.md b/docs/slots.md
new file mode 100644
index 0000000..9b9dd6a
--- /dev/null
+++ b/docs/slots.md
@@ -0,0 +1,91 @@
+# Slots
+
+`cf_Island` captures any HTML between its opening and closing tags and passes it to your component as the default slot. You can render dynamic CFML markup (queries, conditionals, includes) and project it straight into a Vue/React component.
+
+## Usage
+
+### CFML side
+
+```cfml
+
+ SELECT title, body, createdAt FROM posts
+ WHERE userID =
+
+
+
+
+
+
#title#
+
#body#
+
+
+
+
+```
+
+### Vue component
+
+```vue
+
+
+
+
+
+```
+
+### React component
+
+```jsx
+export default function PostList({ children }) {
+ return
{children}
;
+}
+```
+
+## How it works
+
+1. `cf_Island` runs on the **end** pass and reads the rendered body from `thisTag.generatedContent` as raw HTML.
+2. The HTML is stashed in a sibling `` element (inert, scripts inside don't execute).
+3. The mount options passed to the client include `{ slotId, hasSlot }`.
+4. On both server (SSR sidecar) and client, the slot HTML is injected via Vue's `createStaticVNode` (or React's `dangerouslySetInnerHTML`) so SSR markup and hydration match byte-for-byte.
+
+## Consequences and gotchas
+
+These follow from running the tag on the end pass.
+
+### Errors in the body abort the page
+
+If a `` or expression inside the slot throws, the tag never gets to emit anything — the error propagates as it would in plain CFML. The island can't `try/catch` its own body.
+
+### `cfabort` / `cfexit` in the body suppresses the island entirely
+
+If the body short-circuits, no mount div, no ``, no `