diff --git a/packages/caching-html-compiler/caching-html-compiler.js b/packages/caching-html-compiler/caching-html-compiler.js index b7b04c383..0282dd4fa 100644 --- a/packages/caching-html-compiler/caching-html-compiler.js +++ b/packages/caching-html-compiler/caching-html-compiler.js @@ -115,14 +115,17 @@ CachingHtmlCompiler = class CachingHtmlCompiler extends CachingCompiler { } }); - // Add JavaScript code to set attributes on body + // Add JavaScript code to set attributes on body. + // Guarded with Meteor.isClient because document.body doesn't exist on the server. allJavaScript += -`Meteor.startup(function() { - var attrs = ${JSON.stringify(compileResult.bodyAttrs)}; - for (var prop in attrs) { - document.body.setAttribute(prop, attrs[prop]); - } -}); +`if (Meteor.isClient) { + Meteor.startup(function() { + var attrs = ${JSON.stringify(compileResult.bodyAttrs)}; + for (var prop in attrs) { + document.body.setAttribute(prop, attrs[prop]); + } + }); +} `; } diff --git a/packages/templating-compiler/compile-templates.js b/packages/templating-compiler/compile-templates.js index bdeda1a86..66d16535f 100644 --- a/packages/templating-compiler/compile-templates.js +++ b/packages/templating-compiler/compile-templates.js @@ -1,7 +1,8 @@ /* global CachingHtmlCompiler TemplatingTools */ Plugin.registerCompiler({ extensions: ['html'], - archMatching: 'web', + // archMatching removed — compile for all architectures (web + os/server) + // so that Template definitions are available server-side for SSG rendering. isTemplate: true, }, () => new CachingHtmlCompiler( 'templating', diff --git a/packages/templating-runtime/package.js b/packages/templating-runtime/package.js index 8ae37900d..50d761d96 100644 --- a/packages/templating-runtime/package.js +++ b/packages/templating-runtime/package.js @@ -11,9 +11,10 @@ Package.onUse(function (api) { // XXX would like to do the following only when the first html file // is encountered - api.export('Template', 'client'); + // Export Template to both client and server — server needs it for SSG rendering. + api.export('Template'); - api.addFiles('templating.js', 'client'); + api.addFiles('templating.js'); // html_scanner.js emits client code that calls Meteor.startup and // Blaze, so anybody using templating (eg apps) need to implicitly use @@ -27,13 +28,14 @@ Package.onUse(function (api) { 'meteor', 'blaze@3.0.0', 'spacebars@2.0.0' - ], 'client'); + ]); // to be able to compile dynamic.html. this compiler is used // only inside this package and it should not be implied to not // conflict with other packages providing .html compilers. api.use('templating-compiler@2.0.0'); + // dynamic template support is client-only (requires DOM) api.addFiles([ 'dynamic.html', 'dynamic.js' diff --git a/packages/templating-runtime/templating.js b/packages/templating-runtime/templating.js index eab591599..f3b228b86 100644 --- a/packages/templating-runtime/templating.js +++ b/packages/templating-runtime/templating.js @@ -10,6 +10,7 @@ Template = Blaze.Template; const RESERVED_TEMPLATE_NAMES = "__proto__ name".split(" "); // Check for duplicate template names and illegal names that won't work. +// This is server-safe — no DOM dependency. Template.__checkName = function (name) { // Some names can't be used for Templates. These include: // - Properties Blaze sets on the Template object. @@ -26,157 +27,170 @@ Template.__checkName = function (name) { } }; -let shownWarning = false; - -// XXX COMPAT WITH 0.8.3 -Template.__define__ = function (name, renderFunc) { - Template.__checkName(name); - Template[name] = new Template(`Template.${name}`, renderFunc); - // Exempt packages built pre-0.9.0 from warnings about using old - // helper syntax, because we can. It's not very useful to get a - // warning about someone else's code (like a package on Atmosphere), - // and this should at least put a bit of a dent in number of warnings - // that come from packages that haven't been updated lately. - Template[name]._NOWARN_OLDSTYLE_HELPERS = true; - - // Now we want to show at least one warning so that people get serious about - // updating away from this method. - if (!shownWarning) { - shownWarning = true; - console.warn("Your app is using old Template definition that is scheduled to be removed with Blaze 3.0, please check your app and packages for use of: Template.__define__"); - } -}; - -// Define a template `Template.body` that renders its -// `contentRenderFuncs`. `
` tags (of which there may be -// multiple) will have their contents added to it. - -/** - * @summary The [template object](#Template-Declarations) representing your `` - * tag. - * @locus Client - */ -Template.body = new Template('body', function () { - const view = this; - return Template.body.contentRenderFuncs.map((func) => func.apply(view)); -}); -Template.body.contentRenderFuncs = []; // array of Blaze.Views -Template.body.view = null; - -Template.body.addContent = function (renderFunc) { - Template.body.contentRenderFuncs.push(renderFunc); -}; - -// This function does not use `this` and so it may be called -// as `Meteor.startup(Template.body.renderIntoDocument)`. -Template.body.renderToDocument = function () { - // Only do it once. - if (Template.body.view) - return; - - const view = Blaze.render(Template.body, document.body); - Template.body.view = view; -}; - -Template.__pendingReplacement = [] - -let updateTimeout = null; - -// Simple HMR integration to re-render all of the root views -// when a template is modified. This function can be overridden to provide -// an alternative method of applying changes from HMR. -Template._applyHmrChanges = function (templateName) { - if (updateTimeout) { - return; - } - - // debounce so we only re-render once per rebuild - updateTimeout = setTimeout(() => { - updateTimeout = null; - - for (let i = 0; i < Template.__pendingReplacement.length; i++) { - delete Template[Template.__pendingReplacement[i]]; +// Everything below this point requires DOM and is client-only. +// On the server, Template is available as a registry for compiled templates +// (used by Blaze.toHTML / StaticRender for SSG), but body rendering, +// HMR, and DOM attachment are skipped. + +if (Meteor.isClient) { + + let shownWarning = false; + + // XXX COMPAT WITH 0.8.3 + Template.__define__ = function (name, renderFunc) { + Template.__checkName(name); + Template[name] = new Template(`Template.${name}`, renderFunc); + // Exempt packages built pre-0.9.0 from warnings about using old + // helper syntax, because we can. It's not very useful to get a + // warning about someone else's code (like a package on Atmosphere), + // and this should at least put a bit of a dent in number of warnings + // that come from packages that haven't been updated lately. + Template[name]._NOWARN_OLDSTYLE_HELPERS = true; + + // Now we want to show at least one warning so that people get serious about + // updating away from this method. + if (!shownWarning) { + shownWarning = true; + console.warn("Your app is using old Template definition that is scheduled to be removed with Blaze 3.0, please check your app and packages for use of: Template.__define__"); + } + }; + + // Define a template `Template.body` that renders its + // `contentRenderFuncs`. `` tags (of which there may be + // multiple) will have their contents added to it. + + /** + * @summary The [template object](#Template-Declarations) representing your `` + * tag. + * @locus Client + */ + Template.body = new Template('body', function () { + const view = this; + return Template.body.contentRenderFuncs.map((func) => func.apply(view)); + }); + Template.body.contentRenderFuncs = []; // array of Blaze.Views + Template.body.view = null; + + Template.body.addContent = function (renderFunc) { + Template.body.contentRenderFuncs.push(renderFunc); + }; + + // This function does not use `this` and so it may be called + // as `Meteor.startup(Template.body.renderIntoDocument)`. + Template.body.renderToDocument = function () { + // Only do it once. + if (Template.body.view) + return; + + const view = Blaze.render(Template.body, document.body); + Template.body.view = view; + }; + + Template.__pendingReplacement = []; + + let updateTimeout = null; + + // Simple HMR integration to re-render all of the root views + // when a template is modified. This function can be overridden to provide + // an alternative method of applying changes from HMR. + Template._applyHmrChanges = function (templateName) { + if (updateTimeout) { + return; } - Template.__pendingReplacement = []; - - const views = Blaze.__rootViews.slice(); - for (let i = 0; i < views.length; i++) { - const view = views[i]; - if (view.destroyed) { - continue; - } + // debounce so we only re-render once per rebuild + updateTimeout = setTimeout(() => { + updateTimeout = null; - const renderFunc = view._render; - let parentEl; - if (view._domrange && view._domrange.parentElement) { - parentEl = view._domrange.parentElement; - } else if (view._hmrParent) { - parentEl = view._hmrParent; + for (let i = 0; i < Template.__pendingReplacement.length; i++) { + delete Template[Template.__pendingReplacement[i]]; } - let comment; - if (view._hmrAfter) { - comment = view._hmrAfter; - } else { - const first = view._domrange.firstNode(); - comment = document.createComment('Blaze HMR PLaceholder'); - parentEl.insertBefore(comment, first); - } + Template.__pendingReplacement = []; - view._hmrAfter = null; - view._hmrParent = null; + const views = Blaze.__rootViews.slice(); + for (let i = 0; i < views.length; i++) { + const view = views[i]; + if (view.destroyed) { + continue; + } - if (view._domrange) { - Blaze.remove(view); - } + const renderFunc = view._render; + let parentEl; + if (view._domrange && view._domrange.parentElement) { + parentEl = view._domrange.parentElement; + } else if (view._hmrParent) { + parentEl = view._hmrParent; + } - try { - if (view === Template.body.view) { - const newView = Blaze.render(Template.body, document.body, comment); - Template.body.view = newView; - } else if (view.dataVar) { - Blaze.renderWithData(renderFunc, view.dataVar.curValue?.value, parentEl, comment); + let comment; + if (view._hmrAfter) { + comment = view._hmrAfter; } else { - Blaze.render(renderFunc, parentEl, comment); + const first = view._domrange.firstNode(); + comment = document.createComment('Blaze HMR PLaceholder'); + parentEl.insertBefore(comment, first); + } + + view._hmrAfter = null; + view._hmrParent = null; + + if (view._domrange) { + Blaze.remove(view); } - parentEl.removeChild(comment); - } catch (e) { - console.log('[Blaze HMR] Error re-rending template:'); - console.error(e); - - // Record where the view should have been so we can still render it - // during the next update - const newestRoot = Blaze.__rootViews[Blaze.__rootViews.length - 1]; - if (newestRoot && newestRoot.isCreated && !newestRoot.isRendered) { - newestRoot._hmrAfter = comment; - newestRoot._hmrParent = parentEl; + try { + if (view === Template.body.view) { + const newView = Blaze.render(Template.body, document.body, comment); + Template.body.view = newView; + } else if (view.dataVar) { + Blaze.renderWithData(renderFunc, view.dataVar.curValue?.value, parentEl, comment); + } else { + Blaze.render(renderFunc, parentEl, comment); + } + + parentEl.removeChild(comment); + } catch (e) { + console.log('[Blaze HMR] Error re-rending template:'); + console.error(e); + + // Record where the view should have been so we can still render it + // during the next update + const newestRoot = Blaze.__rootViews[Blaze.__rootViews.length - 1]; + if (newestRoot && newestRoot.isCreated && !newestRoot.isRendered) { + newestRoot._hmrAfter = comment; + newestRoot._hmrParent = parentEl; + } } } - } - }); -}; + }); + }; + +} // end if (Meteor.isClient) +// _migrateTemplate is called by compiled template code on both client and server. +// On the server, it just registers the template without HMR logic. Template._migrateTemplate = function (templateName, newTemplate, migrate) { - const oldTemplate = Template[templateName]; - migrate = Template.__pendingReplacement.includes(templateName); - - if (oldTemplate && migrate) { - newTemplate.__helpers = oldTemplate.__helpers; - newTemplate.__eventMaps = oldTemplate.__eventMaps; - newTemplate._callbacks.created = oldTemplate._callbacks.created; - newTemplate._callbacks.rendered = oldTemplate._callbacks.rendered; - newTemplate._callbacks.destroyed = oldTemplate._callbacks.destroyed; - delete Template[templateName]; - Template._applyHmrChanges(templateName); - } + if (Meteor.isClient) { + const oldTemplate = Template[templateName]; + migrate = Template.__pendingReplacement.includes(templateName); + + if (oldTemplate && migrate) { + newTemplate.__helpers = oldTemplate.__helpers; + newTemplate.__eventMaps = oldTemplate.__eventMaps; + newTemplate._callbacks.created = oldTemplate._callbacks.created; + newTemplate._callbacks.rendered = oldTemplate._callbacks.rendered; + newTemplate._callbacks.destroyed = oldTemplate._callbacks.destroyed; + delete Template[templateName]; + Template._applyHmrChanges(templateName); + } - if (migrate) { - Template.__pendingReplacement.splice( - Template.__pendingReplacement.indexOf(templateName), - 1 - ) + if (migrate) { + Template.__pendingReplacement.splice( + Template.__pendingReplacement.indexOf(templateName), + 1 + ); + } } Template.__checkName(templateName); diff --git a/packages/templating-tools/code-generation.js b/packages/templating-tools/code-generation.js index 2c516db2f..e13d04ca1 100644 --- a/packages/templating-tools/code-generation.js +++ b/packages/templating-tools/code-generation.js @@ -27,26 +27,32 @@ Template[${nameLiteral}] = new Template(${templateDotNameLiteral}, ${renderFuncC } export function generateBodyJS(renderFuncCode, useHMR) { + // Body rendering requires DOM (document.body) — guard with Meteor.isClient + // so that compiled templates can safely load on the server for SSG. if (useHMR) { return ` -(function () { - var renderFunc = ${renderFuncCode}; - Template.body.addContent(renderFunc); - Meteor.startup(Template.body.renderToDocument); - if (typeof module === "object" && module.hot) { - module.hot.accept(); - module.hot.dispose(function () { - var index = Template.body.contentRenderFuncs.indexOf(renderFunc) - Template.body.contentRenderFuncs.splice(index, 1); - Template._applyHmrChanges(); - }); - } -})(); +if (Meteor.isClient) { + (function () { + var renderFunc = ${renderFuncCode}; + Template.body.addContent(renderFunc); + Meteor.startup(Template.body.renderToDocument); + if (typeof module === "object" && module.hot) { + module.hot.accept(); + module.hot.dispose(function () { + var index = Template.body.contentRenderFuncs.indexOf(renderFunc) + Template.body.contentRenderFuncs.splice(index, 1); + Template._applyHmrChanges(); + }); + } + })(); +} ` } return ` -Template.body.addContent(${renderFuncCode}); -Meteor.startup(Template.body.renderToDocument); +if (Meteor.isClient) { + Template.body.addContent(${renderFuncCode}); + Meteor.startup(Template.body.renderToDocument); +} `; } diff --git a/packages/templating-tools/html-scanner-tests.js b/packages/templating-tools/html-scanner-tests.js index 9f3e22211..1c7086b98 100644 --- a/packages/templating-tools/html-scanner-tests.js +++ b/packages/templating-tools/html-scanner-tests.js @@ -32,7 +32,7 @@ Tinytest.add("templating-tools - html scanner", function (test) { // where content is something simple like the string "Hello" // (passed in as a source string including the quotes). var simpleBody = function (content) { - return "\nTemplate.body.addContent((function () { var view = this; return " + content + "; }));\nMeteor.startup(Template.body.renderToDocument);\n"; + return "\nif (Meteor.isClient) {\n Template.body.addContent((function () { var view = this; return " + content + "; }));\n Meteor.startup(Template.body.renderToDocument);\n}\n"; }; // arguments are quoted strings like '"hello"' diff --git a/packages/templating/package.js b/packages/templating/package.js index 6ac7828b1..ba4805c22 100644 --- a/packages/templating/package.js +++ b/packages/templating/package.js @@ -11,7 +11,8 @@ Package.describe({ // registry and a default templating system, ideally per-package. Package.onUse(function (api) { - api.export('Template', 'client'); + // Export Template to both client and server for SSG rendering support + api.export('Template'); api.use('templating-runtime@2.0.0'); api.imply('templating-runtime'); diff --git a/site/source/guide/server-rendering.md b/site/source/guide/server-rendering.md new file mode 100644 index 000000000..204f23828 --- /dev/null +++ b/site/source/guide/server-rendering.md @@ -0,0 +1,127 @@ +--- +title: Server Rendering +description: Render Blaze templates to HTML strings on the server for SEO, social previews, and pre-rendering. +--- + +# Server Rendering with Blaze + +Starting from Blaze 3.1.x, compiled templates are available both on the client and on the server. This enables rendering templates to HTML strings from Node.js — useful for SEO, social link previews, and pre-rendering static or dynamic pages. + +## The basics + +`Blaze.toHTML()` and `Blaze.toHTMLWithData()` work on the server, returning a string of HTML: + +```js +// server/main.js +import '../imports/ui/my-template.html'; // make template available server-side + +const html = Blaze.toHTML(Template.myTemplate); +const html2 = Blaze.toHTMLWithData(Template.productCard, { title: 'Oak Chair', price: 149 }); +``` + +The rendering path goes through `Blaze._expandView()`, which is DOM-free. No browser, no JSDOM required. + +## Making templates available on the server + +In modern Meteor with explicit imports, `.html` files must be imported from `server/main.js` (or a file it imports) to be included in the server bundle: + +```js +// server/main.js +import '../imports/ui/templates.html'; +``` + +On the client, your existing imports continue to work unchanged. + +## Template restrictions + +Templates rendered on the server must avoid client-only APIs and Tracker-based reactivity: + +- ❌ `Session.get()`, `Session.set()` +- ❌ `Template.instance().subscribe()` +- ❌ `Template.dynamic` (it remains client-only) +- ❌ ReactiveVar, ReactiveDict reads inside helpers +- ❌ `onRendered()`, `onDestroyed()` callbacks (they don't fire server-side) +- ✅ Pure helpers that use the data context passed to the template +- ✅ `#each`, `#if`, `#unless`, `#with`, `#let` block helpers +- ✅ Sub-templates with `{{> myTemplate}}` + +Server-rendered templates should be written as pure render functions against explicit data context. + +## Using server rendering for SSG/SSR + +The [`static-render` package](../packages/static-render) provides a higher-level API for pre-rendering routes at server startup (SSG) or at each request (SSR), integrating with `flow-router-extra` and the Meteor boilerplate pipeline. + +### SSG — Static Site Generation + +For pages whose content doesn't change without a server restart (about, contact, terms). The HTML is rendered once at startup and cached permanently in memory. + +```js +import { FlowRouter } from 'meteor/ostrio:flow-router-extra'; + +FlowRouter.route('/about', { + static: 'ssg', + template: 'about', + staticData() { + return { title: 'About Us' }; + }, + staticHead() { + return '