Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions claude-tools/check-demo-jsx.mjs
Original file line number Diff line number Diff line change
@@ -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.`);
52 changes: 52 additions & 0 deletions packages/apostrophe/modules/@apostrophecms/template/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -117,16 +143,24 @@ 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();
}
}
};
},
methods(self) {
return {
...require('./lib/bundlesLoader')(self),
...require('./lib/jsxRender')(self),
...require('./lib/viewWatcher')(self),

// Add helpers in the namespace for a particular module.
// They will be visible in nunjucks at
Expand Down Expand Up @@ -261,6 +295,21 @@ module.exports = {

let result;

// For named files, prefer a .jsx implementation when one exists
// anywhere in the module's view-folder chain. Falling back to
// Nunjucks happens automatically below when no JSX file is found.
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', '');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's odd. Shouldn't this replace be \r\n -> \n? Windows to Linux new line normalization?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

\r never makes sense by itself in a source file, so it's reasonable to just dump them, knowing the \n will still be there.

}
return result;
}
}

const args = self.getRenderArgs(req, data, module);

const env = self.getEnv(req, module);
Expand Down Expand Up @@ -495,6 +544,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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see anything preventing watchers in production. I might be missing something, just pointing it out.

}
return self.loaders[key];
},
Expand Down
128 changes: 128 additions & 0 deletions packages/apostrophe/modules/@apostrophecms/template/lib/jsxLoader.js
Original file line number Diff line number Diff line change
@@ -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
};
Loading
Loading