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(``) + ]; + } + + if (voidElements[tag] && children.length === 0) { + return [ new Raw(`<${tag}${attrs} />`) ]; + } + + return [ + new Raw(`<${tag}${attrs}>`), + ...children, + new Raw(``) + ]; +} + +// 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 ( +
+