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;