Add intellisense functionality for document and email templates
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good

This commit introduces new route handlers for intellisense in both document and email templates, allowing for cursor-relative suggestions based on template content. The `intellisenseDocumentTemplateRouteHandler` and `intellisenseEmailTemplateRouteHandler` functions are implemented to collect and return relevant intellisense data. Additionally, tests are added to ensure the correct behavior of the intellisense feature, enhancing the overall template management capabilities.
This commit is contained in:
Tom Butcher 2026-09-12 23:29:43 +01:00
parent 1d473cc755
commit c10281064e
9 changed files with 1792 additions and 12 deletions

View File

@ -47,6 +47,7 @@ import {
previewDocumentTemplateRouteHandler, previewDocumentTemplateRouteHandler,
downloadDocumentTemplateRouteHandler, downloadDocumentTemplateRouteHandler,
formatDocumentTemplateRouteHandler, formatDocumentTemplateRouteHandler,
intellisenseDocumentTemplateRouteHandler,
} from '../../services/management/documenttemplates.js'; } from '../../services/management/documenttemplates.js';
// list of document templates // list of document templates
@ -114,6 +115,10 @@ router.get('/neighbors', isAuthenticated, async (req, res) => {
getDocumentTemplateNeighborsRouteHandler(req, res, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder, id); getDocumentTemplateNeighborsRouteHandler(req, res, property, filter, search, getSort(sortProperty, listAllowedSorters), sortOrder, id);
}); });
router.post('/:id/intellisense', isAuthenticated, checkPermissions('documentTemplate', 'design'), async (req, res) => {
intellisenseDocumentTemplateRouteHandler(req, res);
});
router.post('/:id/preview', isAuthenticated, checkPermissions('documentTemplate', 'design'), async (req, res) => { router.post('/:id/preview', isAuthenticated, checkPermissions('documentTemplate', 'design'), async (req, res) => {
previewDocumentTemplateRouteHandler(req, res); previewDocumentTemplateRouteHandler(req, res);
}); });

View File

@ -16,6 +16,7 @@ import {
newEmailTemplateRouteHandler, newEmailTemplateRouteHandler,
previewEmailTemplateRouteHandler, previewEmailTemplateRouteHandler,
searchEmailTemplatesRouteHandler, searchEmailTemplatesRouteHandler,
intellisenseEmailTemplateRouteHandler,
} from '../../services/management/emailtemplates.js'; } from '../../services/management/emailtemplates.js';
const router = express.Router(); const router = express.Router();
@ -91,6 +92,7 @@ router.get('/neighbors', isAuthenticated, async (req, res) => {
}); });
router.post('/', isAuthenticated, checkPermissions('emailTemplate', 'new'), newEmailTemplateRouteHandler); router.post('/', isAuthenticated, checkPermissions('emailTemplate', 'new'), newEmailTemplateRouteHandler);
router.post('/format', isAuthenticated, checkPermissions('emailTemplate', 'design'), formatEmailTemplateRouteHandler); router.post('/format', isAuthenticated, checkPermissions('emailTemplate', 'design'), formatEmailTemplateRouteHandler);
router.post('/:id/intellisense', isAuthenticated, checkPermissions('emailTemplate', 'design'), intellisenseEmailTemplateRouteHandler);
router.post('/:id/preview', isAuthenticated, checkPermissions('emailTemplate', 'design'), previewEmailTemplateRouteHandler); router.post('/:id/preview', isAuthenticated, checkPermissions('emailTemplate', 'design'), previewEmailTemplateRouteHandler);
router.get('/:id', isAuthenticated, getEmailTemplateRouteHandler); router.get('/:id', isAuthenticated, getEmailTemplateRouteHandler);
router.put('/:id', isAuthenticated, checkPermissions('emailTemplate', 'edit'), editEmailTemplateRouteHandler); router.put('/:id', isAuthenticated, checkPermissions('emailTemplate', 'edit'), editEmailTemplateRouteHandler);

View File

@ -22,6 +22,7 @@ jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({
templateManager: { templateManager: {
renderTemplate: jest.fn(), renderTemplate: jest.fn(),
renderDownload: jest.fn(), renderDownload: jest.fn(),
collectTemplateIntellisense: jest.fn(),
}, },
DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX: 'documenttemplaterenders:', DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX: 'documenttemplaterenders:',
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS: 15, DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS: 15,
@ -53,6 +54,7 @@ const {
editDocumentTemplateRouteHandler, editDocumentTemplateRouteHandler,
previewDocumentTemplateRouteHandler, previewDocumentTemplateRouteHandler,
downloadDocumentTemplateRouteHandler, downloadDocumentTemplateRouteHandler,
intellisenseDocumentTemplateRouteHandler,
} = await import('../documenttemplates.js'); } = await import('../documenttemplates.js');
const { listObjects, getObject, editObject, newObject } = await import( const { listObjects, getObject, editObject, newObject } = await import(
@ -109,6 +111,32 @@ describe('Document Template Service Route Handlers', () => {
}); });
}); });
describe('intellisenseDocumentTemplateRouteHandler', () => {
it('should collect cursor-relative intellisense', async () => {
req.params.id = 'template-1';
req.body = {
content: '<% const user = 1 %>',
testObject: { name: 'Test' },
cursor: { offset: 12 },
};
const mockIntellisense = {
intellisense: { $type: 'object', properties: { user: { $type: 'number', value: 1 } } },
};
templateManager.collectTemplateIntellisense.mockResolvedValue(mockIntellisense);
await intellisenseDocumentTemplateRouteHandler(req, res);
expect(templateManager.collectTemplateIntellisense).toHaveBeenCalledWith(
'template-1',
'<% const user = 1 %>',
{ name: 'Test' },
{ offset: 12 },
'documentTemplate'
);
expect(res.send).toHaveBeenCalledWith(mockIntellisense);
});
});
describe('previewDocumentTemplateRouteHandler', () => { describe('previewDocumentTemplateRouteHandler', () => {
it('should preview a document template', async () => { it('should preview a document template', async () => {
req.params.id = 'template-1'; req.params.id = 'template-1';

View File

@ -327,6 +327,30 @@ export const formatDocumentTemplateRouteHandler = async (req, res) => {
res.send({ content: result.content }); res.send({ content: result.content });
}; };
export const intellisenseDocumentTemplateRouteHandler = async (req, res) => {
const id = req.params.id;
const content = req.body?.content;
const testObject = req.body?.testObject || req.body?.object || {};
const cursor = req.body?.cursor || {};
logger.debug(`Collecting document template intellisense with ID: ${id}`);
const result = await templateManager.collectTemplateIntellisense(
id,
content,
testObject,
cursor,
'documentTemplate'
);
if (result?.error) {
logger.warn('Error collecting document template intellisense:', result.error);
return res.status(result.code || 400).send(result);
}
res.send(result);
};
export const previewDocumentTemplateRouteHandler = async (req, res) => { export const previewDocumentTemplateRouteHandler = async (req, res) => {
const id = req.params.id; const id = req.params.id;
const content = req.body?.content; const content = req.body?.content;

View File

@ -305,6 +305,21 @@ export const formatEmailTemplateRouteHandler = (req, res) => {
res.send(result); res.send(result);
}; };
export const intellisenseEmailTemplateRouteHandler = async (req, res) => {
const result = await templateManager.collectTemplateIntellisense(
req.params.id,
req.body?.content,
req.body?.testObject || req.body?.object || {},
req.body?.cursor || {},
'emailTemplate'
);
if (result?.error) {
logger.error('Error collecting email template intellisense:', result.error);
return res.status(result.code || 400).send(result);
}
res.send(result);
};
export const previewEmailTemplateRouteHandler = async (req, res) => { export const previewEmailTemplateRouteHandler = async (req, res) => {
const result = await templateManager.renderEmailTemplate( const result = await templateManager.renderEmailTemplate(
req.params.id, req.params.id,

View File

@ -0,0 +1,375 @@
import ejs from 'ejs';
import {
extractTemplateBindings,
appendCaptureScriptlet,
collectIntellisense,
createIntellisenseRender,
serializeIntellisenseValue,
projectTemplateToJs,
collectScopeAtOffset,
filterIntellisenseToCursor,
createCursorIntellisenseRender,
FC_CAPTURE_NAME,
} from '../templateintellisense.js';
describe('extractTemplateBindings', () => {
it('extracts const bindings from scriptlets', () => {
const content = `<% const user = await fc.getObject('user', id) %><Text><%= user.username %></Text>`;
expect(extractTemplateBindings(content).declared).toEqual(['user']);
});
it('skips output and comment tags', () => {
expect(extractTemplateBindings('<%= name %>').declared).toEqual([]);
expect(extractTemplateBindings('<%- html %>').declared).toEqual([]);
expect(extractTemplateBindings('<%# const skip = 1 %>').declared).toEqual([]);
});
it('extracts let, var, and destructuring names', () => {
const content = `<% const { username, createdAt } = user; let [first] = items; var count = 1; %>`;
expect(extractTemplateBindings(content).declared).toEqual(
expect.arrayContaining(['username', 'createdAt', 'first', 'count'])
);
});
it('extracts for-of bindings', () => {
const content = `<% for (const item of items) { %><Text><%= item.name %></Text><% } %>`;
expect(extractTemplateBindings(content).forOf).toEqual([{ item: 'item', items: 'items' }]);
});
it('ignores const inside strings and comments', () => {
const content = `<% const msg = "const foo = 1"; /* const hidden = 2 */ %>`;
expect(extractTemplateBindings(content).declared).toEqual(['msg']);
});
});
describe('appendCaptureScriptlet', () => {
it('does not change HTML output when capturing locals', async () => {
const content = '<% const label = "Hello" %><Text><%= label %></Text>';
const session = createIntellisenseRender(content, {});
const html = await ejs.render(session.content, session.data, { async: true });
const withoutCapture = await ejs.render(content, {}, { async: true });
expect(html).toBe(withoutCapture);
expect(html).toBe('<Text>Hello</Text>');
});
});
describe('serializeIntellisenseValue', () => {
it('omits fc helpers and functions', () => {
const serialized = serializeIntellisenseValue({
name: 'Test',
fc: { getObject: async () => ({}) },
[FC_CAPTURE_NAME]: () => {},
});
expect(serialized).toEqual({
$type: 'object',
properties: { name: { $type: 'string', value: 'Test' } },
});
});
it('preserves Date and ObjectId types', () => {
const createdAt = new Date('2020-01-01T00:00:00.000Z');
const cyclic = { name: 'loop' };
cyclic.self = cyclic;
const serialized = serializeIntellisenseValue({
createdAt,
_id: '507f1f77bcf86cd799439011',
cyclic,
});
expect(serialized.properties.createdAt).toEqual({
$type: 'Date',
value: '2020-01-01T00:00:00.000Z',
});
expect(serialized.properties._id).toEqual({
$type: 'ObjectId',
value: '507f1f77bcf86cd799439011',
});
expect(serialized.properties.cyclic.$type).toBe('object');
expect(serialized.properties.cyclic.properties.name).toEqual({
$type: 'string',
value: 'loop',
});
});
});
describe('collectIntellisense', () => {
it('aliases for-of item to the array element type', () => {
const intellisense = collectIntellisense(
{ items: [{ name: 'Widget', sku: 'W1' }] },
{},
[{ item: 'item', items: 'items' }]
);
expect(intellisense.properties.item).toEqual({
$type: 'object',
properties: {
name: { $type: 'string', value: 'Widget' },
sku: { $type: 'string', value: 'W1' },
},
});
});
it('fills null fields from mongoose schema types', () => {
const intellisense = collectIntellisense(
{ name: 'Test', createdAt: null },
{},
[],
{
obj: {
name: String,
createdAt: Date,
},
}
);
expect(intellisense.properties.name).toEqual({ $type: 'string', value: 'Test' });
expect(intellisense.properties.createdAt).toEqual({ $type: 'Date', value: null });
});
});
describe('createIntellisenseRender with real ejs', () => {
it('captures evaluated fc.getObject results', async () => {
const content = `<% const user = await fc.getObject('user', id) %><%= user.username %>`;
const fc = {
getObject: async () => ({
_id: 'abc',
username: 'bob',
createdAt: '2020-01-01',
}),
};
const session = createIntellisenseRender(content, { fc, id: 'abc' });
const html = await ejs.render(session.content, session.data, { async: true });
expect(html).toBe('bob');
expect(session.collect().properties.user).toEqual({
$type: 'object',
properties: {
_id: { $type: 'string', value: 'abc' },
username: { $type: 'string', value: 'bob' },
createdAt: { $type: 'string', value: '2020-01-01' },
},
});
expect(session.collect().properties).not.toHaveProperty('fc');
});
it('still returns template data when render throws before capture', async () => {
const content = `<% throw new Error('boom') %>`;
const session = createIntellisenseRender(content, { username: 'alice', fc: {} });
await expect(ejs.render(session.content, session.data, { async: true })).rejects.toThrow(
'boom'
);
expect(session.collect()).toEqual({
$type: 'object',
properties: { username: { $type: 'string', value: 'alice' } },
});
});
it('does not throw from the capture scriptlet', async () => {
const content = '<% const user = { username: "ok" } %>';
const rendered = appendCaptureScriptlet(content, ['user', 'missing']);
await expect(ejs.render(rendered, { __fcCapture: () => {} }, { async: true })).resolves.toBe(
''
);
});
});
describe('cursor-relative intellisense', () => {
const template = `<% const user = { username: 'bob' } %>
<% for (const item of items) { %>
<Text><%= item.name %></Text>
<% } %>
<Text><%= user.username %></Text>`;
it('preserves template length when projecting to JS', () => {
const projected = projectTemplateToJs(template);
expect(projected.length).toBe(template.length);
});
it('includes loop variables only inside the for body', () => {
const js = projectTemplateToJs(template);
const insideLoop = collectScopeAtOffset(js, template.indexOf('item.name'));
const outsideLoop = collectScopeAtOffset(js, template.lastIndexOf('user.username'));
expect(insideLoop.names).toEqual(expect.arrayContaining(['user', 'item']));
expect(insideLoop.forOf).toEqual([{ item: 'item', items: 'items' }]);
expect(outsideLoop.names).toEqual(expect.arrayContaining(['user']));
expect(outsideLoop.names).not.toContain('item');
expect(outsideLoop.forOf).toEqual([]);
});
it('includes const bindings from an if block only when the cursor is inside it', () => {
const content = `<% const outer = 1 %>
<% if (outer) { %>
<% const inner = 2 %>
<%= inner %>
<% } %>
<%= outer %>`;
const js = projectTemplateToJs(content);
const inside = collectScopeAtOffset(js, content.indexOf('<%= inner %>') + 4);
const outside = collectScopeAtOffset(js, content.lastIndexOf('outer'));
expect(inside.names).toEqual(expect.arrayContaining(['outer', 'inner']));
expect(outside.names).toEqual(expect.arrayContaining(['outer']));
expect(outside.names).not.toContain('inner');
});
it('filters intellisense properties to the cursor scope', () => {
const intellisense = collectIntellisense(
{ items: [{ name: 'Widget' }], title: 'Doc' },
{ user: { username: 'bob' }, item: { name: 'Widget' } },
[{ item: 'item', items: 'items' }]
);
const inside = filterIntellisenseToCursor(
intellisense,
template,
{ offset: template.indexOf('item.name') },
['title', 'items']
);
const outside = filterIntellisenseToCursor(
intellisense,
template,
{ offset: template.lastIndexOf('user.username') },
['title', 'items']
);
expect(inside.properties).toHaveProperty('item');
expect(inside.properties).toHaveProperty('user');
expect(inside.properties).toHaveProperty('title');
expect(outside.properties).toHaveProperty('user');
expect(outside.properties).toHaveProperty('title');
expect(outside.properties).not.toHaveProperty('item');
});
it('captures an object declared inside the active else-if block', async () => {
const content = `<% const clientObject = await fc.getObject("client", client._id);
const orderItems = await fc.listObjects("orderItem", {
orderType: 'salesOrder',
order: _id
})
const shipments = await fc.listObjects("shipment", {
orderType: 'salesOrder',
order: _id
})
var estimatedShippingTimeString = "n/a";
var deliveryMethods = []
if (shipments.length > 1) {
for (let shipment of shipments) {}
} else if (shipments.length == 1) {
const courierService = await fc.getObject("courierService", shipments[0].courierService?._id)
estimatedShippingTimeString = " " + courierService.deliveryTime + " days"
deliveryMethods.push(courierService?.name)
} %>`;
const cursor = {
offset:
content.indexOf('const courierService') +
'const courierService = await fc.getObject("courierService", shipments[0].courierService?._id)'.length,
};
const fc = {
getObject: async (type) =>
type === 'courierService'
? { _id: 'courier-1', name: 'Royal Mail', deliveryTime: 2 }
: { _id: 'client-1' },
listObjects: async (type) =>
type === 'shipment'
? [{ courierService: { _id: 'courier-1' } }]
: [{ _id: 'item-1' }],
};
const session = createCursorIntellisenseRender(content, cursor, {
fc,
client: { _id: 'client-1' },
_id: 'order-1',
});
await ejs.render(session.content, session.data, { async: true });
const intellisense = filterIntellisenseToCursor(
session.collect(),
content,
cursor,
['client', '_id']
);
expect(intellisense.properties.courierService).toEqual({
$type: 'object',
properties: {
_id: { $type: 'string', value: 'courier-1' },
name: { $type: 'string', value: 'Royal Mail' },
deliveryTime: { $type: 'number', value: 2 },
},
});
expect(intellisense.properties).toHaveProperty('shipments');
expect(intellisense.properties).toHaveProperty('clientObject');
});
it('captures all completed declarations in a C-style for-loop body', async () => {
const content = `<% const taxTotals = new Map()
for (var i = 0; i < invoiceOrderItems.length; i++) {
const invoiceOrderItem = invoiceOrderItems[i]
const orderItem = await fc.getObject('orderItem', invoiceOrderItem.orderItem)
const taxRateId = invoiceOrderItem.taxRate?._id || "UNKNOWN";
const taxAmount = invoiceOrderItem.invoiceAmountWithTax - invoiceOrderItem.invoiceAmount;
taxTotals.set(taxRateId, (taxTotals.get(taxRateId) || 0) + taxAmount);
const orderItemObject = await fc.getObject(orderItem.itemType, orderItem.item)
const orderItemObjectSku = await fc.getObject(orderItem.itemType + "Sku", orderItem.sku) %>
<Text><%= orderItemObject.name %> (<%= orderItemObjectSku.name %>)</Text>
<% } %>`;
const cursor = {
offset: content.indexOf('orderItemObject.name') + 'orderItemObject'.length,
};
const fc = {
getObject: async (type) => {
if (type === 'orderItem') {
return { itemType: 'product', item: 'product-1', sku: 'sku-1' };
}
if (type === 'productSku') {
return { _id: 'sku-1', name: 'Blue' };
}
return { _id: 'product-1', name: 'Widget' };
},
};
const session = createCursorIntellisenseRender(content, cursor, {
fc,
invoiceOrderItems: [
{
orderItem: 'order-item-1',
taxRate: { _id: 'tax-1' },
invoiceAmountWithTax: 12,
invoiceAmount: 10,
},
],
});
await ejs.render(session.content, session.data, { async: true });
const intellisense = filterIntellisenseToCursor(
session.collect(),
content,
cursor,
['invoiceOrderItems']
);
expect(Object.keys(intellisense.properties)).toEqual(
expect.arrayContaining([
'i',
'invoiceOrderItem',
'orderItem',
'taxRateId',
'taxAmount',
'orderItemObject',
'orderItemObjectSku',
])
);
expect(intellisense.properties.orderItemObject.properties.name).toEqual({
$type: 'string',
value: 'Widget',
});
expect(intellisense.properties.orderItemObjectSku.properties.name).toEqual({
$type: 'string',
value: 'Blue',
});
});
it('does not include a later declaration before its declaration point', () => {
const content = `<% const before = 1\nconst after = 2 %>`;
const js = projectTemplateToJs(content);
const scope = collectScopeAtOffset(js, content.indexOf('const after'));
expect(scope.names).toContain('before');
expect(scope.names).not.toContain('after');
});
});

View File

@ -206,6 +206,7 @@ describe('TemplateManager', () => {
expect(result.html).toContain('max-width:640px'); expect(result.html).toContain('max-width:640px');
expect(result.html).toContain('transformed: rendered: <Text>Body</Text>'); expect(result.html).toContain('transformed: rendered: <Text>Body</Text>');
expect(result).not.toHaveProperty('height'); expect(result).not.toHaveProperty('height');
expect(result).not.toHaveProperty('intellisense');
}); });
}); });
@ -228,6 +229,24 @@ describe('TemplateManager', () => {
expect(result).toHaveProperty('html'); expect(result).toHaveProperty('html');
expect(result.width).toBe(100); expect(result.width).toBe(100);
expect(result.height).toBe(100); expect(result.height).toBe(100);
expect(result).not.toHaveProperty('intellisense');
});
it('does not return intellisense when preview render throws', async () => {
getObject.mockResolvedValue({
documentSize: { width: 100, height: 100, infiniteHeight: false },
global: false,
objectType: 'printer',
});
ejs.render.mockRejectedValueOnce(new Error('ejs boom'));
const result = await templateManager.renderTemplate('temp-id', 'some content', {
name: 'Test',
});
expect(result.error).toBe('ejs boom');
expect(result.code).toBe(500);
expect(result).not.toHaveProperty('intellisense');
}); });
it('hydrates a saved testObject id when previewing', async () => { it('hydrates a saved testObject id when previewing', async () => {
@ -305,7 +324,14 @@ describe('TemplateManager', () => {
testObject: 'printer-id', testObject: 'printer-id',
}); });
await templateManager.renderTemplate('temp-id', 'some content', {}, 1, {}, false); const result = await templateManager.renderTemplate(
'temp-id',
'some content',
{},
1,
{},
false
);
expect(getObject).toHaveBeenCalledTimes(1); expect(getObject).toHaveBeenCalledTimes(1);
expect(ejs.render).toHaveBeenCalledWith( expect(ejs.render).toHaveBeenCalledWith(
@ -313,6 +339,7 @@ describe('TemplateManager', () => {
expect.objectContaining({ name: null, status: null }), expect.objectContaining({ name: null, status: null }),
expect.anything() expect.anything()
); );
expect(result).not.toHaveProperty('intellisense');
}); });
it('should return error if template not found', async () => { it('should return error if template not found', async () => {
@ -527,6 +554,29 @@ describe('TemplateManager', () => {
}); });
}); });
describe('collectTemplateIntellisense', () => {
it('returns no intellisense when the cursor-instrumented template does not compile', async () => {
getObject.mockResolvedValue({
global: true,
content: '<% const broken = %>',
});
ejs.compile.mockImplementationOnce(() => {
throw new SyntaxError('Unexpected token');
});
const result = await templateManager.collectTemplateIntellisense(
'template-id',
'<% const broken = %>',
{ name: 'Test' },
{ offset: 18 },
'documentTemplate'
);
expect(result).toEqual({});
expect(result).not.toHaveProperty('intellisense');
});
});
describe('listObjects', () => { describe('listObjects', () => {
it('parses a present filter with getFilter before listing', async () => { it('parses a present filter with getFilter before listing', async () => {
const parsedFilter = { name: { $eq: 'Test' } }; const parsedFilter = { name: { $eq: 'Test' } };

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,10 @@ import { getModelByName } from '../services/misc/model.js';
import { getFilter } from '../utils.js'; import { getFilter } from '../utils.js';
import { generatePDF } from './pdffactory.js'; import { generatePDF } from './pdffactory.js';
import { convertPDFToImage } from './pdfUtils.js'; import { convertPDFToImage } from './pdfUtils.js';
import {
createCursorIntellisenseRender,
filterIntellisenseToCursor,
} from './templateintellisense.js';
import { eventManager } from '../events/eventmanager.js'; import { eventManager } from '../events/eventmanager.js';
import { redisServer } from '../database/redis.js'; import { redisServer } from '../database/redis.js';
@ -445,6 +449,15 @@ async function storeDocumentTemplateRender(renderRequestId, payload) {
); );
} }
function resolveTemplateSchema(template) {
if (template?.global == true) {
return null;
}
const modelEntry = getModelByName(template?.objectType);
const model = modelEntry?.model || modelEntry;
return model?.schema || null;
}
function omitTemplateFc(data) { function omitTemplateFc(data) {
if (data == null || typeof data !== 'object' || Array.isArray(data)) { if (data == null || typeof data !== 'object' || Array.isArray(data)) {
return data; return data;
@ -743,16 +756,10 @@ export class TemplateManager {
let templateData; let templateData;
const fc = this.createEmailTemplateFc(() => templateData, renderOptions, visited); const fc = this.createEmailTemplateFc(() => templateData, renderOptions, visited);
templateData = buildTemplateData(template, resolvedData, fc); templateData = buildTemplateData(template, resolvedData, fc);
const renderedSubject = await ejs.render( const subjectSource = typeof subject === 'string' ? subject : template.subject || '';
typeof subject === 'string' ? subject : template.subject || '', const contentSource = typeof content === 'string' ? content : template.content || '';
templateData, const renderedSubject = await ejs.render(subjectSource, templateData, renderOptions);
renderOptions let renderedContent = await ejs.render(contentSource, templateData, renderOptions);
);
let renderedContent = await ejs.render(
typeof content === 'string' ? content : template.content || '',
templateData,
renderOptions
);
let parent = template.parent; let parent = template.parent;
if (parent) { if (parent) {
@ -902,7 +909,11 @@ export class TemplateManager {
templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited); templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited);
await report(0.16, 'Rendering template content...'); await report(0.16, 'Rendering template content...');
const templateContent = await ejs.render(templateContentSource, templateData, defaultOptions); const templateContent = await ejs.render(
templateContentSource,
templateData,
defaultOptions
);
var templateWithParentContent; var templateWithParentContent;
var parentTemplate = documentTemplate.parent; var parentTemplate = documentTemplate.parent;
@ -1230,6 +1241,77 @@ export class TemplateManager {
return result; return result;
} }
async collectTemplateIntellisense(
id,
content,
data = {},
cursor = {},
kind = 'documentTemplate'
) {
try {
const isEmail = kind === 'emailTemplate';
const template = await getObject({
model: isEmail ? emailTemplateModel : documentTemplateModel,
id,
populate: isEmail
? [{ path: 'parent', strictPopulate: false }]
: [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }],
});
if (template == null || template.error) {
return { error: template?.error || 'Template not found.', code: 404 };
}
const source =
content != null && typeof content === 'string' ? content : template.content || '';
const resolvedData = await this.hydratePreviewTestObject(template, data, true);
if (resolvedData?.error) {
return resolvedData;
}
const visited = new Set(template._reference ? [String(template._reference)] : []);
const renderOptions = { async: true };
let templateData;
const fc = isEmail
? this.createEmailTemplateFc(() => templateData, renderOptions, visited)
: this.createTemplateFc(() => templateData, renderOptions, visited);
templateData = buildTemplateData(template, resolvedData, fc);
const session = createCursorIntellisenseRender(
source,
cursor,
templateData,
resolveTemplateSchema(template)
);
let compiledTemplate;
try {
compiledTemplate = ejs.compile(session.content, renderOptions);
} catch (error) {
logger.debug('Skipping intellisense update due to compile error:', error.message);
return {};
}
try {
await compiledTemplate(session.data);
} catch {
// Return scoped types from whatever did evaluate.
}
const alwaysNames = Object.keys(templateData).filter(
(key) => key !== 'fc' && key !== '__fcCapture'
);
return {
intellisense: filterIntellisenseToCursor(
session.collect(),
source,
cursor,
alwaysNames
),
};
} catch (error) {
logger.warn('Error collecting template intellisense:', error.message);
return { error: error.message, code: 500 };
}
}
async listObjects(objectType, filter = {}, populate = []) { async listObjects(objectType, filter = {}, populate = []) {
const modelEntry = getModelByName(objectType); const modelEntry = getModelByName(objectType);
const model = modelEntry?.model || modelEntry; const model = modelEntry?.model || modelEntry;