From c10281064ea1b35454734670c2bd910fe16e81dd Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 12 Sep 2026 23:29:43 +0100 Subject: [PATCH] Add intellisense functionality for document and email templates 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. --- src/routes/management/documenttemplates.js | 5 + src/routes/management/emailtemplates.js | 2 + .../__tests__/documenttemplates.test.js | 28 + src/services/management/documenttemplates.js | 24 + src/services/management/emailtemplates.js | 15 + .../__tests__/templateintellisense.test.js | 375 ++++++ .../__tests__/templatemanager.test.js | 52 +- src/templates/templateintellisense.js | 1199 +++++++++++++++++ src/templates/templatemanager.js | 104 +- 9 files changed, 1792 insertions(+), 12 deletions(-) create mode 100644 src/templates/__tests__/templateintellisense.test.js create mode 100644 src/templates/templateintellisense.js diff --git a/src/routes/management/documenttemplates.js b/src/routes/management/documenttemplates.js index 9d72d6a..33c4464 100644 --- a/src/routes/management/documenttemplates.js +++ b/src/routes/management/documenttemplates.js @@ -47,6 +47,7 @@ import { previewDocumentTemplateRouteHandler, downloadDocumentTemplateRouteHandler, formatDocumentTemplateRouteHandler, + intellisenseDocumentTemplateRouteHandler, } from '../../services/management/documenttemplates.js'; // 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); }); +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) => { previewDocumentTemplateRouteHandler(req, res); }); diff --git a/src/routes/management/emailtemplates.js b/src/routes/management/emailtemplates.js index a02c7ff..ba05cc1 100644 --- a/src/routes/management/emailtemplates.js +++ b/src/routes/management/emailtemplates.js @@ -16,6 +16,7 @@ import { newEmailTemplateRouteHandler, previewEmailTemplateRouteHandler, searchEmailTemplatesRouteHandler, + intellisenseEmailTemplateRouteHandler, } from '../../services/management/emailtemplates.js'; const router = express.Router(); @@ -91,6 +92,7 @@ router.get('/neighbors', isAuthenticated, async (req, res) => { }); router.post('/', isAuthenticated, checkPermissions('emailTemplate', 'new'), newEmailTemplateRouteHandler); 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.get('/:id', isAuthenticated, getEmailTemplateRouteHandler); router.put('/:id', isAuthenticated, checkPermissions('emailTemplate', 'edit'), editEmailTemplateRouteHandler); diff --git a/src/services/management/__tests__/documenttemplates.test.js b/src/services/management/__tests__/documenttemplates.test.js index 1d5b793..0984bc7 100644 --- a/src/services/management/__tests__/documenttemplates.test.js +++ b/src/services/management/__tests__/documenttemplates.test.js @@ -22,6 +22,7 @@ jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({ templateManager: { renderTemplate: jest.fn(), renderDownload: jest.fn(), + collectTemplateIntellisense: jest.fn(), }, DOCUMENT_TEMPLATE_RENDER_KEY_PREFIX: 'documenttemplaterenders:', DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS: 15, @@ -53,6 +54,7 @@ const { editDocumentTemplateRouteHandler, previewDocumentTemplateRouteHandler, downloadDocumentTemplateRouteHandler, + intellisenseDocumentTemplateRouteHandler, } = await import('../documenttemplates.js'); 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', () => { it('should preview a document template', async () => { req.params.id = 'template-1'; diff --git a/src/services/management/documenttemplates.js b/src/services/management/documenttemplates.js index 0fecd93..0dd30da 100644 --- a/src/services/management/documenttemplates.js +++ b/src/services/management/documenttemplates.js @@ -327,6 +327,30 @@ export const formatDocumentTemplateRouteHandler = async (req, res) => { 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) => { const id = req.params.id; const content = req.body?.content; diff --git a/src/services/management/emailtemplates.js b/src/services/management/emailtemplates.js index bbc5cd3..ddbc262 100644 --- a/src/services/management/emailtemplates.js +++ b/src/services/management/emailtemplates.js @@ -305,6 +305,21 @@ export const formatEmailTemplateRouteHandler = (req, res) => { 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) => { const result = await templateManager.renderEmailTemplate( req.params.id, diff --git a/src/templates/__tests__/templateintellisense.test.js b/src/templates/__tests__/templateintellisense.test.js new file mode 100644 index 0000000..74629a1 --- /dev/null +++ b/src/templates/__tests__/templateintellisense.test.js @@ -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) %><%= user.username %>`; + 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) { %><%= item.name %><% } %>`; + 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" %><%= label %>'; + 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('Hello'); + }); +}); + +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) { %> + <%= item.name %> +<% } %> +<%= user.username %>`; + + 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) %> +<%= orderItemObject.name %> (<%= orderItemObjectSku.name %>) +<% } %>`; + 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'); + }); +}); diff --git a/src/templates/__tests__/templatemanager.test.js b/src/templates/__tests__/templatemanager.test.js index a228551..840b918 100644 --- a/src/templates/__tests__/templatemanager.test.js +++ b/src/templates/__tests__/templatemanager.test.js @@ -206,6 +206,7 @@ describe('TemplateManager', () => { expect(result.html).toContain('max-width:640px'); expect(result.html).toContain('transformed: rendered: Body'); expect(result).not.toHaveProperty('height'); + expect(result).not.toHaveProperty('intellisense'); }); }); @@ -228,6 +229,24 @@ describe('TemplateManager', () => { expect(result).toHaveProperty('html'); expect(result.width).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 () => { @@ -305,7 +324,14 @@ describe('TemplateManager', () => { 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(ejs.render).toHaveBeenCalledWith( @@ -313,6 +339,7 @@ describe('TemplateManager', () => { expect.objectContaining({ name: null, status: null }), expect.anything() ); + expect(result).not.toHaveProperty('intellisense'); }); 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', () => { it('parses a present filter with getFilter before listing', async () => { const parsedFilter = { name: { $eq: 'Test' } }; diff --git a/src/templates/templateintellisense.js b/src/templates/templateintellisense.js new file mode 100644 index 0000000..72eaccf --- /dev/null +++ b/src/templates/templateintellisense.js @@ -0,0 +1,1199 @@ +const FC_CAPTURE_NAME = '__fcCapture'; +const MAX_DEPTH = 6; +const MAX_ARRAY_ITEMS = 3; +const MAX_STRING_LENGTH = 500; +const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/; +const SCRIPTLET_RE = /<%(?![=#\-])_?([\s\S]*?)%>/g; +const OMIT_KEYS = new Set([FC_CAPTURE_NAME, 'fc']); +const RESERVED_NAMES = new Set([ + 'break', + 'case', + 'catch', + 'class', + 'const', + 'continue', + 'debugger', + 'default', + 'delete', + 'do', + 'else', + 'export', + 'extends', + 'finally', + 'for', + 'function', + 'if', + 'import', + 'in', + 'instanceof', + 'new', + 'return', + 'super', + 'switch', + 'this', + 'throw', + 'try', + 'typeof', + 'var', + 'void', + 'while', + 'with', + 'yield', + 'let', + 'static', + 'enum', + 'await', + 'implements', + 'interface', + 'package', + 'private', + 'protected', + 'public', + 'null', + 'true', + 'false', + 'undefined', + 'NaN', + 'Infinity', + FC_CAPTURE_NAME, + 'fc', +]); + +function isCapturableName(name) { + return typeof name === 'string' && IDENTIFIER_RE.test(name) && !RESERVED_NAMES.has(name); +} + +function unique(names) { + return [...new Set(names.filter(isCapturableName))]; +} + +function skipWs(source, start) { + let i = start; + while (i < source.length && /\s/.test(source[i])) { + i += 1; + } + return i; +} + +function findMatching(source, start) { + const open = source[start]; + const close = open === '{' ? '}' : open === '[' ? ']' : ')'; + let depth = 0; + for (let i = start; i < source.length; i += 1) { + if (source[i] === open) { + depth += 1; + } else if (source[i] === close) { + depth -= 1; + if (depth === 0) { + return i; + } + } + } + return -1; +} + +function findTopLevelSeparator(source, separator) { + let depthBrace = 0; + let depthBracket = 0; + let depthParen = 0; + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + if (char === '{') { + depthBrace += 1; + } else if (char === '}') { + depthBrace -= 1; + } else if (char === '[') { + depthBracket += 1; + } else if (char === ']') { + depthBracket -= 1; + } else if (char === '(') { + depthParen += 1; + } else if (char === ')') { + depthParen -= 1; + } else if ( + char === separator && + depthBrace === 0 && + depthBracket === 0 && + depthParen === 0 + ) { + return i; + } + } + return -1; +} + +function splitTopLevel(source, separator = ',') { + const parts = []; + let depthBrace = 0; + let depthBracket = 0; + let depthParen = 0; + let current = ''; + for (let i = 0; i < source.length; i += 1) { + const char = source[i]; + if (char === '{') { + depthBrace += 1; + } else if (char === '}') { + depthBrace -= 1; + } else if (char === '[') { + depthBracket += 1; + } else if (char === ']') { + depthBracket -= 1; + } else if (char === '(') { + depthParen += 1; + } else if (char === ')') { + depthParen -= 1; + } + if ( + char === separator && + depthBrace === 0 && + depthBracket === 0 && + depthParen === 0 + ) { + parts.push(current); + current = ''; + continue; + } + current += char; + } + if (current.trim()) { + parts.push(current); + } + return parts; +} + +function skipInitializer(source, start) { + let depthBrace = 0; + let depthBracket = 0; + let depthParen = 0; + let i = start; + while (i < source.length) { + const skipped = skipStringOrComment(source, i); + if (skipped !== i) { + i = skipped; + continue; + } + const char = source[i]; + if (char === '{') { + depthBrace += 1; + } else if (char === '}') { + if (depthBrace === 0) { + break; + } + depthBrace -= 1; + } else if (char === '[') { + depthBracket += 1; + } else if (char === ']') { + if (depthBracket === 0) { + break; + } + depthBracket -= 1; + } else if (char === '(') { + depthParen += 1; + } else if (char === ')') { + if (depthParen === 0) { + break; + } + depthParen -= 1; + } else if ( + (char === ';' || char === ',' || char === '\n') && + depthBrace === 0 && + depthBracket === 0 && + depthParen === 0 + ) { + break; + } + i += 1; + } + return i; +} + +function extractNamesFromPattern(pattern) { + const trimmed = String(pattern || '').trim(); + if (!trimmed) { + return []; + } + if (trimmed.startsWith('...')) { + return extractNamesFromPattern(trimmed.slice(3)); + } + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + return extractNamesFromObjectPattern(trimmed.slice(1, -1)); + } + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return extractNamesFromArrayPattern(trimmed.slice(1, -1)); + } + const equalsAt = findTopLevelSeparator(trimmed, '='); + const left = (equalsAt === -1 ? trimmed : trimmed.slice(0, equalsAt)).trim(); + return isCapturableName(left) ? [left] : []; +} + +function extractNamesFromObjectPattern(inner) { + const names = []; + for (const raw of splitTopLevel(inner)) { + const entry = raw.trim(); + if (!entry) { + continue; + } + if (entry.startsWith('...')) { + names.push(...extractNamesFromPattern(entry)); + continue; + } + const colonAt = findTopLevelSeparator(entry, ':'); + if (colonAt === -1) { + names.push(...extractNamesFromPattern(entry)); + } else { + names.push(...extractNamesFromPattern(entry.slice(colonAt + 1))); + } + } + return names; +} + +function extractNamesFromArrayPattern(inner) { + const names = []; + for (const raw of splitTopLevel(inner)) { + names.push(...extractNamesFromPattern(raw)); + } + return names; +} + +function stripJsStringsAndComments(source) { + let out = ''; + let i = 0; + while (i < source.length) { + const char = source[i]; + const next = source[i + 1]; + if (char === '/' && next === '/') { + while (i < source.length && source[i] !== '\n') { + i += 1; + } + continue; + } + if (char === '/' && next === '*') { + i += 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { + i += 1; + } + i += 2; + out += ' '; + continue; + } + if (char === '"' || char === "'" || char === '`') { + const quote = char; + i += 1; + while (i < source.length) { + if (source[i] === '\\') { + i += 2; + continue; + } + if (source[i] === quote) { + i += 1; + break; + } + i += 1; + } + out += ' "" '; + continue; + } + out += char; + i += 1; + } + return out; +} + +function extractDeclaredFromJs(js) { + const declared = []; + const forOf = []; + const source = stripJsStringsAndComments(js); + + const forOfRe = /\bfor\s*\(\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s+of\s+([A-Za-z_$][\w$]*)/g; + let match; + while ((match = forOfRe.exec(source))) { + if (isCapturableName(match[1]) && isCapturableName(match[2])) { + forOf.push({ item: match[1], items: match[2] }); + } + } + + const declRe = /\b(?:const|let|var)\b/g; + while ((match = declRe.exec(source))) { + let i = skipWs(source, match.index + match[0].length); + const afterDecl = source.slice(i); + if (/^[A-Za-z_$][\w$]*\s+(?:of|in)\b/.test(afterDecl)) { + continue; + } + + while (i < source.length) { + i = skipWs(source, i); + if (i >= source.length) { + break; + } + const char = source[i]; + if (char === ';' || char === ')') { + break; + } + + let pattern; + if (char === '{' || char === '[') { + const end = findMatching(source, i); + if (end === -1) { + break; + } + pattern = source.slice(i, end + 1); + i = end + 1; + } else { + const idMatch = source.slice(i).match(/^[A-Za-z_$][\w$]*/); + if (!idMatch) { + break; + } + pattern = idMatch[0]; + i += pattern.length; + } + + i = skipWs(source, i); + if (source[i] === '=') { + i = skipInitializer(source, i + 1); + } + + declared.push(...extractNamesFromPattern(pattern)); + + i = skipWs(source, i); + if (source[i] === ',') { + i += 1; + continue; + } + break; + } + } + + return { declared: unique(declared), forOf }; +} + +export function extractTemplateBindings(content) { + const declared = []; + const forOf = []; + if (content == null || typeof content !== 'string' || content === '') { + return { declared, forOf }; + } + + SCRIPTLET_RE.lastIndex = 0; + let match; + while ((match = SCRIPTLET_RE.exec(content))) { + const extracted = extractDeclaredFromJs(match[1] || ''); + declared.push(...extracted.declared); + forOf.push(...extracted.forOf); + } + + return { declared: unique(declared), forOf }; +} + +function spaceFill(source) { + return String(source).replace(/[^\n]/g, ' '); +} + +export function projectTemplateToJs(source) { + if (source == null || typeof source !== 'string' || source === '') { + return ''; + } + + let result = ''; + let i = 0; + while (i < source.length) { + const start = source.indexOf('<%', i); + if (start === -1) { + result += spaceFill(source.slice(i)); + break; + } + + result += spaceFill(source.slice(i, start)); + let bodyStart = start + 2; + let kind = 'scriptlet'; + const marker = source[bodyStart]; + if (marker === '#') { + kind = 'comment'; + bodyStart += 1; + } else if (marker === '=' || marker === '-') { + kind = 'output'; + bodyStart += 1; + } else if (marker === '_') { + bodyStart += 1; + } + + const close = source.indexOf('%>', bodyStart); + if (close === -1) { + result += spaceFill(source.slice(start)); + break; + } + + let bodyEnd = close; + if (source[bodyEnd - 1] === '-' || source[bodyEnd - 1] === '_') { + bodyEnd -= 1; + } + const fullEnd = close + 2; + result += ' '.repeat(bodyStart - start); + const body = source.slice(bodyStart, bodyEnd); + result += kind === 'comment' ? spaceFill(body) : body; + result += ' '.repeat(fullEnd - bodyEnd); + i = fullEnd; + } + + return result; +} + +export function cursorToOffset(content, cursor = {}) { + const text = content == null ? '' : String(content); + if (cursor == null || typeof cursor !== 'object') { + return 0; + } + if (Number.isFinite(cursor.offset)) { + return Math.min(Math.max(0, Math.floor(cursor.offset)), text.length); + } + if (Number.isFinite(cursor.line)) { + const line = Math.max(1, Math.floor(cursor.line)); + const column = Math.max(0, Math.floor(cursor.column ?? cursor.ch ?? 0)); + let pos = 0; + let currentLine = 1; + while (currentLine < line && pos < text.length) { + const newline = text.indexOf('\n', pos); + if (newline === -1) { + return text.length; + } + pos = newline + 1; + currentLine += 1; + } + return Math.min(pos + column, text.length); + } + return 0; +} + +function isWordChar(char) { + return char != null && /[A-Za-z0-9_$]/.test(char); +} + +function matchKeywordAt(source, index, word) { + if (index < 0 || source.slice(index, index + word.length) !== word) { + return false; + } + if (index > 0 && isWordChar(source[index - 1])) { + return false; + } + if (isWordChar(source[index + word.length])) { + return false; + } + return true; +} + +function skipStringOrComment(source, start) { + const char = source[start]; + const next = source[start + 1]; + if (char === '/' && next === '/') { + let i = start + 2; + while (i < source.length && source[i] !== '\n') { + i += 1; + } + return i; + } + if (char === '/' && next === '*') { + let i = start + 2; + while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) { + i += 1; + } + return Math.min(i + 2, source.length); + } + if (char === '"' || char === "'" || char === '`') { + let i = start + 1; + while (i < source.length) { + if (source[i] === '\\') { + i += 2; + continue; + } + if (source[i] === char) { + return i + 1; + } + i += 1; + } + return source.length; + } + return start; +} + +function findMatchingAware(source, start) { + const open = source[start]; + const close = open === '{' ? '}' : open === '[' ? ']' : ')'; + let depth = 0; + let i = start; + while (i < source.length) { + const skipped = skipStringOrComment(source, i); + if (skipped !== i) { + i = skipped; + continue; + } + if (source[i] === open) { + depth += 1; + } else if (source[i] === close) { + depth -= 1; + if (depth === 0) { + return i; + } + } + i += 1; + } + return -1; +} + +function previousNonWs(source, index) { + let i = index; + while (i >= 0 && /\s/.test(source[i])) { + i -= 1; + } + return i; +} + +function isBlockBrace(source, braceIndex) { + const i = previousNonWs(source, braceIndex - 1); + if (i < 0) { + return true; + } + const char = source[i]; + if (char === ')' || char === '{' || char === '}' || char === ';' || char === '>') { + return true; + } + if (matchKeywordAt(source, i - 3, 'else')) { + return true; + } + if (matchKeywordAt(source, i - 1, 'do')) { + return true; + } + if (matchKeywordAt(source, i - 2, 'try')) { + return true; + } + if (matchKeywordAt(source, i - 6, 'finally')) { + return true; + } + return false; +} + +function parseDeclarationAt(source, keywordIndex) { + const kindMatch = source.slice(keywordIndex).match(/^(const|let|var)\b/); + if (!kindMatch) { + return null; + } + const kind = kindMatch[1]; + let i = skipWs(source, keywordIndex + kindMatch[0].length); + const afterDecl = source.slice(i); + if (/^[A-Za-z_$][\w$]*\s+(?:of|in)\b/.test(afterDecl)) { + return { kind, names: [], end: i }; + } + + const names = []; + let visibilityOffset = i; + while (i < source.length) { + i = skipWs(source, i); + if (i >= source.length) { + break; + } + const char = source[i]; + if (char === ';' || char === ')') { + break; + } + + let pattern; + if (char === '{' || char === '[') { + const end = findMatchingAware(source, i); + if (end === -1) { + break; + } + pattern = source.slice(i, end + 1); + i = end + 1; + } else { + const idMatch = source.slice(i).match(/^[A-Za-z_$][\w$]*/); + if (!idMatch) { + break; + } + pattern = idMatch[0]; + i += pattern.length; + } + + i = skipWs(source, i); + if (source[i] === '=') { + i = skipInitializer(source, i + 1); + } + + names.push(...extractNamesFromPattern(pattern)); + visibilityOffset = i; + i = skipWs(source, i); + if (source[i] === ',') { + i += 1; + continue; + } + break; + } + + return { kind, names: unique(names), end: i, visibilityOffset }; +} + +function parseParenGroup(source, fromIndex) { + let i = skipWs(source, fromIndex); + if (source[i] !== '(') { + return { inner: '', end: i }; + } + const close = findMatchingAware(source, i); + if (close === -1) { + return { inner: source.slice(i + 1), end: source.length }; + } + return { inner: source.slice(i + 1, close), end: close + 1 }; +} + +export function collectScopeAtOffset(js, offset) { + const root = { start: 0, end: js.length, names: [], declarations: [], forOf: [] }; + const stack = [root]; + const scopes = [root]; + let pending = null; + let i = 0; + + const pushScope = (start, end, extra = {}) => { + const scope = { + start, + end: end == null ? js.length : end, + names: extra.names ? [...extra.names] : [], + declarations: (extra.names || []).map((name) => ({ name, offset: start })), + forOf: extra.forOf ? [...extra.forOf] : [], + }; + stack.push(scope); + scopes.push(scope); + return scope; + }; + + while (i < js.length) { + const skipped = skipStringOrComment(js, i); + if (skipped !== i) { + i = skipped; + continue; + } + + if (matchKeywordAt(js, i, 'for')) { + const forStart = i; + const header = parseParenGroup(js, i + 3); + const extracted = extractDeclaredFromJs(`for (${header.inner})`); + pending = { + names: extracted.declared, + forOf: extracted.forOf, + start: forStart, + }; + i = skipWs(js, header.end); + continue; + } + + if (matchKeywordAt(js, i, 'catch')) { + const header = parseParenGroup(js, i + 5); + const extracted = extractDeclaredFromJs(`const ${header.inner}`); + pending = { + names: extracted.declared, + forOf: [], + start: i, + }; + i = skipWs(js, header.end); + continue; + } + + if (matchKeywordAt(js, i, 'const') || matchKeywordAt(js, i, 'let') || matchKeywordAt(js, i, 'var')) { + const declaration = parseDeclarationAt(js, i); + if (declaration) { + const target = declaration.kind === 'var' ? root : stack[stack.length - 1]; + target.names.push(...declaration.names); + target.declarations.push( + ...declaration.names.map((name) => ({ + name, + offset: declaration.visibilityOffset, + })) + ); + i = Math.max(declaration.end, i + 1); + continue; + } + } + + const char = js[i]; + if (char === '{') { + if (isBlockBrace(js, i)) { + const end = findMatchingAware(js, i); + const extra = pending + ? { + names: unique([ + ...(pending.names || []), + ...(pending.forOf || []).map((binding) => binding.item), + ]), + forOf: pending.forOf, + } + : {}; + const scope = pushScope(pending?.start ?? i, end === -1 ? js.length : end, extra); + if (pending) { + scope.start = pending.start; + pending = null; + } + i += 1; + continue; + } + const end = findMatchingAware(js, i); + i = end === -1 ? i + 1 : end + 1; + continue; + } + + if (char === '}') { + if (stack.length > 1) { + stack[stack.length - 1].end = i; + stack.pop(); + } + pending = null; + i += 1; + continue; + } + + i += 1; + } + + const pos = Math.min(Math.max(0, offset), js.length); + const enclosing = scopes.filter((scope) => pos >= scope.start && pos <= scope.end); + const names = []; + const forOf = []; + for (const scope of enclosing) { + names.push( + ...scope.declarations + .filter((declaration) => declaration.offset <= pos) + .map((declaration) => declaration.name) + ); + forOf.push(...scope.forOf); + } + return { names: unique(names), forOf }; +} + +export function filterIntellisenseToCursor(intellisense, content, cursor, alwaysNames = []) { + const offset = cursorToOffset(content, cursor); + const js = projectTemplateToJs(content || ''); + const scope = collectScopeAtOffset(js, offset); + const allowed = new Set([ + ...alwaysNames.filter(isCapturableName), + ...scope.names, + ...scope.forOf.map((binding) => binding.item), + ]); + const sourceProps = + intellisense?.$type === 'object' && intellisense.properties != null + ? intellisense.properties + : {}; + const properties = {}; + for (const [name, node] of Object.entries(sourceProps)) { + if (allowed.has(name)) { + properties[name] = node; + } + } + for (const binding of scope.forOf) { + if (properties[binding.item] != null && properties[binding.item].$type !== 'null') { + continue; + } + const collection = properties[binding.items] || sourceProps[binding.items]; + if (collection?.$type === 'array' && collection.element != null) { + properties[binding.item] = collection.element; + } else if (properties[binding.item] == null) { + properties[binding.item] = { $type: 'any' }; + } + } + for (const name of allowed) { + if (properties[name] == null) { + properties[name] = { $type: 'any' }; + } + } + return { $type: 'object', properties }; +} + +export function buildCaptureScriptlet(names) { + const capturable = unique(names); + if (capturable.length === 0) { + return ''; + } + return `<% ${buildCaptureCall(capturable)} %>`; +} + +function buildCaptureCall(names) { + const capturable = unique(names); + if (capturable.length === 0) { + return ''; + } + const props = capturable.map((name) => { + const key = JSON.stringify(name); + return `${key}: typeof ${name} !== "undefined" ? ${name} : undefined`; + }); + return `try { ${FC_CAPTURE_NAME}({ ${props.join(', ')} }); } catch (e) {}`; +} + +export function appendCaptureScriptlet(content, names) { + return `${content || ''}${buildCaptureScriptlet(names)}`; +} + +function findEjsRegionAtOffset(content, offset) { + let searchFrom = 0; + let lastRegion = null; + while (searchFrom < content.length) { + const open = content.indexOf('<%', searchFrom); + if (open === -1 || open > offset) { + break; + } + const marker = content[open + 2]; + const bodyStart = open + 2 + (['=', '-', '#', '_'].includes(marker) ? 1 : 0); + const close = content.indexOf('%>', bodyStart); + if (close === -1) { + break; + } + const region = { + open, + bodyStart, + close, + end: close + 2, + executable: marker !== '=' && marker !== '-' && marker !== '#', + }; + if (offset >= open && offset <= region.end) { + return region; + } + lastRegion = region; + searchFrom = region.end; + } + return lastRegion?.end === offset ? lastRegion : null; +} + +export function injectCaptureAtCursor(content, cursor, names) { + const source = content == null ? '' : String(content); + const offset = cursorToOffset(source, cursor); + const call = buildCaptureCall(names); + if (!call) { + return source; + } + + const region = findEjsRegionAtOffset(source, offset); + if (region?.executable && offset >= region.bodyStart && offset <= region.close) { + return `${source.slice(0, offset)}; ${call};${source.slice(offset)}`; + } + + const insertionOffset = + region && offset >= region.open && offset <= region.end ? region.end : offset; + return `${source.slice(0, insertionOffset)}<% ${call} %>${source.slice(insertionOffset)}`; +} + +const SCHEMA_EXCLUDED_PATHS = new Set(['__v', 'appPasswordHash', 'secret']); +const ISO_DATE_RE = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/; +const OBJECT_ID_RE = /^[a-fA-F0-9]{24}$/; + +function isObjectIdLike(value) { + return ( + value != null && + typeof value === 'object' && + (typeof value.toHexString === 'function' || value._bsontype === 'ObjectId') + ); +} + +function isTypedNode(value) { + return value != null && typeof value === 'object' && !Array.isArray(value) && typeof value.$type === 'string'; +} + +function typedString(value, key) { + const truncated = value.length > MAX_STRING_LENGTH ? value.slice(0, MAX_STRING_LENGTH) : value; + if (ISO_DATE_RE.test(value)) { + return { $type: 'Date', value: truncated }; + } + if ((key === '_id' || key === 'id' || /Id$/.test(String(key || ''))) && OBJECT_ID_RE.test(value)) { + return { $type: 'ObjectId', value: truncated }; + } + return { $type: 'string', value: truncated }; +} + +function mongooseCtorType(ctor, field = {}) { + if (ctor == null) { + return { $type: 'any' }; + } + const name = ctor.name || ctor.schemaName || (typeof ctor === 'string' ? ctor : ''); + if (ctor === String || name === 'String') { + return { $type: 'string' }; + } + if (ctor === Number || name === 'Number' || name === 'Decimal128') { + return { $type: 'number' }; + } + if (ctor === Date || name === 'Date') { + return { $type: 'Date' }; + } + if (ctor === Boolean || name === 'Boolean') { + return { $type: 'boolean' }; + } + if (name === 'ObjectId' || name === 'ObjectID') { + const ref = field.ref || field.options?.ref; + return ref ? { $type: 'ObjectId', name: String(ref) } : { $type: 'ObjectId' }; + } + if (name === 'Buffer') { + return { $type: 'any' }; + } + if (name === 'Mixed' || name === 'Object') { + return { $type: 'object', properties: {} }; + } + return { $type: 'any' }; +} + +function typeFromMongoosePath(path) { + if (!path) { + return { $type: 'any' }; + } + if (path.instance === 'Array') { + return { $type: 'array', element: typeFromMongoosePath(path.caster) }; + } + if (path.schema) { + return typedNodeFromMongooseSchema(path.schema); + } + if (path.instance === 'Embedded' && path.caster?.schema) { + return typedNodeFromMongooseSchema(path.caster.schema); + } + return mongooseCtorType(path.options?.type || path.instance, path); +} + +function typeFromMongooseField(field) { + if (field == null || (typeof field === 'object' && !Array.isArray(field) && Object.keys(field).length === 0)) { + return { $type: 'any' }; + } + if (Array.isArray(field)) { + return { $type: 'array', element: typeFromMongooseField(field[0]) }; + } + if (typeof field === 'object' && (field.obj || field.paths)) { + return typedNodeFromMongooseSchema(field); + } + if (typeof field === 'object' && field.type) { + if (Array.isArray(field.type)) { + return { $type: 'array', element: typeFromMongooseField(field.type[0] ?? field) }; + } + return mongooseCtorType(field.type, field); + } + return mongooseCtorType(field, {}); +} + +export function typedNodeFromMongooseSchema(schema) { + if (schema == null || typeof schema !== 'object') { + return null; + } + const properties = {}; + if (schema.paths && typeof schema.paths === 'object') { + for (const [key, path] of Object.entries(schema.paths)) { + if (key.includes('.') || SCHEMA_EXCLUDED_PATHS.has(key)) { + continue; + } + properties[key] = typeFromMongoosePath(path); + } + } else if (schema.obj && typeof schema.obj === 'object') { + for (const [key, field] of Object.entries(schema.obj)) { + if (SCHEMA_EXCLUDED_PATHS.has(key)) { + continue; + } + properties[key] = typeFromMongooseField(field); + } + } else { + return null; + } + return { $type: 'object', properties }; +} + +export function mergeTypedNodes(runtime, schema) { + if (!isTypedNode(schema)) { + return runtime; + } + if (!isTypedNode(runtime)) { + return schema; + } + if (runtime.$type === 'null' && schema.$type !== 'null' && schema.$type !== 'any') { + return { ...schema, value: null }; + } + if (runtime.$type === 'array' && schema.$type === 'array') { + return { + $type: 'array', + element: mergeTypedNodes(runtime.element, schema.element) || runtime.element || schema.element, + }; + } + if (runtime.$type === 'object' && schema.$type === 'object') { + const properties = { ...(schema.properties || {}) }; + for (const [key, node] of Object.entries(runtime.properties || {})) { + properties[key] = mergeTypedNodes(node, properties[key]); + } + return { + $type: 'object', + ...(runtime.name || schema.name ? { name: runtime.name || schema.name } : {}), + properties, + }; + } + return runtime; +} + +export function serializeIntellisenseValue(value, depth = 0, seen = new WeakSet(), key = null) { + if (value === undefined) { + return undefined; + } + if (value === null) { + return { $type: 'null' }; + } + + const valueType = typeof value; + if (valueType === 'string') { + return typedString(value, key); + } + if (valueType === 'number') { + return { $type: 'number', value }; + } + if (valueType === 'boolean') { + return { $type: 'boolean', value }; + } + if (valueType === 'bigint') { + return { $type: 'string', value: String(value) }; + } + if (valueType === 'function' || valueType === 'symbol') { + return undefined; + } + if (value instanceof Date) { + return { $type: 'Date', value: value.toISOString() }; + } + if (typeof Buffer !== 'undefined' && Buffer.isBuffer?.(value)) { + return undefined; + } + if (isObjectIdLike(value)) { + return { $type: 'ObjectId', value: String(value) }; + } + if (valueType !== 'object') { + return undefined; + } + if (seen.has(value)) { + return { $type: 'object', properties: {} }; + } + + if (depth >= MAX_DEPTH) { + return Array.isArray(value) ? { $type: 'array', element: { $type: 'any' } } : { $type: 'object', properties: {} }; + } + + seen.add(value); + + if (typeof value.toObject === 'function') { + try { + const plain = value.toObject({ virtuals: true }); + if (plain !== value) { + return serializeIntellisenseValue(plain, depth, seen, key); + } + } catch { + // Fall through to enumerating own keys. + } + } + if (typeof value.toJSON === 'function' && value.toJSON !== Object.prototype.toJSON) { + try { + const jsonValue = value.toJSON(); + if (jsonValue !== value) { + return serializeIntellisenseValue(jsonValue, depth, seen, key); + } + } catch { + // Fall through to enumerating own keys. + } + } + + if (Array.isArray(value)) { + let element = null; + for (const item of value.slice(0, MAX_ARRAY_ITEMS)) { + const serialized = serializeIntellisenseValue(item, depth + 1, seen); + if (isTypedNode(serialized) && serialized.$type !== 'null') { + element = element ? mergeTypedNodes(serialized, element) : serialized; + } + } + return { $type: 'array', element: element || { $type: 'any' } }; + } + + const properties = {}; + for (const [nestedKey, nested] of Object.entries(value)) { + if (OMIT_KEYS.has(nestedKey) || typeof nested === 'function') { + continue; + } + const serialized = serializeIntellisenseValue(nested, depth + 1, seen, nestedKey); + if (serialized !== undefined) { + properties[nestedKey] = serialized; + } + } + return { $type: 'object', properties }; +} + +export function collectIntellisense(templateData, captured = {}, forOfBindings = [], schema = null) { + const merged = { + ...(templateData != null && typeof templateData === 'object' && !Array.isArray(templateData) + ? templateData + : {}), + ...(captured != null && typeof captured === 'object' && !Array.isArray(captured) + ? captured + : {}), + }; + const runtime = serializeIntellisenseValue(merged) || { $type: 'object', properties: {} }; + const schemaNode = typedNodeFromMongooseSchema(schema); + const base = mergeTypedNodes(runtime, schemaNode) || { $type: 'object', properties: {} }; + if (base.$type !== 'object' || base.properties == null) { + return { $type: 'object', properties: {} }; + } + + for (const binding of forOfBindings) { + const itemName = binding?.item; + const itemsName = binding?.items; + if (!isCapturableName(itemName) || !isCapturableName(itemsName)) { + continue; + } + if (base.properties[itemName] != null && base.properties[itemName].$type !== 'null') { + continue; + } + const collection = base.properties[itemsName]; + if (collection?.$type === 'array' && collection.element != null) { + base.properties[itemName] = collection.element; + } + } + + return base; +} + +export function createIntellisenseRender(content, templateData, schema = null) { + const bindings = extractTemplateBindings(content); + const captured = {}; + const hasCapture = bindings.declared.length > 0; + const data = hasCapture + ? { + ...templateData, + [FC_CAPTURE_NAME]: (values) => { + if (values != null && typeof values === 'object' && !Array.isArray(values)) { + Object.assign(captured, values); + } + }, + } + : templateData; + + return { + content: hasCapture ? appendCaptureScriptlet(content, bindings.declared) : content, + data, + collect: () => collectIntellisense(templateData, captured, bindings.forOf, schema), + }; +} + +export function createCursorIntellisenseRender( + content, + cursor, + templateData, + schema = null +) { + const source = content == null ? '' : String(content); + const offset = cursorToOffset(source, cursor); + const scope = collectScopeAtOffset(projectTemplateToJs(source), offset); + const captured = {}; + const names = unique(scope.names); + const data = { + ...templateData, + [FC_CAPTURE_NAME]: (values) => { + if (values != null && typeof values === 'object' && !Array.isArray(values)) { + Object.assign(captured, values); + } + }, + }; + + return { + content: injectCaptureAtCursor(source, { offset }, names), + data, + scope, + collect: () => collectIntellisense(templateData, captured, scope.forOf, schema), + }; +} + +export { FC_CAPTURE_NAME }; diff --git a/src/templates/templatemanager.js b/src/templates/templatemanager.js index b4fa3bf..226624e 100644 --- a/src/templates/templatemanager.js +++ b/src/templates/templatemanager.js @@ -18,6 +18,10 @@ import { getModelByName } from '../services/misc/model.js'; import { getFilter } from '../utils.js'; import { generatePDF } from './pdffactory.js'; import { convertPDFToImage } from './pdfUtils.js'; +import { + createCursorIntellisenseRender, + filterIntellisenseToCursor, +} from './templateintellisense.js'; import { eventManager } from '../events/eventmanager.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) { if (data == null || typeof data !== 'object' || Array.isArray(data)) { return data; @@ -743,16 +756,10 @@ export class TemplateManager { let templateData; const fc = this.createEmailTemplateFc(() => templateData, renderOptions, visited); templateData = buildTemplateData(template, resolvedData, fc); - const renderedSubject = await ejs.render( - typeof subject === 'string' ? subject : template.subject || '', - templateData, - renderOptions - ); - let renderedContent = await ejs.render( - typeof content === 'string' ? content : template.content || '', - templateData, - renderOptions - ); + const subjectSource = typeof subject === 'string' ? subject : template.subject || ''; + const contentSource = typeof content === 'string' ? content : template.content || ''; + const renderedSubject = await ejs.render(subjectSource, templateData, renderOptions); + let renderedContent = await ejs.render(contentSource, templateData, renderOptions); let parent = template.parent; if (parent) { @@ -902,7 +909,11 @@ export class TemplateManager { templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited); 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 parentTemplate = documentTemplate.parent; @@ -1230,6 +1241,77 @@ export class TemplateManager { 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 = []) { const modelEntry = getModelByName(objectType); const model = modelEntry?.model || modelEntry;