diff --git a/claude-tools/check-demo-jsx.mjs b/claude-tools/check-demo-jsx.mjs
new file mode 100644
index 0000000000..518fc60019
--- /dev/null
+++ b/claude-tools/check-demo-jsx.mjs
@@ -0,0 +1,49 @@
+// Quick syntax check for every .jsx template under public-demo. Loads
+// each via the same JSX loader used in production and reports compile
+// errors with proper file/line info.
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createRequire } from 'node:module';
+import fs from 'node:fs';
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const apostropheRoot = path.resolve(here, '..');
+const require = createRequire(path.join(apostropheRoot, 'packages/apostrophe/index.js'));
+
+const { install } = require(path.join(apostropheRoot, 'packages/apostrophe/modules/@apostrophecms/template/lib/jsxLoader.js'));
+install();
+
+const demoRoot = '/srv/workspace/apostrophecms/public-demo';
+
+function* walk(dir) {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (entry.name === 'node_modules' || entry.name === 'data') continue;
+ const full = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ yield* walk(full);
+ } else if (entry.name.endsWith('.jsx')) {
+ yield full;
+ }
+ }
+}
+
+let failed = 0;
+for (const file of walk(demoRoot)) {
+ try {
+ require(file);
+ console.log('OK ', file);
+ } catch (err) {
+ failed += 1;
+ console.error('FAIL', file);
+ console.error(' ', err.message);
+ if (err.stack) {
+ console.error(err.stack.split('\n').slice(1, 5).join('\n'));
+ }
+ }
+}
+
+if (failed > 0) {
+ console.error(`\n${failed} file(s) failed to compile/load.`);
+ process.exit(1);
+}
+console.log(`\nAll JSX templates loaded successfully.`);
diff --git a/packages/apostrophe/eslint.config.js b/packages/apostrophe/eslint.config.js
index 969698e6ef..5169f67265 100644
--- a/packages/apostrophe/eslint.config.js
+++ b/packages/apostrophe/eslint.config.js
@@ -7,6 +7,7 @@ module.exports = defineConfig([
'**/blueimp/**/*.js',
'test/public',
'test/apos-build',
+ 'test/modules/jsx-mixed-test/views/syntax-error.jsx',
'coverage',
'claude-tools'
]),
diff --git a/packages/apostrophe/modules/@apostrophecms/attachment/index.js b/packages/apostrophe/modules/@apostrophecms/attachment/index.js
index da44ba9052..63441912b9 100644
--- a/packages/apostrophe/modules/@apostrophecms/attachment/index.js
+++ b/packages/apostrophe/modules/@apostrophecms/attachment/index.js
@@ -656,7 +656,10 @@ module.exports = {
// to avoid template errors
getMissingAttachmentUrl() {
const defaultIconUrl = '/modules/@apostrophecms/attachment/img/missing-icon.svg';
- self.apos.util.warn('Template warning: Impossible to retrieve the attachment url since it is missing, a default icon has been set. Please fix this ASAP!');
+ const e = new Error();
+ self.apos.util.warn('Template warning: Impossible to retrieve the attachment url since it is missing, a default icon has been set. Please fix this ASAP!\n\n' +
+ e.stack
+ );
// Convert static asset path to full URL, which matters when static
// assets are in uploadfs
return self.apos.asset.url(defaultIconUrl);
diff --git a/packages/apostrophe/modules/@apostrophecms/oembed/index.js b/packages/apostrophe/modules/@apostrophecms/oembed/index.js
index d21e458da7..f76bfe5ea7 100644
--- a/packages/apostrophe/modules/@apostrophecms/oembed/index.js
+++ b/packages/apostrophe/modules/@apostrophecms/oembed/index.js
@@ -54,7 +54,8 @@ module.exports = {
self.oembetter.allowlist(minimumAllowlist.concat(self.options.allowlist || []));
- const minimumEndpoints = self.options.minimumEndpoints || self.oembetter.suggestedEndpoints;
+ const minimumEndpoints = self.options.minimumEndpoints ||
+ self.oembetter.suggestedEndpoints;
self.oembetter.endpoints(
minimumEndpoints.concat(self.options.endpoints || [])
);
diff --git a/packages/apostrophe/modules/@apostrophecms/template/index.js b/packages/apostrophe/modules/@apostrophecms/template/index.js
index a11a00e375..fe5419cdf5 100644
--- a/packages/apostrophe/modules/@apostrophecms/template/index.js
+++ b/packages/apostrophe/modules/@apostrophecms/template/index.js
@@ -76,6 +76,32 @@ module.exports = {
self.insertions = {};
self.runtimeNodes = {};
+ // Install the .jsx require hook and teach the JSX runtime about
+ // Nunjucks' SafeString class so its instances pass through unescaped.
+ self.initJsx();
+
+ // Wire up the view-folder watcher with the two default invalidation
+ // handlers — Nunjucks loader caches and compiled .jsx modules. Both
+ // engines share a single set of chokidar watchers so we don't pay
+ // twice for watching the same directories.
+ const jsxLoader = require('./lib/jsxLoader.js');
+ self.onViewChange(function clearNunjucksLoaderCaches() {
+ // Setting `cache = {}` mirrors the historical in-loader behavior
+ // and is exactly what Nunjucks itself reads when looking up a
+ // previously-loaded template.
+ for (const loader of Object.values(self.loaders || {})) {
+ loader.cache = {};
+ }
+ });
+ self.onViewChange(function invalidateJsxModules(filePath) {
+ if (filePath && filePath.endsWith('.jsx')) {
+ jsxLoader.invalidate(path.resolve(filePath));
+ } else {
+ // Anything else (e.g. a Nunjucks file) might be a template imported
+ // by a `.jsx` file via require()/import — be safe and drop them all.
+ jsxLoader.invalidateAll();
+ }
+ });
},
handlers(self) {
return {
@@ -117,9 +143,15 @@ module.exports = {
},
'apostrophe:destroy': {
async nunjucksLoaderCleanup() {
+ // Older code paths used to manage chokidar watchers per loader;
+ // a no-op `destroy()` is still defined for backwards compat.
for (const loader of Object.values(self.loaders || {})) {
await loader.destroy();
}
+ },
+ async closeViewWatchers() {
+ // Tear down chokidar watchers (Nunjucks + JSX share these).
+ await self.closeViewWatchers();
}
}
};
@@ -127,6 +159,19 @@ module.exports = {
methods(self) {
return {
...require('./lib/bundlesLoader')(self),
+ ...require('./lib/jsxRender')(self),
+ ...require('./lib/viewWatcher')(self),
+
+ // Arm chokidar for the view-folder chain of the module whose views
+ // actually contain the resolved JSX file. For a same-module render
+ // that's the caller; for a cross-module render like
+ // `@apostrophecms/page` rendering `@apostrophecms/home-page:page`
+ // it's the target module. Idempotent per absolute directory.
+ watchJsxRenderTargets(callerModule, resolved) {
+ const owner = (resolved && self.apos.modules[resolved.moduleName]) ||
+ callerModule;
+ self.watchViewFolders(self.getViewFolders(owner));
+ },
// Add helpers in the namespace for a particular module.
// They will be visible in nunjucks at
@@ -261,6 +306,24 @@ module.exports = {
let result;
+ // For named files, resolve through the module's view-folder
+ // chain. Chain position wins: a closer directory's .html/.njk
+ // beats a more distant directory's .jsx. JSX only takes
+ // precedence over Nunjucks within the same directory. See
+ // resolveTemplate. Falling back to Nunjucks happens automatically
+ // below when the resolved file is not JSX.
+ if (type === 'file') {
+ const resolved = self.resolveTemplate(module, s);
+ if (resolved && resolved.kind === 'jsx') {
+ const renderData = self.getRenderDataArgs(req, data, module);
+ result = await self.renderJsxTemplate(req, resolved, renderData, module);
+ if (process.platform === 'win32') {
+ result = result.replaceAll('\r', '');
+ }
+ return result;
+ }
+ }
+
const args = self.getRenderArgs(req, data, module);
const env = self.getEnv(req, module);
@@ -495,6 +558,9 @@ module.exports = {
}
if (!self.loaders[key]) {
self.loaders[key] = self.newLoader(moduleName, dirs);
+ // Register these dirs with the shared view watcher (idempotent
+ // per absolute path, so calling it for every loader is fine).
+ self.watchViewFolders(dirs);
}
return self.loaders[key];
},
diff --git a/packages/apostrophe/modules/@apostrophecms/template/lib/jsxLoader.js b/packages/apostrophe/modules/@apostrophecms/template/lib/jsxLoader.js
new file mode 100644
index 0000000000..cf5fdfc12d
--- /dev/null
+++ b/packages/apostrophe/modules/@apostrophecms/template/lib/jsxLoader.js
@@ -0,0 +1,128 @@
+// Compiles Apostrophe `.jsx` template files via Babel and registers a
+// `require.extensions['.jsx']` hook so they can be loaded with `require()`
+// (and `import` after CommonJS transformation) just like normal modules.
+//
+// Each compiled module is automatically prefixed with a `require()` of our
+// JSX runtime so the `h` and `Fragment` identifiers produced by the Babel
+// transform resolve without the user importing them. Both `import` and
+// `require` work inside `.jsx` files because the CommonJS transform also
+// runs.
+//
+// Source maps are kept in memory and wired through `source-map-support`,
+// which means stack traces from a JSX template point at the original
+// `views/page.jsx` line/column rather than the compiled output.
+
+const fs = require('fs');
+const Module = require('module');
+const babel = require('@babel/core');
+const sourceMapSupport = require('source-map-support');
+
+const runtimePath = require.resolve('./jsxRuntime.js');
+
+const sourceMaps = new Map();
+let installed = false;
+
+// Idempotent: register the require hook + source-map handler once per
+// process even if multiple Apostrophe instances boot in the same Node
+// process (e.g. tests, multisite).
+function install() {
+ if (installed) {
+ return;
+ }
+ installed = true;
+
+ sourceMapSupport.install({
+ environment: 'node',
+ hookRequire: false,
+ handleUncaughtExceptions: false,
+ retrieveSourceMap(filename) {
+ const map = sourceMaps.get(filename);
+ if (!map) {
+ return null;
+ }
+ return {
+ url: filename,
+ map
+ };
+ }
+ });
+
+ Module._extensions['.jsx'] = function(module, filename) {
+ const src = fs.readFileSync(filename, 'utf-8');
+ const compiled = compile(src, filename);
+ sourceMaps.set(filename, compiled.map);
+ module._compile(compiled.code, filename);
+ };
+}
+
+// Compile a JSX source string for `filename`. Returns `{ code, map }`.
+// `code` is CommonJS-compatible JS with our runtime injected at the top.
+function compile(src, filename) {
+ let result;
+ try {
+ result = babel.transformSync(src, {
+ filename,
+ sourceMaps: true,
+ sourceFileName: filename,
+ babelrc: false,
+ configFile: false,
+ compact: false,
+ plugins: [
+ [
+ require.resolve('@babel/plugin-transform-react-jsx'),
+ {
+ pragma: '__aposJsx.h',
+ pragmaFrag: '__aposJsx.Fragment',
+ useBuiltIns: false,
+ throwIfNamespace: false
+ }
+ ],
+ require.resolve('@babel/plugin-transform-modules-commonjs')
+ ]
+ });
+ } catch (e) {
+ // Babel errors already include code frames pointing at the offending
+ // line/column. Preserve that detail and add the file path for clarity.
+ const err = new Error(`JSX compile error in ${filename}: ${e.message}`);
+ err.cause = e;
+ err.code = 'APOS_JSX_COMPILE_ERROR';
+ err.filename = filename;
+ throw err;
+ }
+
+ // Inject runtime references. Using a single `__aposJsx` namespace avoids
+ // colliding with user variables named `h` or `Fragment` while still
+ // matching the pragma we passed to Babel above. Source maps remain valid
+ // because we only prepend a single line and rely on a leading `\n` to
+ // keep line numbers stable.
+ const prefix = `var __aposJsx = require(${JSON.stringify(runtimePath)});\n`;
+ return {
+ code: prefix + result.code,
+ map: result.map
+ };
+}
+
+// Drop a single .jsx file from the require cache and our source-map cache.
+// Called by the template module's chokidar watcher when a JSX file changes,
+// so the next render picks up the new code without restarting the process.
+function invalidate(filename) {
+ sourceMaps.delete(filename);
+ delete Module._cache[filename];
+}
+
+// Drop every cached .jsx module and source map. Used when watcher events
+// don't carry a specific path or when an unknown view file was modified.
+function invalidateAll() {
+ for (const filename of sourceMaps.keys()) {
+ delete Module._cache[filename];
+ }
+ sourceMaps.clear();
+}
+
+module.exports = {
+ install,
+ compile,
+ invalidate,
+ invalidateAll,
+ runtimePath
+};
diff --git a/packages/apostrophe/modules/@apostrophecms/template/lib/jsxRender.js b/packages/apostrophe/modules/@apostrophecms/template/lib/jsxRender.js
new file mode 100644
index 0000000000..f1320fe20a
--- /dev/null
+++ b/packages/apostrophe/modules/@apostrophecms/template/lib/jsxRender.js
@@ -0,0 +1,490 @@
+// JSX render orchestration. Mixed into the `@apostrophecms/template`
+// module by `index.js`, this file implements:
+//
+// * Template path resolution that walks the same module chain as Nunjucks
+// but knows about `.jsx` and prefers it when present alongside `.html`.
+// * `renderJsxTemplate(req, resolved, data, module)` which loads the
+// compiled JSX module, invokes its default-exported function with
+// `(data, helpers)`, and flattens the resulting node tree into HTML.
+// * The `Area`, `Component`, `Template`, `Extend`, and `Widget` runtime
+// helpers exposed to JSX templates as the second argument to their
+// default function.
+// * The cross-engine bridge: when a JSX template invokes `Template`/
+// `Extend` against a Nunjucks `.html` layout, the helper synthesizes a
+// Nunjucks string that extends the target and turns each named prop
+// into a `{% block ... %}` override.
+
+const fs = require('fs');
+const path = require('path');
+const _ = require('lodash');
+
+const jsxLoader = require('./jsxLoader.js');
+const {
+ Raw, flatten, registerSafeClass
+} = require('./jsxRuntime.js');
+
+const TEMPLATE_EXTENSIONS = [ 'jsx', 'njk', 'html' ];
+
+module.exports = function(self) {
+ return {
+ // Install the global JSX `require` hook and teach the runtime about
+ // Nunjucks `SafeString` instances so they pass through unescaped.
+ initJsx() {
+ jsxLoader.install();
+ registerSafeClass(self.nunjucks.runtime.SafeString);
+ },
+
+ // Walk a module's view-folder chain and find the first file matching
+ // `name` with one of the supported extensions. JSX wins over Nunjucks
+ // when both exist in the same directory, but the chain ordering still
+ // takes precedence (a child module's `.html` still overrides a parent
+ // module's `.jsx` if it appears earlier in the chain).
+ //
+ // `name` may be `'localname'` (resolved against the supplied
+ // `module`'s chain) or `'modulename:localname'` (resolved against the
+ // named module's chain). An explicit extension is honored verbatim.
+ //
+ // Returns `{ kind: 'jsx' | 'nunjucks', path, ext, moduleName,
+ // baseName, ext, requestedName }` or `null` when nothing was found.
+ resolveTemplate(module, name) {
+ let moduleName = module.__meta.name;
+ let filename = name;
+ const colonAt = name.indexOf(':');
+ if (colonAt !== -1) {
+ moduleName = name.substring(0, colonAt);
+ filename = name.substring(colonAt + 1);
+ }
+ const targetModule = self.apos.modules[moduleName];
+ if (!targetModule) {
+ return null;
+ }
+ const dirs = self.getViewFolders(targetModule);
+ const m = filename.match(/^(.*)\.([^/.]+)$/);
+ let baseName = filename;
+ let requestedExt = null;
+ if (m) {
+ baseName = m[1];
+ requestedExt = m[2];
+ }
+ // For requests without an extension, or for the well-known template
+ // extensions, allow falling back to any of them. For an unknown
+ // extension (e.g. `.svg`) preserve current Nunjucks-loader behavior:
+ // try only the literal name.
+ const exts = (!requestedExt || TEMPLATE_EXTENSIONS.includes(requestedExt))
+ ? TEMPLATE_EXTENSIONS
+ : [ requestedExt ];
+
+ for (const dir of dirs) {
+ for (const ext of exts) {
+ const fullpath = path.join(dir, `${baseName}.${ext}`);
+ if (fs.existsSync(fullpath)) {
+ return {
+ kind: ext === 'jsx' ? 'jsx' : 'nunjucks',
+ path: fullpath,
+ ext,
+ moduleName,
+ baseName,
+ relativeName: `${baseName}.${ext}`,
+ requestedName: name
+ };
+ }
+ }
+ }
+ return null;
+ },
+
+ // Render the JSX module at `resolved.path` against `data`. Loads the
+ // compiled module via Node's require (the `.jsx` extension hook
+ // installed by `jsxLoader` does the Babel transform on first load),
+ // calls its default function, and flattens the returned node tree.
+ async renderJsxTemplate(req, resolved, data, module) {
+ self.watchJsxRenderTargets(module, resolved);
+ let mod;
+ try {
+ mod = require(resolved.path);
+ } catch (e) {
+ throw decorateJsxError(e, resolved.path);
+ }
+ const fn = (mod && mod.default) || mod;
+ if (typeof fn !== 'function') {
+ throw new Error(
+ `JSX template ${resolved.path} must export a default function. ` +
+ `Got ${typeof fn}.`
+ );
+ }
+ const helpers = self.buildJsxHelpers(req, module, data);
+ let result;
+ try {
+ result = await fn(data, helpers);
+ } catch (e) {
+ throw decorateJsxError(e, resolved.path);
+ }
+ try {
+ return await flatten(result);
+ } catch (e) {
+ throw decorateJsxError(e, resolved.path);
+ }
+ },
+
+ // Build the `{ apos, helpers, Area, Component, Extend, Template,
+ // Widget, ... }` object passed as the second argument to every JSX
+ // template. The closure captures `req`, `module` (the module whose
+ // template is currently being rendered, used to resolve `Template`
+ // names without a `module:` prefix), and `ambientData` (the merged
+ // render data for the current invocation, threaded through
+ // `Template`/`Extend` to mirror Nunjucks' `extends` semantics).
+ buildJsxHelpers(req, module, ambientData) {
+ const helpers = {
+ // Full `self.apos`. Unlike Nunjucks, JSX supports await, so
+ // there is no need to restrict access to this object.
+ apos: self.apos,
+ // The Nunjucks-compatible wrapper. Carries `addHelpers`-registered
+ // helpers under `helpers.modules['module-name'].method(...)` and
+ // module aliases, matching the `apos` object available in
+ // Nunjucks templates. Distinct from the JSX `apos` above.
+ helpers: self.templateApos,
+ // Localization helper, matching the Nunjucks `__t` global.
+ __t: req.t && req.t.bind(req),
+
+ Area: (props) => self.jsxArea(req, props),
+ Component: (props) => self.jsxComponent(req, props),
+ Widget: (props) => self.jsxWidget(req, props),
+ Template: (props) => self.jsxInvoke(req, module, props, {
+ mode: 'include',
+ ambientData
+ }),
+ Extend: (props) => self.jsxInvoke(req, module, props, {
+ mode: 'extend',
+ ambientData
+ })
+ };
+ return helpers;
+ },
+
+ // Implementation of the `` helper.
+ // Mirrors the Nunjucks `{% area %}` custom tag closely so behavior
+ // (including stub-area persistence) is identical.
+ jsxArea(req, props) {
+ const {
+ doc, name, with: ctx
+ } = props || {};
+ return (async () => {
+ if (!doc || typeof doc !== 'object') {
+ throw new Error(
+ 'Area: the `doc` prop must be an existing doc or widget object.'
+ );
+ }
+ if (typeof name !== 'string') {
+ throw new Error('Area: the `name` prop must be a string.');
+ }
+ if (!name.match(/^\w+$/)) {
+ throw new Error(
+ 'Area: area names must consist only of letters, digits, and underscores.'
+ );
+ }
+ let area = doc[name];
+ if (!area) {
+ // Same stub-into-db logic as the {% area %} tag, so that newly
+ // added schema fields get a persistent `_id` on first render.
+ area = {
+ metaType: 'area',
+ _id: self.apos.util.generateId(),
+ items: []
+ };
+ doc[name] = area;
+ const docId = doc._docId || ((doc.metaType === 'doc') ? doc._id : null);
+ if (docId) {
+ let mainDoc = await self.apos.doc.db.findOne({ _id: docId });
+ if (!mainDoc) {
+ throw self.apos.error('notfound');
+ }
+ let docDotPath;
+ try {
+ docDotPath = (doc._id === docId)
+ ? ''
+ : self.apos.util.findNestedObjectAndDotPathById(mainDoc, doc._id).dotPath;
+ } catch (e) {
+ throw self.apos.error('notfound');
+ }
+ const areaDotPath = docDotPath ? `${docDotPath}.${name}` : name;
+ await self.apos.doc.db.updateOne({
+ _id: docId,
+ [areaDotPath]: { $eq: null }
+ }, {
+ $set: {
+ [areaDotPath]: self.apos.util.clonePermanent(area)
+ }
+ });
+ mainDoc = await self.apos.doc.db.findOne({ _id: docId });
+ area._id = self.apos.util.get(mainDoc, areaDotPath)._id;
+ }
+ }
+ const manager = self.apos.util.getManagerOf(doc);
+ const field = manager && manager.schema.find(f => f.name === name);
+ if (!field) {
+ throw new Error(
+ `Area: the doc of type ${doc.type} with the slug ${doc.slug} ` +
+ `has no field named ${name}.`
+ );
+ }
+ area._fieldId = field._id;
+ area._docId = doc._docId || ((doc.metaType === 'doc') ? doc._id : null);
+ area._edit = area._edit || doc._edit;
+ self.apos.area.prepForRender(area, doc, name);
+ const html = await self.apos.area.renderArea(req, area, ctx);
+ return new Raw(html);
+ })();
+ },
+
+ // Implementation of ``.
+ // Looks up the named async component, awaits it, then renders the
+ // component's matching template via the existing module render path.
+ jsxComponent(req, props) {
+ const {
+ module: moduleName, name, children, ...rest
+ } = props || {};
+ return (async () => {
+ if (typeof moduleName !== 'string' || typeof name !== 'string') {
+ throw new Error(
+ 'Component: both `module` and `name` props must be strings.'
+ );
+ }
+ const target = self.apos.modules[moduleName];
+ if (!target) {
+ throw new Error(
+ `Component: module "${moduleName}" does not exist. ` +
+ 'It must be a real, instantiated module, not a base class.'
+ );
+ }
+ if (!(target.components && target.components[name])) {
+ throw new Error(
+ `Component: ${moduleName}:${name} is not a registered async component.`
+ );
+ }
+ // Components receive plain data: any JSX nodes passed as props
+ // (including `children`) need to be flattened to HTML strings
+ // first so the underlying Nunjucks/JSX component template can
+ // safely render them.
+ const inputProps = await self.flattenJsxProps({
+ ...rest,
+ children
+ });
+ const result = await self.apos.util.recursionGuard(
+ req,
+ `component:${moduleName}:${name}`,
+ async () => {
+ const input = await target.components[name](req, inputProps);
+ return target.render(req, name, input);
+ }
+ );
+ if (result === undefined) {
+ // Recursion guard kicked in.
+ return new Raw('');
+ }
+ return new Raw(result);
+ })();
+ },
+
+ // Implementation of ``.
+ // Mirrors the Nunjucks `{% widget %}` tag, intended only for users
+ // reimplementing `area.html` in JSX.
+ jsxWidget(req, props) {
+ const {
+ widget, options, with: contextOptions
+ } = props || {};
+ return (async () => {
+ if (!widget) {
+ self.apos.util.warn('a null widget was encountered.');
+ return new Raw('');
+ }
+ const opts = options || {};
+ let ctxOpts = {};
+ if (contextOptions && typeof contextOptions === 'object' && contextOptions[widget.type]) {
+ ctxOpts = (typeof contextOptions[widget.type] === 'object')
+ ? contextOptions[widget.type]
+ : {};
+ }
+ const manager = self.apos.area.getWidgetManager(widget.type);
+ if (!manager) {
+ self.apos.area.warnMissingWidgetType(widget.type);
+ return new Raw('');
+ }
+ const html = await manager.output(req, widget, opts, ctxOpts);
+ return new Raw(html);
+ })();
+ },
+
+ // Shared implementation of `Template` and `Extend`. The rules:
+ //
+ // * Strip `templateName`/`name` per the spec — `templateName` always
+ // wins as the file selector, otherwise `name` is the file selector
+ // AND is *not* passed through as a data prop.
+ // * Resolve the file. JSX targets receive the props (plus ambient
+ // data) and `children` natively; Nunjucks targets receive props
+ // as block overrides (`extend` mode) or as data (`include` mode
+ // when called via `Template` against a Nunjucks file).
+ jsxInvoke(req, callerModule, props, { mode, ambientData }) {
+ const {
+ templateName, name, ...rest
+ } = props || {};
+ let targetName;
+ const dataProps = { ...rest };
+ if (templateName !== undefined) {
+ targetName = templateName;
+ // Per spec: `name` is forwarded as a normal prop only when
+ // `templateName` is also present.
+ if (name !== undefined) {
+ dataProps.name = name;
+ }
+ } else {
+ targetName = name;
+ }
+ if (typeof targetName !== 'string') {
+ throw new Error(
+ 'Template/Extend: pass a string to the `templateName` prop ' +
+ '(or `name` when no other prop named `name` is needed).'
+ );
+ }
+ return (async () => {
+ const resolved = self.resolveTemplate(callerModule, targetName);
+ if (!resolved) {
+ throw new Error(`Template/Extend: could not resolve template "${targetName}".`);
+ }
+ if (resolved.kind === 'jsx') {
+ // For JSX targets both Template and Extend behave the same:
+ // the props (with the parent's ambient data underneath) are
+ // passed straight through. JSX values like `children` flow as
+ // node trees — they are flattened only when the target template
+ // emits them.
+ const targetModule = self.apos.modules[resolved.moduleName];
+ const merged = {
+ ...(ambientData || {}),
+ ...dataProps
+ };
+ const html = await self.renderJsxTemplate(req, resolved, merged, targetModule);
+ return new Raw(html);
+ }
+ // Nunjucks target.
+ if (mode === 'extend') {
+ return self.renderNunjucksWithBlocks(
+ req, resolved, dataProps, ambientData
+ );
+ }
+ // mode === 'include': render the Nunjucks template with our
+ // props merged on top of ambient data, the same way a Nunjucks
+ // `{% include %}` would inherit data.
+ const flatProps = await self.flattenJsxProps(dataProps);
+ const targetModule = self.apos.modules[resolved.moduleName];
+ const data = {
+ ...(ambientData || {}),
+ ...flatProps
+ };
+ const html = await targetModule.render(req, resolved.relativeName, data);
+ return new Raw(html);
+ })();
+ },
+
+ // Invoke a Nunjucks template via `extends` so that JSX-supplied props
+ // become `{% block %}` overrides. The synthetic Nunjucks template
+ // declares one block per prop, each emitting the matching string
+ // marked safe so already-rendered HTML survives.
+ async renderNunjucksWithBlocks(req, resolved, props, ambientData) {
+ const targetModule = self.apos.modules[resolved.moduleName];
+ const blocks = {};
+ for (const key of Object.keys(props)) {
+ const html = await flattenToHtml(props[key]);
+ blocks[key] = html;
+ }
+ const blockNames = Object.keys(blocks);
+ const targetRef = `${resolved.moduleName}:${resolved.relativeName}`;
+ const lines = [ `{% extends ${JSON.stringify(targetRef)} %}` ];
+ for (const blockName of blockNames) {
+ if (!/^[A-Za-z_][\w]*$/.test(blockName)) {
+ throw new Error(
+ 'Template/Extend: prop names used as block overrides must be ' +
+ `valid identifiers (got "${blockName}").`
+ );
+ }
+ lines.push(
+ `{% block ${blockName} %}{{ data.aposJsxBlocks[${JSON.stringify(blockName)}] | safe }}{% endblock %}`
+ );
+ }
+ const synthetic = lines.join('\n');
+ // Layer ambient page data underneath so the Nunjucks layout has
+ // access to `data.outerLayout`, `data.page`, etc.; our blocks
+ // override only what they explicitly name.
+ const data = {
+ ...(ambientData || {}),
+ aposJsxBlocks: blocks
+ };
+ const html = await targetModule.renderString(req, synthetic, data);
+ return new Raw(html);
+ },
+
+ // Walk a props object, flattening any JSX node values to HTML strings
+ // so they can cross into Nunjucks (which can't deal with our internal
+ // node arrays). Plain primitive props pass through unchanged.
+ async flattenJsxProps(props) {
+ const out = {};
+ for (const key of Object.keys(props)) {
+ const value = props[key];
+ if (value === undefined) {
+ continue;
+ }
+ if (isJsxNode(value)) {
+ out[key] = self.safe(await flatten(value));
+ } else {
+ out[key] = value;
+ }
+ }
+ return out;
+ }
+ };
+};
+
+// Decide whether a value should be flattened by the JSX runtime before
+// being handed to Nunjucks. Arrays, Raw markers, and thenables produced
+// by our helpers all need flattening; everything else (strings, numbers,
+// docs, options objects) is forwarded as-is.
+function isJsxNode(value) {
+ if (value == null) {
+ return false;
+ }
+ if (Array.isArray(value)) {
+ return true;
+ }
+ if (value instanceof Raw) {
+ return true;
+ }
+ if (typeof value === 'object' && typeof value.then === 'function') {
+ return true;
+ }
+ return false;
+}
+
+// Convenience wrapper used when a JSX-supplied prop becomes a Nunjucks
+// block override: regardless of input type, produce the final HTML string
+// that the synthesized template will emit via the `safe` filter.
+async function flattenToHtml(value) {
+ if (value == null || value === false) {
+ return '';
+ }
+ if (isJsxNode(value)) {
+ return await flatten(value);
+ }
+ return String(value);
+}
+
+// Annotate exceptions thrown out of a JSX template with the file path so
+// the error log clearly identifies which template failed. Source maps
+// (installed by `jsxLoader`) take care of accurate line/column info.
+function decorateJsxError(error, file) {
+ if (!error || typeof error !== 'object') {
+ return error;
+ }
+ if (!error.aposJsxFile) {
+ error.aposJsxFile = file;
+ error.message = `[JSX template ${file}] ${error.message}`;
+ }
+ return error;
+}
diff --git a/packages/apostrophe/modules/@apostrophecms/template/lib/jsxRuntime.js b/packages/apostrophe/modules/@apostrophecms/template/lib/jsxRuntime.js
new file mode 100644
index 0000000000..915a11900c
--- /dev/null
+++ b/packages/apostrophe/modules/@apostrophecms/template/lib/jsxRuntime.js
@@ -0,0 +1,276 @@
+// JSX runtime used by Apostrophe `.jsx` templates. The classic Babel
+// transform (`@babel/plugin-transform-react-jsx`) compiles `...`
+// into `h(Tag, { a: x }, ...children)` and `<>...>` into
+// `h(Fragment, null, ...children)`. The `h` function and `Fragment` symbol
+// are injected into every compiled module by `jsxLoader.js`.
+//
+// Output model: `h` returns a nested array of strings, `Raw` markers,
+// promises, and arrays. The array is flattened by `flatten()` which awaits
+// every promise, escapes plain string values, and joins everything into a
+// final HTML string. This array+promise model is what allows JSX templates
+// to call asynchronous helpers (`Area`, `Component`, `Template`, `Extend`)
+// directly inside markup without wrapping them in `await`.
+
+const voidElements = require('void-elements');
+
+const Fragment = Symbol('AposJsxFragment');
+
+// Marker class for already-rendered raw HTML. Anything wrapped in a `Raw`
+// is emitted into the final output without any additional escaping.
+class Raw {
+ constructor(html) {
+ this.html = (html == null) ? '' : String(html);
+ }
+}
+
+// Compatibility hook: Nunjucks `SafeString` instances (returned by Apostrophe
+// helpers like `apos.area.html()` and the `safe` filter) need to flow through
+// JSX templates without being escaped a second time. `template/index.js`
+// registers Nunjucks's `SafeString` class via `registerSafeClass()`; any
+// instance of a registered class is treated as raw HTML.
+const safeClasses = [];
+
+function registerSafeClass(cls) {
+ if (cls && !safeClasses.includes(cls)) {
+ safeClasses.push(cls);
+ }
+}
+
+function isSafeInstance(value) {
+ if (value == null || typeof value !== 'object') {
+ return false;
+ }
+ for (const cls of safeClasses) {
+ if (value instanceof cls) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function escapeHtml(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+function escapeAttr(value) {
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+}
+
+// Build the JSX node for an element, function component, or fragment.
+// For function types (including the runtime helpers `Area`, `Component`,
+// `Template`, `Extend`, `Widget`) we invoke the function with a single
+// `props` object that includes `children`, matching React conventions.
+function h(type, props, ...children) {
+ props = props || {};
+ if (type === Fragment) {
+ return children;
+ }
+ if (typeof type === 'function') {
+ const finalProps = { ...props };
+ if (children.length > 0) {
+ finalProps.children = (children.length === 1) ? children[0] : children;
+ }
+ return type(finalProps);
+ }
+ if (typeof type !== 'string') {
+ throw new Error(`Invalid JSX element type: ${typeof type}`);
+ }
+ return buildElement(type, props, children);
+}
+
+function buildElement(tag, props, children) {
+ let attrs = '';
+ let dangerous = null;
+ for (const key of Object.keys(props)) {
+ if (key === 'children' || key === 'key' || key === 'ref') {
+ continue;
+ }
+ if (key === 'dangerouslySetInnerHTML') {
+ const v = props[key];
+ if (v && typeof v.__html === 'string') {
+ dangerous = v.__html;
+ } else if (v && v.__html != null) {
+ dangerous = String(v.__html);
+ }
+ continue;
+ }
+ const value = props[key];
+ if (value == null || value === false) {
+ continue;
+ }
+ const attrName = jsxAttrName(key);
+ if (value === true) {
+ attrs += ` ${attrName}`;
+ } else {
+ attrs += ` ${attrName}="${escapeAttr(value)}"`;
+ }
+ }
+
+ if (dangerous != null) {
+ return [
+ new Raw(`<${tag}${attrs}>`),
+ new Raw(dangerous),
+ new Raw(`${tag}>`)
+ ];
+ }
+
+ if (voidElements[tag] && children.length === 0) {
+ return [ new Raw(`<${tag}${attrs} />`) ];
+ }
+
+ return [
+ new Raw(`<${tag}${attrs}>`),
+ ...children,
+ new Raw(`${tag}>`)
+ ];
+}
+
+// Match React's friendly attribute names to standard HTML/SVG attribute
+// names. `data-*` and `aria-*` props pass through verbatim; SVG presentation
+// attributes (`strokeWidth`, `fillRule`, etc.) are converted to the
+// kebab-case form actually understood by browsers when the document is
+// parsed as text/html. The handful of SVG attributes that are *natively*
+// camelCase (e.g. `viewBox`, `preserveAspectRatio`) stay as-is.
+const svgAttrMap = {
+ // Stroke
+ strokeWidth: 'stroke-width',
+ strokeLinecap: 'stroke-linecap',
+ strokeLinejoin: 'stroke-linejoin',
+ strokeDasharray: 'stroke-dasharray',
+ strokeDashoffset: 'stroke-dashoffset',
+ strokeMiterlimit: 'stroke-miterlimit',
+ strokeOpacity: 'stroke-opacity',
+ // Fill
+ fillOpacity: 'fill-opacity',
+ fillRule: 'fill-rule',
+ // Clip / mask
+ clipPath: 'clip-path',
+ clipRule: 'clip-rule',
+ // Text
+ textAnchor: 'text-anchor',
+ textDecoration: 'text-decoration',
+ alignmentBaseline: 'alignment-baseline',
+ baselineShift: 'baseline-shift',
+ dominantBaseline: 'dominant-baseline',
+ fontFamily: 'font-family',
+ fontSize: 'font-size',
+ fontStyle: 'font-style',
+ fontVariant: 'font-variant',
+ fontWeight: 'font-weight',
+ letterSpacing: 'letter-spacing',
+ wordSpacing: 'word-spacing',
+ // Generic
+ colorInterpolation: 'color-interpolation',
+ colorInterpolationFilters: 'color-interpolation-filters',
+ colorProfile: 'color-profile',
+ colorRendering: 'color-rendering',
+ fillRendering: 'fill-rendering',
+ imageRendering: 'image-rendering',
+ shapeRendering: 'shape-rendering',
+ textRendering: 'text-rendering',
+ pointerEvents: 'pointer-events',
+ unicodeBidi: 'unicode-bidi',
+ vectorEffect: 'vector-effect',
+ writingMode: 'writing-mode',
+ enableBackground: 'enable-background',
+ floodColor: 'flood-color',
+ floodOpacity: 'flood-opacity',
+ glyphOrientationHorizontal: 'glyph-orientation-horizontal',
+ glyphOrientationVertical: 'glyph-orientation-vertical',
+ lightingColor: 'lighting-color',
+ markerEnd: 'marker-end',
+ markerMid: 'marker-mid',
+ markerStart: 'marker-start',
+ overflowWrap: 'overflow-wrap',
+ paintOrder: 'paint-order',
+ stopColor: 'stop-color',
+ stopOpacity: 'stop-opacity',
+ // Linked references
+ xlinkHref: 'xlink:href',
+ xlinkRole: 'xlink:role',
+ xlinkTitle: 'xlink:title',
+ xlinkType: 'xlink:type',
+ xlinkArcrole: 'xlink:arcrole',
+ xlinkActuate: 'xlink:actuate',
+ xlinkShow: 'xlink:show',
+ xmlBase: 'xml:base',
+ xmlLang: 'xml:lang',
+ xmlSpace: 'xml:space',
+ xmlnsXlink: 'xmlns:xlink'
+};
+
+function jsxAttrName(name) {
+ if (name === 'className') {
+ return 'class';
+ }
+ if (name === 'htmlFor') {
+ return 'for';
+ }
+ const svg = svgAttrMap[name];
+ if (svg) {
+ return svg;
+ }
+ return name;
+}
+
+// Walk the tree of strings, Raw markers, promises, and arrays, awaiting
+// every promise and producing a final HTML string. Plain strings/numbers
+// are escaped; Raw and Nunjucks SafeString values are not.
+async function flatten(node) {
+ const out = [];
+ await walk(node, out);
+ return out.join('');
+}
+
+async function walk(node, out) {
+ if (node == null || node === false || node === true) {
+ return;
+ }
+ if (Array.isArray(node)) {
+ for (const item of node) {
+ await walk(item, out);
+ }
+ return;
+ }
+ if (node instanceof Raw) {
+ out.push(node.html);
+ return;
+ }
+ if (isSafeInstance(node)) {
+ out.push(node.toString());
+ return;
+ }
+ if (typeof node === 'object' && typeof node.then === 'function') {
+ const resolved = await node;
+ await walk(resolved, out);
+ return;
+ }
+ if (typeof node === 'string') {
+ out.push(escapeHtml(node));
+ return;
+ }
+ if (typeof node === 'number' || typeof node === 'bigint') {
+ out.push(escapeHtml(String(node)));
+ return;
+ }
+ // Anything else (objects without a known handler) is coerced to string
+ // and escaped, mirroring how React stringifies unexpected children.
+ out.push(escapeHtml(String(node)));
+}
+
+module.exports = {
+ h,
+ Fragment,
+ Raw,
+ flatten,
+ escapeHtml,
+ escapeAttr,
+ registerSafeClass
+};
diff --git a/packages/apostrophe/modules/@apostrophecms/template/lib/nunjucksLoader.js b/packages/apostrophe/modules/@apostrophecms/template/lib/nunjucksLoader.js
index 3bfe99801b..38c0bd133f 100644
--- a/packages/apostrophe/modules/@apostrophecms/template/lib/nunjucksLoader.js
+++ b/packages/apostrophe/modules/@apostrophecms/template/lib/nunjucksLoader.js
@@ -7,23 +7,26 @@
// Note that if @apostrophecms/template has a project-level override
// of outerLayout.html, that will be loaded instead. This is
// intentional.
+//
+// File watching and cache invalidation are deliberately NOT handled here:
+// the template module wires up `viewWatcher.js` once for both Nunjucks and
+// JSX, and clears every loader's `cache` (read by Nunjucks itself) when a
+// view file changes.
const fs = require('fs');
const path = require('path');
const _ = require('lodash');
const { stripIndent } = require('common-tags');
-const chokidar = require('chokidar');
module.exports = function(moduleName, searchPaths, noWatch, templates, options) {
const self = this;
- self.watches = [];
options = options || {};
const extensions = options.extensions || [ 'njk', 'html' ];
self.moduleName = moduleName;
self.templates = templates;
- self.init = function(searchPaths, noWatch) {
+ self.init = function(searchPaths) {
self.pathsToNames = {};
if (searchPaths) {
searchPaths = Array.isArray(searchPaths) ? searchPaths : [ searchPaths ];
@@ -32,34 +35,6 @@ module.exports = function(moduleName, searchPaths, noWatch, templates, options)
} else {
self.searchPaths = [];
}
- // Unless and until chokidar declares this a supported config,
- // no watching in WSL (it doesn't work without chokidar either)
- if ((!noWatch) && (!require('is-wsl'))) {
- _.each(self.searchPaths, function(p) {
- if (fs.existsSync(p)) {
- try {
- const watcher = chokidar.watch(p);
- watcher.on('change', (path, stats) => {
- // Just blow the whole cache if anything is modified. Much
- // simpler, avoids several false negatives, and works well for a
- // CMS in dev. -Tom
- self.cache = {};
- });
- self.watches.push(watcher);
- } catch (e) {
- if (!self.firstWatchFailure) {
- // Don't crash in broken environments (not sure if any are left
- // thanks to chokidar, but still a useful warning to have if it
- // comes up)
- self.firstWatchFailure = true;
- self.templates.apos.util.warn('WARNING: fs.watch does not work on this system. That is OK but you\n' +
- 'will have to restart to see any template changes take effect.');
- }
- self.templates.apos.util.error(e);
- }
- }
- });
- }
};
self.isRelative = function(filename) {
@@ -183,11 +158,11 @@ module.exports = function(moduleName, searchPaths, noWatch, templates, options)
}
};
- self.destroy = async () => {
- for (const watch of self.watches) {
- await watch.close();
- }
- };
+ // Retained for backwards compatibility — file watching now lives in
+ // `viewWatcher.js`, owned by the template module itself, so there is
+ // nothing for the loader to dispose of. Callers (Apostrophe internals
+ // and tests) may still invoke `destroy()` and that must keep working.
+ self.destroy = async () => {};
self.init(searchPaths, noWatch);
};
diff --git a/packages/apostrophe/modules/@apostrophecms/template/lib/viewWatcher.js b/packages/apostrophe/modules/@apostrophecms/template/lib/viewWatcher.js
new file mode 100644
index 0000000000..5442f71432
--- /dev/null
+++ b/packages/apostrophe/modules/@apostrophecms/template/lib/viewWatcher.js
@@ -0,0 +1,108 @@
+// File-watching for view directories shared by Nunjucks and JSX.
+//
+// Owns one chokidar watcher per directory and dispatches change events to
+// any number of registered handlers. The template module wires this up at
+// init time with two handlers: clear every Nunjucks loader's cache, and
+// invalidate compiled `.jsx` modules so the next render reloads them.
+//
+// WSL safety: chokidar's underlying file events are unreliable on WSL, so
+// we deliberately skip watching there — same behavior as the previous
+// in-loader code. Developers running on WSL will need to restart to pick
+// up template edits, but the rest of Apostrophe still functions.
+//
+// Returns a methods mixin compatible with `@apostrophecms/module`'s
+// `methods` factory pattern, the same way `bundlesLoader` and `jsxRender`
+// are mixed in.
+
+const fs = require('fs');
+const chokidar = require('chokidar');
+const isWsl = require('is-wsl');
+
+module.exports = (self) => {
+ // Lazily-allocated state. Lives on `self` so it survives across method
+ // calls and can be inspected by tests if needed.
+ if (!self._viewWatchers) {
+ self._viewWatchers = [];
+ }
+ if (!self._viewWatchedDirs) {
+ self._viewWatchedDirs = new Set();
+ }
+ if (!self._viewChangeHandlers) {
+ self._viewChangeHandlers = [];
+ }
+
+ return {
+ // Begin watching the supplied directories for change events. Idempotent
+ // per absolute path: subsequent calls with the same dir do not create
+ // duplicate watchers. Honors the WSL skip flag and the loader's
+ // `noWatch` option (templates module exposes this via
+ // `options.loader.noWatch`).
+ watchViewFolders(dirs) {
+ if (self.options.loader && self.options.loader.noWatch) {
+ return;
+ }
+ // chokidar's recursive watching is not reliable on WSL; preserve the
+ // historical behavior of skipping watch setup entirely there.
+ if (isWsl) {
+ return;
+ }
+ for (const dir of dirs) {
+ if (self._viewWatchedDirs.has(dir)) {
+ continue;
+ }
+ if (!fs.existsSync(dir)) {
+ continue;
+ }
+ self._viewWatchedDirs.add(dir);
+ try {
+ const watcher = chokidar.watch(dir);
+ watcher.on('change', (filePath) => {
+ for (const handler of self._viewChangeHandlers) {
+ try {
+ handler(filePath);
+ } catch (e) {
+ self.apos.util.error(e);
+ }
+ }
+ });
+ self._viewWatchers.push(watcher);
+ } catch (e) {
+ // Don't crash in broken environments. Warn at most once so the
+ // logs don't get spammed for every dir we fail to watch.
+ if (!self._viewFirstWatchFailure) {
+ self._viewFirstWatchFailure = true;
+ self.apos.util.warn(
+ 'WARNING: fs.watch does not work on this system. That is OK but you\n' +
+ 'will have to restart to see any template changes take effect.'
+ );
+ }
+ self.apos.util.error(e);
+ }
+ }
+ },
+
+ // Register a callback invoked with the absolute path of the changed
+ // file every time chokidar reports a `change` event. Handlers run in
+ // registration order. Errors thrown by a handler are logged but never
+ // stop other handlers from running.
+ onViewChange(handler) {
+ self._viewChangeHandlers.push(handler);
+ },
+
+ // Tear down every chokidar watcher created via `watchViewFolders`.
+ // Called from the `apostrophe:destroy` handler so a process that
+ // builds and tears down multiple Apostrophe instances (tests, the
+ // multisite harness) doesn't leak file descriptors.
+ async closeViewWatchers() {
+ const watchers = self._viewWatchers.splice(0, self._viewWatchers.length);
+ self._viewWatchedDirs.clear();
+ for (const watcher of watchers) {
+ try {
+ await watcher.close();
+ } catch (e) {
+ self.apos.util.error(e);
+ }
+ }
+ }
+ };
+};
diff --git a/packages/apostrophe/package.json b/packages/apostrophe/package.json
index a01079b6d2..198249386d 100644
--- a/packages/apostrophe/package.json
+++ b/packages/apostrophe/package.json
@@ -39,6 +39,9 @@
"dependencies": {
"@apostrophecms/db-connect": "workspace:^",
"@apostrophecms/vue-material-design-icons": "^1.0.0",
+ "@babel/core": "^7.29.0",
+ "@babel/plugin-transform-modules-commonjs": "^7.28.0",
+ "@babel/plugin-transform-react-jsx": "^7.28.0",
"@ctrl/tinycolor": "^4.1.0",
"@floating-ui/dom": "^1.5.3",
"@opentelemetry/api": "^1.9.0",
@@ -126,6 +129,7 @@
"sluggo": "^1.0.0",
"sortablejs": "^1.15.0",
"sortablejs-vue3": "^1.2.11",
+ "source-map-support": "^0.5.21",
"tiny-emitter": "^2.1.0",
"tough-cookie": "^4.0.0",
"underscore.string": "^3.3.4",
diff --git a/packages/apostrophe/test/add-missing-schema-fields-project/test.js b/packages/apostrophe/test/add-missing-schema-fields-project/test.js
index 819a96c445..9d84f01341 100644
--- a/packages/apostrophe/test/add-missing-schema-fields-project/test.js
+++ b/packages/apostrophe/test/add-missing-schema-fields-project/test.js
@@ -16,7 +16,12 @@ describe('Apostrophe - add-missing-schema-fields task', function() {
const projectCwd = path.resolve(process.cwd(), 'test/add-missing-schema-fields-project/');
const testDbUri = t.getTestDbUri('add-missing-schema-fields-project');
const execEnv = testDbUri
- ? { env: { ...process.env, APOS_DB_URI: testDbUri } }
+ ? {
+ env: {
+ ...process.env,
+ APOS_DB_URI: testDbUri
+ }
+ }
: {};
before(async function() {
@@ -33,7 +38,10 @@ describe('Apostrophe - add-missing-schema-fields task', function() {
it('should not run migrations when running the task', async function() {
await util.promisify(exec)(
'node app.js @apostrophecms/migration:add-missing-schema-fields',
- { cwd: projectCwd, ...execEnv }
+ {
+ cwd: projectCwd,
+ ...execEnv
+ }
);
apos = await t.create({
@@ -72,7 +80,10 @@ describe('Apostrophe - add-missing-schema-fields task', function() {
it('should run migrations when running @apostrophecms/migration:migrate task', async function() {
await util.promisify(exec)(
'node app.js @apostrophecms/migration:migrate',
- { cwd: projectCwd, ...execEnv }
+ {
+ cwd: projectCwd,
+ ...execEnv
+ }
);
apos = await t.create({
diff --git a/packages/apostrophe/test/modules/jsx-area-test/index.js b/packages/apostrophe/test/modules/jsx-area-test/index.js
new file mode 100644
index 0000000000..e750c729b2
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-area-test/index.js
@@ -0,0 +1,23 @@
+module.exports = {
+ extend: '@apostrophecms/piece-type',
+ options: {
+ alias: 'jsxAreaTest',
+ name: 'jsx-area-test',
+ label: 'JSX Area Test'
+ },
+ fields: {
+ add: {
+ main: {
+ type: 'area',
+ label: 'Main',
+ options: {
+ widgets: {
+ '@apostrophecms/rich-text': {},
+ 'jsx-async': {},
+ 'jsx-ctx': {}
+ }
+ }
+ }
+ }
+ }
+};
diff --git a/packages/apostrophe/test/modules/jsx-area-test/views/bad-area.jsx b/packages/apostrophe/test/modules/jsx-area-test/views/bad-area.jsx
new file mode 100644
index 0000000000..d9ce539fec
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-area-test/views/bad-area.jsx
@@ -0,0 +1,7 @@
+export default function (data, { Area }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-area-test/views/with-area-ctx.jsx b/packages/apostrophe/test/modules/jsx-area-test/views/with-area-ctx.jsx
new file mode 100644
index 0000000000..da3cb9b266
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-area-test/views/with-area-ctx.jsx
@@ -0,0 +1,13 @@
+export default function (data, { Area }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-area-test/views/with-area.jsx b/packages/apostrophe/test/modules/jsx-area-test/views/with-area.jsx
new file mode 100644
index 0000000000..99a1985f53
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-area-test/views/with-area.jsx
@@ -0,0 +1,7 @@
+export default function (data, { Area }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-area-test/views/with-widget-ctx.jsx b/packages/apostrophe/test/modules/jsx-area-test/views/with-widget-ctx.jsx
new file mode 100644
index 0000000000..17aedd9bd5
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-area-test/views/with-widget-ctx.jsx
@@ -0,0 +1,12 @@
+export default function (data, { Widget }) {
+ return (
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-area-test/views/with-widget.jsx b/packages/apostrophe/test/modules/jsx-area-test/views/with-widget.jsx
new file mode 100644
index 0000000000..a1e801e38b
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-area-test/views/with-widget.jsx
@@ -0,0 +1,7 @@
+export default function (data, { Widget }) {
+ return (
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-async-widget/index.js b/packages/apostrophe/test/modules/jsx-async-widget/index.js
new file mode 100644
index 0000000000..6c4bdf2899
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-async-widget/index.js
@@ -0,0 +1,6 @@
+module.exports = {
+ extend: '@apostrophecms/widget-type',
+ options: {
+ label: 'JSX Async Widget'
+ }
+};
diff --git a/packages/apostrophe/test/modules/jsx-async-widget/views/widget.jsx b/packages/apostrophe/test/modules/jsx-async-widget/views/widget.jsx
new file mode 100644
index 0000000000..9ad8ce16e8
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-async-widget/views/widget.jsx
@@ -0,0 +1,11 @@
+export default function (data, { Component }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/index.js b/packages/apostrophe/test/modules/jsx-bridge-test/index.js
new file mode 100644
index 0000000000..f053ebf797
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/index.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/cross-module.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/cross-module.jsx
new file mode 100644
index 0000000000..f80d7e44fb
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/cross-module.jsx
@@ -0,0 +1,7 @@
+export default function (data, { Template }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-name-only.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-name-only.jsx
new file mode 100644
index 0000000000..53e3b52420
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-name-only.jsx
@@ -0,0 +1,7 @@
+export default function (data, { Template }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-target.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-target.jsx
new file mode 100644
index 0000000000..1f35c9e439
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-target.jsx
@@ -0,0 +1,8 @@
+export default function (data) {
+ return (
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx
new file mode 100644
index 0000000000..446f868793
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/disambig-with-template-name.jsx
@@ -0,0 +1,7 @@
+export default function (data, { Template }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/include-html.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/include-html.jsx
new file mode 100644
index 0000000000..d613cb472b
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/include-html.jsx
@@ -0,0 +1,7 @@
+export default function (data, { Template }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/include-target.html b/packages/apostrophe/test/modules/jsx-bridge-test/views/include-target.html
new file mode 100644
index 0000000000..3ab7c0dfa9
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/include-target.html
@@ -0,0 +1,4 @@
+
+ {% block content %}default-block{% endblock %}
+ {{ data.content }}
+
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx
new file mode 100644
index 0000000000..6a8b61aa27
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-extends-via-extend.jsx
@@ -0,0 +1,9 @@
+export default function (data, { Extend }) {
+ return (
+
+
+ {data.message}
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-extends.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-extends.jsx
new file mode 100644
index 0000000000..8c642524d3
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-extends.jsx
@@ -0,0 +1,9 @@
+export default function (data, { Template }) {
+ return (
+
+
+ {data.message}
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-layout.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-layout.jsx
new file mode 100644
index 0000000000..3e6c04ed89
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/jsx-layout.jsx
@@ -0,0 +1,14 @@
+export default function (data) {
+ return (
+
+
+ {data.title || 'default title'}
+
+
+
+ {data.children}
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/njk-extends.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/njk-extends.jsx
new file mode 100644
index 0000000000..88f504ca76
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/njk-extends.jsx
@@ -0,0 +1,14 @@
+export default function (data, { Extend }) {
+ return (
+
+ I am from JSX
+ {data.message}
+
+ }
+ />
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/njk-layout.html b/packages/apostrophe/test/modules/jsx-bridge-test/views/njk-layout.html
new file mode 100644
index 0000000000..f509141d36
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/njk-layout.html
@@ -0,0 +1,9 @@
+
+
+
+ {% block title %}default title{% endblock %}
+
+
+ {% block main %}default body{% endblock %}
+
+
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/short-form.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/short-form.jsx
new file mode 100644
index 0000000000..c8d5823009
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/short-form.jsx
@@ -0,0 +1,7 @@
+export default function (data, { Template }) {
+ return (
+
+
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-bridge-test/views/short-target.jsx b/packages/apostrophe/test/modules/jsx-bridge-test/views/short-target.jsx
new file mode 100644
index 0000000000..384131e230
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-bridge-test/views/short-target.jsx
@@ -0,0 +1,3 @@
+export default function (data) {
+ return short:{data.message}
;
+}
diff --git a/packages/apostrophe/test/modules/jsx-component-test/index.js b/packages/apostrophe/test/modules/jsx-component-test/index.js
new file mode 100644
index 0000000000..56f0a6954a
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-component-test/index.js
@@ -0,0 +1,15 @@
+const Promise = require('bluebird');
+
+module.exports = {
+ components(self) {
+ return {
+ async greet(req, input) {
+ await Promise.delay(20);
+ return {
+ who: input.who,
+ afterDelay: true
+ };
+ }
+ };
+ }
+};
diff --git a/packages/apostrophe/test/modules/jsx-component-test/views/greet.html b/packages/apostrophe/test/modules/jsx-component-test/views/greet.html
new file mode 100644
index 0000000000..4c474f3a02
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-component-test/views/greet.html
@@ -0,0 +1 @@
+Hello {{ data.who }}{% if data.afterDelay %} (after delay){% endif %}
diff --git a/packages/apostrophe/test/modules/jsx-component-test/views/uses-component.jsx b/packages/apostrophe/test/modules/jsx-component-test/views/uses-component.jsx
new file mode 100644
index 0000000000..fa69353369
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-component-test/views/uses-component.jsx
@@ -0,0 +1,8 @@
+export default function (data, { Component }) {
+ return (
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-ctx-widget/index.js b/packages/apostrophe/test/modules/jsx-ctx-widget/index.js
new file mode 100644
index 0000000000..01514397ea
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-ctx-widget/index.js
@@ -0,0 +1,6 @@
+module.exports = {
+ extend: '@apostrophecms/widget-type',
+ options: {
+ label: 'JSX Ctx Widget'
+ }
+};
diff --git a/packages/apostrophe/test/modules/jsx-ctx-widget/views/widget.jsx b/packages/apostrophe/test/modules/jsx-ctx-widget/views/widget.jsx
new file mode 100644
index 0000000000..96f722dc86
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-ctx-widget/views/widget.jsx
@@ -0,0 +1,4 @@
+export default function (data) {
+ const tag = (data.contextOptions && data.contextOptions.tag) || 'no-tag';
+ return {tag};
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/index.js b/packages/apostrophe/test/modules/jsx-mixed-test/index.js
new file mode 100644
index 0000000000..fe1a8f98db
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/index.js
@@ -0,0 +1,9 @@
+module.exports = {
+ init(self) {
+ self.addHelpers({
+ safeBold(text) {
+ return self.apos.template.safe(`${self.apos.util.escapeHtml(text)}`);
+ }
+ });
+ }
+};
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/apos-full.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/apos-full.jsx
new file mode 100644
index 0000000000..1673075f72
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/apos-full.jsx
@@ -0,0 +1,21 @@
+export default async function (data, { apos, helpers }) {
+ const id = apos.util.generateId();
+ // apos.doc.find returns a real cursor we can await. apos.doc here is the
+ // doc-module instance from self.apos, not a helper bag.
+ const docs = await apos.doc.find(data.req, { type: '@apostrophecms/global' }).toArray();
+ // The doc module instance has methods (e.g. find) that the helper bag
+ // does not — and the two `modules` collections are different objects.
+ const aposDocIsModule = typeof apos.doc.find === 'function';
+ const helpersDocIsHelperBag = (apos.doc !== helpers.modules['@apostrophecms/doc']);
+ const aposModulesIsNotHelpersModules = (apos.modules !== helpers.modules);
+ return (
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/async-list.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/async-list.jsx
new file mode 100644
index 0000000000..e465170c8b
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/async-list.jsx
@@ -0,0 +1,12 @@
+async function bullet(value) {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ return {value};
+}
+
+export default async function (data) {
+ return (
+
+ {data.items.map((item) => bullet(item))}
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/lib/format.js b/packages/apostrophe/test/modules/jsx-mixed-test/views/lib/format.js
new file mode 100644
index 0000000000..1d9221e5cb
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/lib/format.js
@@ -0,0 +1,3 @@
+module.exports = function format(value) {
+ return `[${value}]`;
+};
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/localized.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/localized.jsx
new file mode 100644
index 0000000000..79cd34235a
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/localized.jsx
@@ -0,0 +1,3 @@
+export default function (data, { __t }) {
+ return {__t('apostrophe:notFoundPageTitle')}
;
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/partial.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/partial.jsx
new file mode 100644
index 0000000000..caf556703a
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/partial.jsx
@@ -0,0 +1,3 @@
+export default function (data) {
+ return partial:{data.item}
;
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/safe-helper.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/safe-helper.jsx
new file mode 100644
index 0000000000..d4c6c9d55b
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/safe-helper.jsx
@@ -0,0 +1,3 @@
+export default function (data, { helpers }) {
+ return {helpers.modules['jsx-mixed-test'].safeBold('hello & ')}
;
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/syntax-error.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/syntax-error.jsx
new file mode 100644
index 0000000000..a53f7e7e06
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/syntax-error.jsx
@@ -0,0 +1,3 @@
+export default function () {
+ return missing closing tag<;
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/throws.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/throws.jsx
new file mode 100644
index 0000000000..c0340fa0ef
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/throws.jsx
@@ -0,0 +1,5 @@
+export default function () {
+ // intentionally accesses a property of undefined to trigger an error
+ const data = undefined;
+ return
{data.boom}
;
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/uses-import.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/uses-import.jsx
new file mode 100644
index 0000000000..3f89e3a336
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/uses-import.jsx
@@ -0,0 +1,5 @@
+import format from './lib/format.js';
+
+export default function (data) {
+ return
{format(data.value)};
+}
diff --git a/packages/apostrophe/test/modules/jsx-mixed-test/views/uses-require.jsx b/packages/apostrophe/test/modules/jsx-mixed-test/views/uses-require.jsx
new file mode 100644
index 0000000000..d7f9b66710
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-mixed-test/views/uses-require.jsx
@@ -0,0 +1,5 @@
+const format = require('./lib/format.js');
+
+export default function (data) {
+ return
{format(data.value)};
+}
diff --git a/packages/apostrophe/test/modules/jsx-watcher-cross-test/index.js b/packages/apostrophe/test/modules/jsx-watcher-cross-test/index.js
new file mode 100644
index 0000000000..2292fa9997
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-watcher-cross-test/index.js
@@ -0,0 +1,5 @@
+module.exports = {
+ options: {
+ ignoreNoCodeWarning: true
+ }
+};
diff --git a/packages/apostrophe/test/modules/jsx-watcher-cross-test/views/cross-template.jsx b/packages/apostrophe/test/modules/jsx-watcher-cross-test/views/cross-template.jsx
new file mode 100644
index 0000000000..477abbac39
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-watcher-cross-test/views/cross-template.jsx
@@ -0,0 +1,3 @@
+export default function () {
+ return
cross-original
;
+}
diff --git a/packages/apostrophe/test/modules/jsx-watcher-test/index.js b/packages/apostrophe/test/modules/jsx-watcher-test/index.js
new file mode 100644
index 0000000000..2292fa9997
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-watcher-test/index.js
@@ -0,0 +1,5 @@
+module.exports = {
+ options: {
+ ignoreNoCodeWarning: true
+ }
+};
diff --git a/packages/apostrophe/test/modules/jsx-watcher-test/views/watcher-test.jsx b/packages/apostrophe/test/modules/jsx-watcher-test/views/watcher-test.jsx
new file mode 100644
index 0000000000..c65b67c3ac
--- /dev/null
+++ b/packages/apostrophe/test/modules/jsx-watcher-test/views/watcher-test.jsx
@@ -0,0 +1,3 @@
+export default function () {
+ return
own-original
;
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-options-test/index.js b/packages/apostrophe/test/modules/template-jsx-options-test/index.js
new file mode 100644
index 0000000000..125f464c5d
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-options-test/index.js
@@ -0,0 +1,12 @@
+module.exports = {
+ options: {
+ spiffiness: 'nifty'
+ },
+ init(self) {
+ self.addHelpers({
+ test(a) {
+ return a * 2;
+ }
+ });
+ }
+};
diff --git a/packages/apostrophe/test/modules/template-jsx-options-test/views/options-test.jsx b/packages/apostrophe/test/modules/template-jsx-options-test/views/options-test.jsx
new file mode 100644
index 0000000000..1c16191123
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-options-test/views/options-test.jsx
@@ -0,0 +1,9 @@
+export default function (data, { helpers }) {
+ return (
+ <>
+ {helpers.modules['template-jsx-options-test'].options.spiffiness}
+ {' '}
+ {helpers.modules['template-jsx-options-test'].test(2)}
+ >
+ );
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-subclass-test/index.js b/packages/apostrophe/test/modules/template-jsx-subclass-test/index.js
new file mode 100644
index 0000000000..f1b210e9b5
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-subclass-test/index.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extend: 'template-jsx-test'
+};
diff --git a/packages/apostrophe/test/modules/template-jsx-subclass-test/views/override-test.jsx b/packages/apostrophe/test/modules/template-jsx-subclass-test/views/override-test.jsx
new file mode 100644
index 0000000000..d18fd57240
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-subclass-test/views/override-test.jsx
@@ -0,0 +1,3 @@
+export default function () {
+ return
I am overridden
;
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/index.js b/packages/apostrophe/test/modules/template-jsx-test/index.js
new file mode 100644
index 0000000000..142b6a6cef
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/index.js
@@ -0,0 +1,9 @@
+module.exports = {
+ options: {
+ templateData: {
+ age: 30,
+ multiline: 'first line\nsecond line\n
CSRF attempt',
+ multilineSafe: 'first line\nsecond line\n
This is okay'
+ }
+ }
+};
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/boolean-attrs.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/boolean-attrs.jsx
new file mode 100644
index 0000000000..3cc760bcc2
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/boolean-attrs.jsx
@@ -0,0 +1,11 @@
+export default function () {
+ return (
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/class-and-for.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/class-and-for.jsx
new file mode 100644
index 0000000000..a1000bd8fa
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/class-and-for.jsx
@@ -0,0 +1,7 @@
+export default function () {
+ return (
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/dangerously-set.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/dangerously-set.jsx
new file mode 100644
index 0000000000..59149483a9
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/dangerously-set.jsx
@@ -0,0 +1,3 @@
+export default function (data) {
+ return
;
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/escape-attr.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/escape-attr.jsx
new file mode 100644
index 0000000000..e8f0229b4f
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/escape-attr.jsx
@@ -0,0 +1,3 @@
+export default function (data) {
+ return
link;
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/escape-body.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/escape-body.jsx
new file mode 100644
index 0000000000..14ae199807
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/escape-body.jsx
@@ -0,0 +1,3 @@
+export default function (data) {
+ return
{data.html}
;
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/inherit-test.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/inherit-test.jsx
new file mode 100644
index 0000000000..90ec61c4b9
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/inherit-test.jsx
@@ -0,0 +1,3 @@
+export default function () {
+ return
I am inherited
;
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/list.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/list.jsx
new file mode 100644
index 0000000000..873439439c
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/list.jsx
@@ -0,0 +1,7 @@
+export default function (data) {
+ return (
+
+ {data.items.map((item) => - {item}
)}
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/override-test.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/override-test.jsx
new file mode 100644
index 0000000000..42dd8e3e1d
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/override-test.jsx
@@ -0,0 +1,3 @@
+export default function () {
+ return
I am a bug
;
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/svg-attrs.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/svg-attrs.jsx
new file mode 100644
index 0000000000..fbf373f0c8
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/svg-attrs.jsx
@@ -0,0 +1,27 @@
+// Verifies that camelCase SVG presentation attributes are emitted in
+// the kebab-case form expected by browsers parsing text/html, and that
+// natively camelCase SVG attributes (viewBox, preserveAspectRatio) are
+// preserved.
+
+export default function () {
+ return (
+
+ );
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/test.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/test.jsx
new file mode 100644
index 0000000000..d814ee317f
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/test.jsx
@@ -0,0 +1,3 @@
+export default function (data) {
+ return
{data.age}
;
+}
diff --git a/packages/apostrophe/test/modules/template-jsx-test/views/void-elements.jsx b/packages/apostrophe/test/modules/template-jsx-test/views/void-elements.jsx
new file mode 100644
index 0000000000..89b9f4bc76
--- /dev/null
+++ b/packages/apostrophe/test/modules/template-jsx-test/views/void-elements.jsx
@@ -0,0 +1,9 @@
+export default function () {
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/packages/apostrophe/test/templates-jsx-watcher.js b/packages/apostrophe/test/templates-jsx-watcher.js
new file mode 100644
index 0000000000..9acda7957c
--- /dev/null
+++ b/packages/apostrophe/test/templates-jsx-watcher.js
@@ -0,0 +1,135 @@
+const t = require('../test-lib/test.js');
+const assert = require('assert/strict');
+const fs = require('fs');
+const path = require('path');
+const isWsl = require('is-wsl');
+
+describe('Templates: JSX watcher', function () {
+
+ let apos;
+ this.timeout(t.timeout);
+
+ // Two fixture modules. `jsx-watcher-test` is the simple case: render its
+ // own template directly. `jsx-watcher-cross-test` is the realistic-page
+ // case: another module renders this one's template via the qualified
+ // `module:template` form (mirroring how `@apostrophecms/page` renders
+ // `@apostrophecms/home-page:page` in the wild). The first surfaces a
+ // bug where renderBody never arms a watcher for JSX renders; the second
+ // surfaces a bug where the watcher was armed against the caller's views
+ // instead of the resolved module's views.
+ const ownModule = 'jsx-watcher-test';
+ const ownTemplate = 'watcher-test';
+ const ownPath = path.join(
+ __dirname, 'modules', ownModule, 'views', `${ownTemplate}.jsx`
+ );
+ const ownOriginal = 'export default function () {\n return
own-original
;\n}\n';
+ const ownChanged = 'export default function () {\n return
own-changed
;\n}\n';
+
+ const crossModule = 'jsx-watcher-cross-test';
+ const crossTemplate = 'cross-template';
+ const crossPath = path.join(
+ __dirname, 'modules', crossModule, 'views', `${crossTemplate}.jsx`
+ );
+ const crossOriginal = 'export default function () {\n return
cross-original
;\n}\n';
+ const crossChanged = 'export default function () {\n return
cross-changed
;\n}\n';
+
+ before(async function () {
+ // Defend against poisoned fixtures from a prior failed run.
+ fs.writeFileSync(ownPath, ownOriginal);
+ fs.writeFileSync(crossPath, crossOriginal);
+ apos = await t.create({
+ root: module,
+ modules: {
+ [ownModule]: {},
+ [crossModule]: {}
+ }
+ });
+ });
+
+ after(async function () {
+ try {
+ fs.writeFileSync(ownPath, ownOriginal);
+ } catch (e) {
+ // best-effort restore
+ }
+ try {
+ fs.writeFileSync(crossPath, crossOriginal);
+ } catch (e) {
+ // best-effort restore
+ }
+ return t.destroy(apos);
+ });
+
+ // Wait for chokidar's initial scan to complete on every watcher the
+ // template module has registered. If the JSX render path failed to arm
+ // any watcher for our views directory, no `change` event would ever
+ // fire and the caller's `waitForChange` would time out.
+ async function waitForWatchersReady() {
+ await Promise.all(
+ (apos.template._viewWatchers || []).map(watcher => {
+ if (watcher._readyEmitted) {
+ return Promise.resolve();
+ }
+ return new Promise(resolve => watcher.on('ready', resolve));
+ })
+ );
+ }
+
+ function waitForChange(absolutePath) {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error(`No view-change event was observed for ${absolutePath}.`)),
+ 5000
+ );
+ apos.template.onViewChange(filePath => {
+ if (filePath && path.resolve(filePath) === path.resolve(absolutePath)) {
+ clearTimeout(timer);
+ resolve();
+ }
+ });
+ });
+ }
+
+ it('should pick up edits to a .jsx template rendered through its own module', async function () {
+ if (isWsl) {
+ this.skip();
+ return;
+ }
+ const req = apos.task.getAnonReq();
+ const initial = await apos.modules[ownModule].render(req, ownTemplate);
+ assert.match(initial, /own-original/);
+
+ await waitForWatchersReady();
+ const sawChange = waitForChange(ownPath);
+ fs.writeFileSync(ownPath, ownChanged);
+ await sawChange;
+
+ const after = await apos.modules[ownModule].render(req, ownTemplate);
+ assert.match(after, /own-changed/);
+ assert.doesNotMatch(after, /own-original/);
+ });
+
+ it('should pick up edits when one module renders another module\'s .jsx via `module:template`', async function () {
+ if (isWsl) {
+ this.skip();
+ return;
+ }
+ const req = apos.task.getAnonReq();
+ // The caller is `ownModule`, but the template lives in `crossModule`.
+ // This is the shape that breaks if the watcher is armed against the
+ // caller's view chain instead of the resolved file's module.
+ const qualified = `${crossModule}:${crossTemplate}`;
+ const initial = await apos.modules[ownModule].render(req, qualified);
+ assert.match(initial, /cross-original/);
+
+ await waitForWatchersReady();
+ const sawChange = waitForChange(crossPath);
+ fs.writeFileSync(crossPath, crossChanged);
+ await sawChange;
+
+ const after = await apos.modules[ownModule].render(req, qualified);
+ assert.match(after, /cross-changed/);
+ assert.doesNotMatch(after, /cross-original/);
+ });
+
+});
diff --git a/packages/apostrophe/test/templates-jsx.js b/packages/apostrophe/test/templates-jsx.js
new file mode 100644
index 0000000000..d128e1eb98
--- /dev/null
+++ b/packages/apostrophe/test/templates-jsx.js
@@ -0,0 +1,537 @@
+const t = require('../test-lib/test.js');
+const assert = require('assert/strict');
+
+describe('Templates: JSX', function() {
+
+ let apos;
+
+ this.timeout(t.timeout);
+
+ after(async function() {
+ return t.destroy(apos);
+ });
+
+ before(async function() {
+ apos = await t.create({
+ root: module,
+ modules: {
+ 'express-test': {},
+ 'template-jsx-test': {
+ options: {
+ ignoreNoCodeWarning: true
+ }
+ },
+ 'template-jsx-subclass-test': {
+ options: {
+ ignoreNoCodeWarning: true
+ }
+ },
+ 'template-jsx-options-test': {},
+ 'jsx-component-test': {},
+ 'jsx-bridge-test': {
+ options: {
+ ignoreNoCodeWarning: true
+ }
+ },
+ 'jsx-mixed-test': {},
+ 'jsx-area-test': {},
+ 'jsx-async-widget': {},
+ 'jsx-ctx-widget': {}
+ }
+ });
+ });
+
+ // ---- Equivalents of the existing Nunjucks template tests ----
+
+ it('should render a JSX template relative to a module', async function() {
+ const req = apos.task.getAnonReq();
+ const result = await apos.modules['template-jsx-test'].render(req, 'test', { age: 50 });
+ assert.equal(result.trim(), '
50
');
+ });
+
+ it('should respect templateData at module level for JSX templates', async function() {
+ const req = apos.task.getAnonReq();
+ const result = await apos.modules['template-jsx-test'].render(req, 'test');
+ assert.equal(result.trim(), '
30
');
+ });
+
+ it('should respect JSX template overrides in subclasses', async function() {
+ const req = apos.task.getAnonReq();
+ const result = await apos.modules['template-jsx-subclass-test'].render(req, 'override-test');
+ assert.equal(result.trim(), '
I am overridden
');
+ });
+
+ it('should inherit JSX templates in the absence of overrides', async function() {
+ const req = apos.task.getAnonReq();
+ const result = await apos.modules['template-jsx-subclass-test'].render(req, 'inherit-test');
+ assert.equal(result.trim(), '
I am inherited
');
+ });
+
+ it('should expose module options and helpers to JSX via the helpers second argument', async function() {
+ const req = apos.task.getAnonReq();
+ const result = await apos.modules['template-jsx-options-test'].render(req, 'options-test');
+ assert.match(result, /nifty/);
+ assert.match(result, /\b4\b/);
+ });
+
+ // ---- Auto-escape and dangerouslySetInnerHTML ----
+
+ it('should auto-escape strings in element bodies', async function() {
+ const req = apos.task.getAnonReq();
+ const result = await apos.modules['template-jsx-test'].render(req, 'escape-body', {
+ html: ''
+ });
+ assert.match(result, /<script>alert\("xss"\)<\/script>/);
+ assert.doesNotMatch(result, /