Skip to content

Commit c943313

Browse files
PRO-6295: jsx as an optional alternative to nunjucks (#5391)
* jsx as an optional alternative to nunjucks * eslint, all tests pass * log a useful stack trace on attachment errors! Holy shit! * fix lint * clarify behavior * more tests pass linter * This is just a unit test, but it can't hurt to be thorough & satisfy github-advanced-security Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * true access to the apos object in jsx, per the spec * more test coverage, no code changes * fix watchers --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent f67c272 commit c943313

77 files changed

Lines changed: 2269 additions & 52 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎claude-tools/check-demo-jsx.mjs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Quick syntax check for every .jsx template under public-demo. Loads
2+
// each via the same JSX loader used in production and reports compile
3+
// errors with proper file/line info.
4+
import path from 'node:path';
5+
import { fileURLToPath } from 'node:url';
6+
import { createRequire } from 'node:module';
7+
import fs from 'node:fs';
8+
9+
const here = path.dirname(fileURLToPath(import.meta.url));
10+
const apostropheRoot = path.resolve(here, '..');
11+
const require = createRequire(path.join(apostropheRoot, 'packages/apostrophe/index.js'));
12+
13+
const { install } = require(path.join(apostropheRoot, 'packages/apostrophe/modules/@apostrophecms/template/lib/jsxLoader.js'));
14+
install();
15+
16+
const demoRoot = '/srv/workspace/apostrophecms/public-demo';
17+
18+
function* walk(dir) {
19+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
20+
if (entry.name === 'node_modules' || entry.name === 'data') continue;
21+
const full = path.join(dir, entry.name);
22+
if (entry.isDirectory()) {
23+
yield* walk(full);
24+
} else if (entry.name.endsWith('.jsx')) {
25+
yield full;
26+
}
27+
}
28+
}
29+
30+
let failed = 0;
31+
for (const file of walk(demoRoot)) {
32+
try {
33+
require(file);
34+
console.log('OK ', file);
35+
} catch (err) {
36+
failed += 1;
37+
console.error('FAIL', file);
38+
console.error(' ', err.message);
39+
if (err.stack) {
40+
console.error(err.stack.split('\n').slice(1, 5).join('\n'));
41+
}
42+
}
43+
}
44+
45+
if (failed > 0) {
46+
console.error(`\n${failed} file(s) failed to compile/load.`);
47+
process.exit(1);
48+
}
49+
console.log(`\nAll JSX templates loaded successfully.`);

‎packages/apostrophe/eslint.config.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ module.exports = defineConfig([
77
'**/blueimp/**/*.js',
88
'test/public',
99
'test/apos-build',
10+
'test/modules/jsx-mixed-test/views/syntax-error.jsx',
1011
'coverage',
1112
'claude-tools'
1213
]),

‎packages/apostrophe/modules/@apostrophecms/attachment/index.js‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,10 @@ module.exports = {
656656
// to avoid template errors
657657
getMissingAttachmentUrl() {
658658
const defaultIconUrl = '/modules/@apostrophecms/attachment/img/missing-icon.svg';
659-
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!');
659+
const e = new Error();
660+
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' +
661+
e.stack
662+
);
660663
// Convert static asset path to full URL, which matters when static
661664
// assets are in uploadfs
662665
return self.apos.asset.url(defaultIconUrl);

‎packages/apostrophe/modules/@apostrophecms/oembed/index.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ module.exports = {
5454

5555
self.oembetter.allowlist(minimumAllowlist.concat(self.options.allowlist || []));
5656

57-
const minimumEndpoints = self.options.minimumEndpoints || self.oembetter.suggestedEndpoints;
57+
const minimumEndpoints = self.options.minimumEndpoints ||
58+
self.oembetter.suggestedEndpoints;
5859
self.oembetter.endpoints(
5960
minimumEndpoints.concat(self.options.endpoints || [])
6061
);

‎packages/apostrophe/modules/@apostrophecms/template/index.js‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,32 @@ module.exports = {
7676
self.insertions = {};
7777
self.runtimeNodes = {};
7878

79+
// Install the .jsx require hook and teach the JSX runtime about
80+
// Nunjucks' SafeString class so its instances pass through unescaped.
81+
self.initJsx();
82+
83+
// Wire up the view-folder watcher with the two default invalidation
84+
// handlers — Nunjucks loader caches and compiled .jsx modules. Both
85+
// engines share a single set of chokidar watchers so we don't pay
86+
// twice for watching the same directories.
87+
const jsxLoader = require('./lib/jsxLoader.js');
88+
self.onViewChange(function clearNunjucksLoaderCaches() {
89+
// Setting `cache = {}` mirrors the historical in-loader behavior
90+
// and is exactly what Nunjucks itself reads when looking up a
91+
// previously-loaded template.
92+
for (const loader of Object.values(self.loaders || {})) {
93+
loader.cache = {};
94+
}
95+
});
96+
self.onViewChange(function invalidateJsxModules(filePath) {
97+
if (filePath && filePath.endsWith('.jsx')) {
98+
jsxLoader.invalidate(path.resolve(filePath));
99+
} else {
100+
// Anything else (e.g. a Nunjucks file) might be a template imported
101+
// by a `.jsx` file via require()/import — be safe and drop them all.
102+
jsxLoader.invalidateAll();
103+
}
104+
});
79105
},
80106
handlers(self) {
81107
return {
@@ -117,16 +143,35 @@ module.exports = {
117143
},
118144
'apostrophe:destroy': {
119145
async nunjucksLoaderCleanup() {
146+
// Older code paths used to manage chokidar watchers per loader;
147+
// a no-op `destroy()` is still defined for backwards compat.
120148
for (const loader of Object.values(self.loaders || {})) {
121149
await loader.destroy();
122150
}
151+
},
152+
async closeViewWatchers() {
153+
// Tear down chokidar watchers (Nunjucks + JSX share these).
154+
await self.closeViewWatchers();
123155
}
124156
}
125157
};
126158
},
127159
methods(self) {
128160
return {
129161
...require('./lib/bundlesLoader')(self),
162+
...require('./lib/jsxRender')(self),
163+
...require('./lib/viewWatcher')(self),
164+
165+
// Arm chokidar for the view-folder chain of the module whose views
166+
// actually contain the resolved JSX file. For a same-module render
167+
// that's the caller; for a cross-module render like
168+
// `@apostrophecms/page` rendering `@apostrophecms/home-page:page`
169+
// it's the target module. Idempotent per absolute directory.
170+
watchJsxRenderTargets(callerModule, resolved) {
171+
const owner = (resolved && self.apos.modules[resolved.moduleName]) ||
172+
callerModule;
173+
self.watchViewFolders(self.getViewFolders(owner));
174+
},
130175

131176
// Add helpers in the namespace for a particular module.
132177
// They will be visible in nunjucks at
@@ -261,6 +306,24 @@ module.exports = {
261306

262307
let result;
263308

309+
// For named files, resolve through the module's view-folder
310+
// chain. Chain position wins: a closer directory's .html/.njk
311+
// beats a more distant directory's .jsx. JSX only takes
312+
// precedence over Nunjucks within the same directory. See
313+
// resolveTemplate. Falling back to Nunjucks happens automatically
314+
// below when the resolved file is not JSX.
315+
if (type === 'file') {
316+
const resolved = self.resolveTemplate(module, s);
317+
if (resolved && resolved.kind === 'jsx') {
318+
const renderData = self.getRenderDataArgs(req, data, module);
319+
result = await self.renderJsxTemplate(req, resolved, renderData, module);
320+
if (process.platform === 'win32') {
321+
result = result.replaceAll('\r', '');
322+
}
323+
return result;
324+
}
325+
}
326+
264327
const args = self.getRenderArgs(req, data, module);
265328

266329
const env = self.getEnv(req, module);
@@ -495,6 +558,9 @@ module.exports = {
495558
}
496559
if (!self.loaders[key]) {
497560
self.loaders[key] = self.newLoader(moduleName, dirs);
561+
// Register these dirs with the shared view watcher (idempotent
562+
// per absolute path, so calling it for every loader is fine).
563+
self.watchViewFolders(dirs);
498564
}
499565
return self.loaders[key];
500566
},
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Compiles Apostrophe `.jsx` template files via Babel and registers a
2+
// `require.extensions['.jsx']` hook so they can be loaded with `require()`
3+
// (and `import` after CommonJS transformation) just like normal modules.
4+
//
5+
// Each compiled module is automatically prefixed with a `require()` of our
6+
// JSX runtime so the `h` and `Fragment` identifiers produced by the Babel
7+
// transform resolve without the user importing them. Both `import` and
8+
// `require` work inside `.jsx` files because the CommonJS transform also
9+
// runs.
10+
//
11+
// Source maps are kept in memory and wired through `source-map-support`,
12+
// which means stack traces from a JSX template point at the original
13+
// `views/page.jsx` line/column rather than the compiled output.
14+
15+
const fs = require('fs');
16+
const Module = require('module');
17+
const babel = require('@babel/core');
18+
const sourceMapSupport = require('source-map-support');
19+
20+
const runtimePath = require.resolve('./jsxRuntime.js');
21+
22+
const sourceMaps = new Map();
23+
let installed = false;
24+
25+
// Idempotent: register the require hook + source-map handler once per
26+
// process even if multiple Apostrophe instances boot in the same Node
27+
// process (e.g. tests, multisite).
28+
function install() {
29+
if (installed) {
30+
return;
31+
}
32+
installed = true;
33+
34+
sourceMapSupport.install({
35+
environment: 'node',
36+
hookRequire: false,
37+
handleUncaughtExceptions: false,
38+
retrieveSourceMap(filename) {
39+
const map = sourceMaps.get(filename);
40+
if (!map) {
41+
return null;
42+
}
43+
return {
44+
url: filename,
45+
map
46+
};
47+
}
48+
});
49+
50+
Module._extensions['.jsx'] = function(module, filename) {
51+
const src = fs.readFileSync(filename, 'utf-8');
52+
const compiled = compile(src, filename);
53+
sourceMaps.set(filename, compiled.map);
54+
module._compile(compiled.code, filename);
55+
};
56+
}
57+
58+
// Compile a JSX source string for `filename`. Returns `{ code, map }`.
59+
// `code` is CommonJS-compatible JS with our runtime injected at the top.
60+
function compile(src, filename) {
61+
let result;
62+
try {
63+
result = babel.transformSync(src, {
64+
filename,
65+
sourceMaps: true,
66+
sourceFileName: filename,
67+
babelrc: false,
68+
configFile: false,
69+
compact: false,
70+
plugins: [
71+
[
72+
require.resolve('@babel/plugin-transform-react-jsx'),
73+
{
74+
pragma: '__aposJsx.h',
75+
pragmaFrag: '__aposJsx.Fragment',
76+
useBuiltIns: false,
77+
throwIfNamespace: false
78+
}
79+
],
80+
require.resolve('@babel/plugin-transform-modules-commonjs')
81+
]
82+
});
83+
} catch (e) {
84+
// Babel errors already include code frames pointing at the offending
85+
// line/column. Preserve that detail and add the file path for clarity.
86+
const err = new Error(`JSX compile error in ${filename}: ${e.message}`);
87+
err.cause = e;
88+
err.code = 'APOS_JSX_COMPILE_ERROR';
89+
err.filename = filename;
90+
throw err;
91+
}
92+
93+
// Inject runtime references. Using a single `__aposJsx` namespace avoids
94+
// colliding with user variables named `h` or `Fragment` while still
95+
// matching the pragma we passed to Babel above. Source maps remain valid
96+
// because we only prepend a single line and rely on a leading `\n` to
97+
// keep line numbers stable.
98+
const prefix = `var __aposJsx = require(${JSON.stringify(runtimePath)});\n`;
99+
return {
100+
code: prefix + result.code,
101+
map: result.map
102+
};
103+
}
104+
105+
// Drop a single .jsx file from the require cache and our source-map cache.
106+
// Called by the template module's chokidar watcher when a JSX file changes,
107+
// so the next render picks up the new code without restarting the process.
108+
function invalidate(filename) {
109+
sourceMaps.delete(filename);
110+
delete Module._cache[filename];
111+
}
112+
113+
// Drop every cached .jsx module and source map. Used when watcher events
114+
// don't carry a specific path or when an unknown view file was modified.
115+
function invalidateAll() {
116+
for (const filename of sourceMaps.keys()) {
117+
delete Module._cache[filename];
118+
}
119+
sourceMaps.clear();
120+
}
121+
122+
module.exports = {
123+
install,
124+
compile,
125+
invalidate,
126+
invalidateAll,
127+
runtimePath
128+
};

0 commit comments

Comments
 (0)