Add template resource management features and routes
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
All checks were successful
farmcontrol/farmcontrol-api/pipeline/head This commit looks good
This commit introduces a new schema for managing template resources, including validation and normalization functions. It adds routes for retrieving template resources and integrates resource handling into document and email template services. The changes enhance the application's template management capabilities by allowing for unique resource names and improved resource URL generation. Additionally, tests are added to ensure the correct functionality of the new features, including resource validation and rendering.
This commit is contained in:
parent
622f4b20f2
commit
171d3094a2
@ -1,9 +1,13 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
|
import {
|
||||||
|
normalizeTemplateResourceName,
|
||||||
|
RESOURCE_NAME_PATTERN,
|
||||||
|
validateTemplateResources,
|
||||||
|
} from '../../../templates/templateresources.js';
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
|
|
||||||
const RENDER_DOCUMENT_TEMPLATE_CALL =
|
const RENDER_DOCUMENT_TEMPLATE_CALL = /fc\.renderDocumentTemplate\s*\(\s*(['"])([^'"]+)\1/g;
|
||||||
/fc\.renderDocumentTemplate\s*\(\s*(['"])([^'"]+)\1/g;
|
|
||||||
|
|
||||||
function extractRenderDocumentTemplateReferences(content) {
|
function extractRenderDocumentTemplateReferences(content) {
|
||||||
if (content == null || typeof content !== 'string' || content === '') {
|
if (content == null || typeof content !== 'string' || content === '') {
|
||||||
@ -103,6 +107,27 @@ const documentTemplateSchema = new Schema(
|
|||||||
refPath: 'objectType',
|
refPath: 'objectType',
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
|
resources: {
|
||||||
|
type: [
|
||||||
|
new Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
set: normalizeTemplateResourceName,
|
||||||
|
match: RESOURCE_NAME_PATTERN,
|
||||||
|
},
|
||||||
|
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
|
||||||
|
},
|
||||||
|
{ _id: true }
|
||||||
|
),
|
||||||
|
],
|
||||||
|
default: [],
|
||||||
|
validate: {
|
||||||
|
validator: validateTemplateResources,
|
||||||
|
message: 'Template resource names must be unique.',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,10 @@
|
|||||||
import mongoose from 'mongoose';
|
import mongoose from 'mongoose';
|
||||||
import { generateId } from '../../utils.js';
|
import { generateId } from '../../utils.js';
|
||||||
|
import {
|
||||||
|
normalizeTemplateResourceName,
|
||||||
|
RESOURCE_NAME_PATTERN,
|
||||||
|
validateTemplateResources,
|
||||||
|
} from '../../../templates/templateresources.js';
|
||||||
|
|
||||||
const { Schema } = mongoose;
|
const { Schema } = mongoose;
|
||||||
const RENDER_EMAIL_TEMPLATE_CALL = /fc\.renderEmailTemplate\s*\(\s*(['"])([^'"]+)\1/g;
|
const RENDER_EMAIL_TEMPLATE_CALL = /fc\.renderEmailTemplate\s*\(\s*(['"])([^'"]+)\1/g;
|
||||||
@ -36,13 +41,32 @@ const emailTemplateSchema = new Schema(
|
|||||||
active: { type: Boolean, required: true, default: true },
|
active: { type: Boolean, required: true, default: true },
|
||||||
global: { type: Boolean, required: true, default: false },
|
global: { type: Boolean, required: true, default: false },
|
||||||
parent: { type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false },
|
parent: { type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false },
|
||||||
referencedTemplates: [
|
referencedTemplates: [{ type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false }],
|
||||||
{ type: Schema.Types.ObjectId, ref: 'emailTemplate', required: false },
|
|
||||||
],
|
|
||||||
subject: { type: String, required: false, default: '' },
|
subject: { type: String, required: false, default: '' },
|
||||||
content: { type: String, required: false, default: '<Container></Container>' },
|
content: { type: String, required: false, default: '<Container></Container>' },
|
||||||
testObject: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false },
|
testObject: { type: Schema.Types.ObjectId, refPath: 'objectType', required: false },
|
||||||
attachments: { type: [emailTemplateAttachmentSchema], default: [] },
|
attachments: { type: [emailTemplateAttachmentSchema], default: [] },
|
||||||
|
resources: {
|
||||||
|
type: [
|
||||||
|
new Schema(
|
||||||
|
{
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
set: normalizeTemplateResourceName,
|
||||||
|
match: RESOURCE_NAME_PATTERN,
|
||||||
|
},
|
||||||
|
file: { type: Schema.Types.ObjectId, ref: 'file', required: true },
|
||||||
|
},
|
||||||
|
{ _id: true }
|
||||||
|
),
|
||||||
|
],
|
||||||
|
default: [],
|
||||||
|
validate: {
|
||||||
|
validator: validateTemplateResources,
|
||||||
|
message: 'Template resource names must be unique.',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|||||||
@ -74,6 +74,7 @@ import {
|
|||||||
appUpdateRoutes,
|
appUpdateRoutes,
|
||||||
serverRoutes,
|
serverRoutes,
|
||||||
slicerRoutes,
|
slicerRoutes,
|
||||||
|
templateResourceRoutes,
|
||||||
} from './routes/index.js';
|
} from './routes/index.js';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@ -247,6 +248,7 @@ app.use('/applaunch', appLaunchRoutes);
|
|||||||
app.use('/appupdate', appUpdateRoutes);
|
app.use('/appupdate', appUpdateRoutes);
|
||||||
app.use('/server', serverRoutes);
|
app.use('/server', serverRoutes);
|
||||||
app.use('/slicer', slicerRoutes);
|
app.use('/slicer', slicerRoutes);
|
||||||
|
app.use('/template-resources', templateResourceRoutes);
|
||||||
|
|
||||||
// Start the application
|
// Start the application
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
|||||||
@ -67,6 +67,7 @@ import appLaunchRoutes from './misc/applaunch.js';
|
|||||||
import appUpdateRoutes from './misc/appupdate.js';
|
import appUpdateRoutes from './misc/appupdate.js';
|
||||||
import serverRoutes from './misc/server.js';
|
import serverRoutes from './misc/server.js';
|
||||||
import slicerRoutes from './misc/slicer.js';
|
import slicerRoutes from './misc/slicer.js';
|
||||||
|
import templateResourceRoutes from './misc/templateresources.js';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
userRoutes,
|
userRoutes,
|
||||||
@ -138,4 +139,5 @@ export {
|
|||||||
appUpdateRoutes,
|
appUpdateRoutes,
|
||||||
serverRoutes,
|
serverRoutes,
|
||||||
slicerRoutes,
|
slicerRoutes,
|
||||||
|
templateResourceRoutes,
|
||||||
};
|
};
|
||||||
|
|||||||
8
src/routes/misc/templateresources.js
Normal file
8
src/routes/misc/templateresources.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { getTemplateResourceRouteHandler } from '../../services/misc/templateresources.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/:token', getTemplateResourceRouteHandler);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -58,12 +58,10 @@ const {
|
|||||||
intellisenseDocumentTemplateRouteHandler,
|
intellisenseDocumentTemplateRouteHandler,
|
||||||
} = await import('../documenttemplates.js');
|
} = await import('../documenttemplates.js');
|
||||||
|
|
||||||
const { listObjects, getObject, editObject, newObject } = await import(
|
const { listObjects, getObject, editObject, newObject } =
|
||||||
'../../../database/database.js'
|
await import('../../../database/database.js');
|
||||||
);
|
const { documentTemplateModel } =
|
||||||
const { documentTemplateModel } = await import(
|
await import('../../../database/schemas/management/documenttemplate.schema.js');
|
||||||
'../../../database/schemas/management/documenttemplate.schema.js'
|
|
||||||
);
|
|
||||||
const { templateManager } = await import('../../../templates/templatemanager.js');
|
const { templateManager } = await import('../../../templates/templatemanager.js');
|
||||||
const { redisServer } = await import('../../../database/redis.js');
|
const { redisServer } = await import('../../../database/redis.js');
|
||||||
|
|
||||||
@ -101,15 +99,40 @@ describe('Document Template Service Route Handlers', () => {
|
|||||||
|
|
||||||
describe('newDocumentTemplateRouteHandler', () => {
|
describe('newDocumentTemplateRouteHandler', () => {
|
||||||
it('should create a new document template', async () => {
|
it('should create a new document template', async () => {
|
||||||
req.body = { name: 'New Template', documentSize: 'size123' };
|
req.body = {
|
||||||
|
name: 'New Template',
|
||||||
|
documentSize: 'size123',
|
||||||
|
resources: [{ name: ' Header Logo ', file: { _id: 'file-1' } }],
|
||||||
|
};
|
||||||
const mockTemplate = { _id: '456', ...req.body };
|
const mockTemplate = { _id: '456', ...req.body };
|
||||||
newObject.mockResolvedValue(mockTemplate);
|
newObject.mockResolvedValue(mockTemplate);
|
||||||
|
|
||||||
await newDocumentTemplateRouteHandler(req, res);
|
await newDocumentTemplateRouteHandler(req, res);
|
||||||
|
|
||||||
expect(newObject).toHaveBeenCalled();
|
expect(newObject).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
newData: expect.objectContaining({
|
||||||
|
resources: [{ _id: undefined, name: 'header-logo', file: 'file-1' }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
expect(res.send).toHaveBeenCalledWith(mockTemplate);
|
expect(res.send).toHaveBeenCalledWith(mockTemplate);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects resource names that collide after normalization', async () => {
|
||||||
|
req.body = {
|
||||||
|
name: 'New Template',
|
||||||
|
resources: [
|
||||||
|
{ name: 'Main Logo', file: 'file-1' },
|
||||||
|
{ name: 'main-logo', file: 'file-2' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await newDocumentTemplateRouteHandler(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(newObject).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('intellisenseDocumentTemplateRouteHandler', () => {
|
describe('intellisenseDocumentTemplateRouteHandler', () => {
|
||||||
@ -294,4 +317,3 @@ describe('Document Template Service Route Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,10 @@ jest.unstable_mockModule('../../../database/database.js', () => ({
|
|||||||
searchObjects: jest.fn(),
|
searchObjects: jest.fn(),
|
||||||
}));
|
}));
|
||||||
jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({
|
jest.unstable_mockModule('../../../templates/templatemanager.js', () => ({
|
||||||
templateManager: { renderEmailTemplate: jest.fn() },
|
templateManager: {
|
||||||
|
renderEmailTemplate: jest.fn(),
|
||||||
|
collectTemplateIntellisense: jest.fn(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
jest.unstable_mockModule('../../../templates/templateformatter.js', () => ({
|
jest.unstable_mockModule('../../../templates/templateformatter.js', () => ({
|
||||||
formatTemplateContent: jest.fn((content) => ({ content })),
|
formatTemplateContent: jest.fn((content) => ({ content })),
|
||||||
@ -41,11 +44,14 @@ const {
|
|||||||
getEmailTemplateStatsRouteHandler,
|
getEmailTemplateStatsRouteHandler,
|
||||||
listEmailTemplatesByPropertiesRouteHandler,
|
listEmailTemplatesByPropertiesRouteHandler,
|
||||||
newEmailTemplateRouteHandler,
|
newEmailTemplateRouteHandler,
|
||||||
|
intellisenseEmailTemplateRouteHandler,
|
||||||
} = await import('../emailtemplates.js');
|
} = await import('../emailtemplates.js');
|
||||||
const { editObject, getModelStats, getObjectNeighbors, listObjectsByProperties, newObject } =
|
const { editObject, getModelStats, getObjectNeighbors, listObjectsByProperties, newObject } =
|
||||||
await import('../../../database/database.js');
|
await import('../../../database/database.js');
|
||||||
|
|
||||||
const ATTACHMENT_POPULATE = { path: 'attachments.file', strictPopulate: false };
|
const ATTACHMENT_POPULATE = { path: 'attachments.file', strictPopulate: false };
|
||||||
|
const RESOURCE_POPULATE = { path: 'resources.file', strictPopulate: false };
|
||||||
|
const { templateManager } = await import('../../../templates/templatemanager.js');
|
||||||
|
|
||||||
describe('email template standard handlers', () => {
|
describe('email template standard handlers', () => {
|
||||||
let req;
|
let req;
|
||||||
@ -74,6 +80,7 @@ describe('email template standard handlers', () => {
|
|||||||
populate: [
|
populate: [
|
||||||
{ path: 'parent', strictPopulate: false },
|
{ path: 'parent', strictPopulate: false },
|
||||||
ATTACHMENT_POPULATE,
|
ATTACHMENT_POPULATE,
|
||||||
|
RESOURCE_POPULATE,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -115,7 +122,11 @@ describe('email template standard handlers', () => {
|
|||||||
fileName: 'Terms',
|
fileName: 'Terms',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
req.body = { name: 'Invoice', attachments };
|
req.body = {
|
||||||
|
name: 'Invoice',
|
||||||
|
attachments,
|
||||||
|
resources: [{ name: ' Main Logo ', file: { _id: 'resource-file-1' } }],
|
||||||
|
};
|
||||||
newObject.mockResolvedValue({ _id: 'template-1' });
|
newObject.mockResolvedValue({ _id: 'template-1' });
|
||||||
editObject.mockResolvedValue({ _id: 'template-1' });
|
editObject.mockResolvedValue({ _id: 'template-1' });
|
||||||
|
|
||||||
@ -140,6 +151,7 @@ describe('email template standard handlers', () => {
|
|||||||
fileType: undefined,
|
fileType: undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
resources: [{ _id: undefined, name: 'main-logo', file: 'resource-file-1' }],
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -168,4 +180,21 @@ describe('email template standard handlers', () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns resource suggestions with intellisense results', async () => {
|
||||||
|
req.params.id = 'template-1';
|
||||||
|
templateManager.collectTemplateIntellisense.mockResolvedValue({
|
||||||
|
intellisense: { $type: 'object', properties: {} },
|
||||||
|
resources: [{ name: 'main-logo', file: { _id: 'file-1' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await intellisenseEmailTemplateRouteHandler(req, res);
|
||||||
|
|
||||||
|
expect(res.send).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
intellisense: expect.any(Object),
|
||||||
|
resources: [expect.objectContaining({ name: 'main-logo' })],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,6 +24,10 @@ import {
|
|||||||
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS,
|
DOCUMENT_TEMPLATE_RENDER_TTL_SECONDS,
|
||||||
} from '../../templates/templatemanager.js';
|
} from '../../templates/templatemanager.js';
|
||||||
import { formatTemplateContent } from '../../templates/templateformatter.js';
|
import { formatTemplateContent } from '../../templates/templateformatter.js';
|
||||||
|
import {
|
||||||
|
normalizeTemplateResources,
|
||||||
|
validateTemplateResources,
|
||||||
|
} from '../../templates/templateresources.js';
|
||||||
const logger = log4js.getLogger('Document Templates');
|
const logger = log4js.getLogger('Document Templates');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
|
|
||||||
@ -73,6 +77,7 @@ export const listDocumentTemplatesRouteHandler = async (
|
|||||||
{ path: 'parent' },
|
{ path: 'parent' },
|
||||||
{ path: 'documentPrinters', strictPopulate: false },
|
{ path: 'documentPrinters', strictPopulate: false },
|
||||||
{ path: 'referencedTemplates', strictPopulate: false },
|
{ path: 'referencedTemplates', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -99,7 +104,7 @@ export const listDocumentTemplatesByPropertiesRouteHandler = async (
|
|||||||
model: documentTemplateModel,
|
model: documentTemplateModel,
|
||||||
properties,
|
properties,
|
||||||
filter,
|
filter,
|
||||||
populate: ['documentSize'],
|
populate: ['documentSize', { path: 'resources.file', strictPopulate: false }],
|
||||||
masterFilter,
|
masterFilter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -147,6 +152,7 @@ export const getDocumentTemplateRouteHandler = async (req, res) => {
|
|||||||
{ path: 'documentPrinters', strictPopulate: false },
|
{ path: 'documentPrinters', strictPopulate: false },
|
||||||
{ path: 'referencedTemplates', strictPopulate: false },
|
{ path: 'referencedTemplates', strictPopulate: false },
|
||||||
{ path: 'testObject', strictPopulate: false },
|
{ path: 'testObject', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
@ -172,6 +178,13 @@ export const editDocumentTemplateRouteHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
formattedContent = formatResult.content;
|
formattedContent = formatResult.content;
|
||||||
}
|
}
|
||||||
|
const resources = normalizeTemplateResources(req.body.resources);
|
||||||
|
if (resources !== undefined && !validateTemplateResources(resources)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Template resources require unique normalized names and files.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@ -185,6 +198,7 @@ export const editDocumentTemplateRouteHandler = async (req, res) => {
|
|||||||
documentPrinters: req.body.documentPrinters,
|
documentPrinters: req.body.documentPrinters,
|
||||||
content: formattedContent,
|
content: formattedContent,
|
||||||
testObject: req.body.testObject?._id || req.body.testObject,
|
testObject: req.body.testObject?._id || req.body.testObject,
|
||||||
|
resources,
|
||||||
};
|
};
|
||||||
// Create audit log before updating
|
// Create audit log before updating
|
||||||
const result = await editObject({
|
const result = await editObject({
|
||||||
@ -198,12 +212,13 @@ export const editDocumentTemplateRouteHandler = async (req, res) => {
|
|||||||
{ path: 'documentPrinters', strictPopulate: false },
|
{ path: 'documentPrinters', strictPopulate: false },
|
||||||
{ path: 'referencedTemplates', strictPopulate: false },
|
{ path: 'referencedTemplates', strictPopulate: false },
|
||||||
{ path: 'testObject', strictPopulate: false },
|
{ path: 'testObject', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
logger.error('Error editing document template:', result.error);
|
logger.error('Error editing document template:', result.error);
|
||||||
res.status(result).send(result);
|
res.status(result.code || 500).send(result);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -213,6 +228,13 @@ export const editDocumentTemplateRouteHandler = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const newDocumentTemplateRouteHandler = async (req, res) => {
|
export const newDocumentTemplateRouteHandler = async (req, res) => {
|
||||||
|
const resources = normalizeTemplateResources(req.body.resources);
|
||||||
|
if (resources !== undefined && !validateTemplateResources(resources)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Template resources require unique normalized names and files.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
const newData = {
|
const newData = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
name: req.body.name,
|
name: req.body.name,
|
||||||
@ -224,6 +246,7 @@ export const newDocumentTemplateRouteHandler = async (req, res) => {
|
|||||||
documentSize: req.body.documentSize,
|
documentSize: req.body.documentSize,
|
||||||
documentPrinters: req.body.documentPrinters,
|
documentPrinters: req.body.documentPrinters,
|
||||||
content: req.body.content,
|
content: req.body.content,
|
||||||
|
resources,
|
||||||
};
|
};
|
||||||
const result = await newObject({
|
const result = await newObject({
|
||||||
model: documentTemplateModel,
|
model: documentTemplateModel,
|
||||||
@ -237,7 +260,17 @@ export const newDocumentTemplateRouteHandler = async (req, res) => {
|
|||||||
|
|
||||||
logger.debug(`New document template with ID: ${result._id}`);
|
logger.debug(`New document template with ID: ${result._id}`);
|
||||||
|
|
||||||
res.send(result);
|
const populated = await getObject({
|
||||||
|
model: documentTemplateModel,
|
||||||
|
id: result._id,
|
||||||
|
populate: [
|
||||||
|
{ path: 'documentSize' },
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'documentPrinters', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
res.send(populated && !populated.error ? populated : result);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteDocumentTemplateByFilterRouteHandler = async (
|
export const deleteDocumentTemplateByFilterRouteHandler = async (
|
||||||
@ -407,10 +440,7 @@ function sendRenderedDownload(res, result, filename) {
|
|||||||
if (buffers.length === 1) {
|
if (buffers.length === 1) {
|
||||||
const buffer = buffers[0];
|
const buffer = buffers[0];
|
||||||
res.set('Content-Type', result.mime);
|
res.set('Content-Type', result.mime);
|
||||||
res.set(
|
res.set('Content-Disposition', `attachment; filename="${filename}.${result.extension}"`);
|
||||||
'Content-Disposition',
|
|
||||||
`attachment; filename="${filename}.${result.extension}"`
|
|
||||||
);
|
|
||||||
res.set('Content-Length', String(buffer.length));
|
res.set('Content-Length', String(buffer.length));
|
||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
}
|
}
|
||||||
@ -498,4 +528,3 @@ export const downloadDocumentTemplateRouteHandler = async (req, res) => {
|
|||||||
|
|
||||||
return sendRenderedDownload(res, result, filename);
|
return sendRenderedDownload(res, result, filename);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,10 @@ import {
|
|||||||
} from '../../database/database.js';
|
} from '../../database/database.js';
|
||||||
import { templateManager } from '../../templates/templatemanager.js';
|
import { templateManager } from '../../templates/templatemanager.js';
|
||||||
import { formatTemplateContent } from '../../templates/templateformatter.js';
|
import { formatTemplateContent } from '../../templates/templateformatter.js';
|
||||||
|
import {
|
||||||
|
normalizeTemplateResources,
|
||||||
|
validateTemplateResources,
|
||||||
|
} from '../../templates/templateresources.js';
|
||||||
|
|
||||||
const logger = log4js.getLogger('Email Templates');
|
const logger = log4js.getLogger('Email Templates');
|
||||||
logger.level = config.server.logLevel;
|
logger.level = config.server.logLevel;
|
||||||
@ -25,6 +29,7 @@ const populate = [
|
|||||||
{ path: 'parent', strictPopulate: false },
|
{ path: 'parent', strictPopulate: false },
|
||||||
{ path: 'referencedTemplates', strictPopulate: false },
|
{ path: 'referencedTemplates', strictPopulate: false },
|
||||||
{ path: 'attachments.file', strictPopulate: false },
|
{ path: 'attachments.file', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const normalizeAttachments = (attachments) =>
|
const normalizeAttachments = (attachments) =>
|
||||||
@ -84,6 +89,7 @@ export const listEmailTemplatesByPropertiesRouteHandler = async (
|
|||||||
populate: [
|
populate: [
|
||||||
{ path: 'parent', strictPopulate: false },
|
{ path: 'parent', strictPopulate: false },
|
||||||
{ path: 'attachments.file', strictPopulate: false },
|
{ path: 'attachments.file', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -149,6 +155,13 @@ export const editEmailTemplateRouteHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
content = formatted.content;
|
content = formatted.content;
|
||||||
}
|
}
|
||||||
|
const resources = normalizeTemplateResources(req.body.resources);
|
||||||
|
if (resources !== undefined && !validateTemplateResources(resources)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Template resources require unique normalized names and files.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updateData = {
|
const updateData = {
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@ -162,6 +175,7 @@ export const editEmailTemplateRouteHandler = async (req, res) => {
|
|||||||
content,
|
content,
|
||||||
testObject: req.body.testObject?._id ?? req.body.testObject,
|
testObject: req.body.testObject?._id ?? req.body.testObject,
|
||||||
attachments: normalizeAttachments(req.body.attachments),
|
attachments: normalizeAttachments(req.body.attachments),
|
||||||
|
resources,
|
||||||
};
|
};
|
||||||
const result = await editObject({
|
const result = await editObject({
|
||||||
model: emailTemplateModel,
|
model: emailTemplateModel,
|
||||||
@ -192,6 +206,13 @@ export const newEmailTemplateRouteHandler = async (req, res) => {
|
|||||||
}
|
}
|
||||||
content = formatted.content;
|
content = formatted.content;
|
||||||
}
|
}
|
||||||
|
const resources = normalizeTemplateResources(req.body.resources);
|
||||||
|
if (resources !== undefined && !validateTemplateResources(resources)) {
|
||||||
|
return res.status(400).send({
|
||||||
|
error: 'Template resources require unique normalized names and files.',
|
||||||
|
code: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const newData = {
|
const newData = {
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@ -206,6 +227,7 @@ export const newEmailTemplateRouteHandler = async (req, res) => {
|
|||||||
content,
|
content,
|
||||||
testObject: req.body.testObject?._id ?? req.body.testObject,
|
testObject: req.body.testObject?._id ?? req.body.testObject,
|
||||||
attachments: normalizeAttachments(req.body.attachments),
|
attachments: normalizeAttachments(req.body.attachments),
|
||||||
|
resources,
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await newObject({
|
const result = await newObject({
|
||||||
@ -220,7 +242,12 @@ export const newEmailTemplateRouteHandler = async (req, res) => {
|
|||||||
|
|
||||||
logger.debug(`New email template with ID: ${result._id}`);
|
logger.debug(`New email template with ID: ${result._id}`);
|
||||||
|
|
||||||
res.send(result);
|
const populated = await getObject({
|
||||||
|
model: emailTemplateModel,
|
||||||
|
id: result._id,
|
||||||
|
populate,
|
||||||
|
});
|
||||||
|
res.send(populated && !populated.error ? populated : result);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteEmailTemplateByFilterRouteHandler = async (
|
export const deleteEmailTemplateByFilterRouteHandler = async (
|
||||||
|
|||||||
87
src/services/misc/__tests__/templateresources.test.js
Normal file
87
src/services/misc/__tests__/templateresources.test.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
const getAndDeleteKey = jest.fn();
|
||||||
|
const findById = jest.fn();
|
||||||
|
const downloadFile = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../../database/redis.js', () => ({
|
||||||
|
redisServer: { getAndDeleteKey },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../../database/ceph.js', () => ({
|
||||||
|
BUCKETS: { FILES: 'files' },
|
||||||
|
downloadFile,
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../../database/schemas/management/file.schema.js', () => ({
|
||||||
|
fileModel: { findById },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../../templates/templateresources.js', () => ({
|
||||||
|
TEMPLATE_RESOURCE_KEY_PREFIX: 'templateresources:',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { getTemplateResourceRouteHandler } = await import('../templateresources.js');
|
||||||
|
|
||||||
|
function response() {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.send = jest.fn().mockReturnValue(res);
|
||||||
|
res.set = jest.fn().mockReturnValue(res);
|
||||||
|
res.destroy = jest.fn();
|
||||||
|
res.headersSent = false;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('template resource route', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it.each(['bad', '', undefined])('returns 404 for malformed token %s', async (token) => {
|
||||||
|
const res = response();
|
||||||
|
await getTemplateResourceRouteHandler({ params: { token } }, res);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(getAndDeleteKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('atomically consumes the token and streams the stored file inline', async () => {
|
||||||
|
const token = 'a'.repeat(64);
|
||||||
|
const fileId = '507f1f77bcf86cd799439011';
|
||||||
|
const body = Buffer.from('image');
|
||||||
|
getAndDeleteKey.mockResolvedValue({ fileId });
|
||||||
|
findById.mockReturnValue({
|
||||||
|
lean: jest.fn().mockResolvedValue({
|
||||||
|
_id: fileId,
|
||||||
|
name: 'logo',
|
||||||
|
extension: '.png',
|
||||||
|
type: 'image/png',
|
||||||
|
size: body.length,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
downloadFile.mockResolvedValue(body);
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
await getTemplateResourceRouteHandler({ params: { token } }, res);
|
||||||
|
|
||||||
|
expect(getAndDeleteKey).toHaveBeenCalledWith(`templateresources:${token}`);
|
||||||
|
expect(downloadFile).toHaveBeenCalledWith('files', `files/${fileId}.png`);
|
||||||
|
expect(res.set).toHaveBeenCalledWith(
|
||||||
|
'Content-Disposition',
|
||||||
|
'inline; filename="logo.png"'
|
||||||
|
);
|
||||||
|
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'no-store, no-transform');
|
||||||
|
expect(res.set).toHaveBeenCalledWith('Content-Type', 'image/png');
|
||||||
|
expect(res.set).toHaveBeenCalledWith('Content-Length', String(body.length));
|
||||||
|
expect(res.send).toHaveBeenCalledWith(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the same 404 for missing, used, expired, or deleted resources', async () => {
|
||||||
|
getAndDeleteKey.mockResolvedValue(null);
|
||||||
|
const res = response();
|
||||||
|
await getTemplateResourceRouteHandler({ params: { token: 'b'.repeat(64) } }, res);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
|
||||||
|
getAndDeleteKey.mockResolvedValue({ fileId: '507f1f77bcf86cd799439011' });
|
||||||
|
findById.mockReturnValue({ lean: jest.fn().mockResolvedValue(null) });
|
||||||
|
const deletedRes = response();
|
||||||
|
await getTemplateResourceRouteHandler({ params: { token: 'c'.repeat(64) } }, deletedRes);
|
||||||
|
expect(deletedRes.status).toHaveBeenCalledWith(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
53
src/services/misc/templateresources.js
Normal file
53
src/services/misc/templateresources.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import mongoose from 'mongoose';
|
||||||
|
import { redisServer } from '../../database/redis.js';
|
||||||
|
import { downloadFile, BUCKETS } from '../../database/ceph.js';
|
||||||
|
import { fileModel } from '../../database/schemas/management/file.schema.js';
|
||||||
|
import { TEMPLATE_RESOURCE_KEY_PREFIX } from '../../templates/templateresources.js';
|
||||||
|
|
||||||
|
const TOKEN_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
|
|
||||||
|
function notFound(res) {
|
||||||
|
return res.status(404).send({ error: 'Template resource not found.', code: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateResourceRouteHandler(req, res) {
|
||||||
|
const token = req.params?.token;
|
||||||
|
if (!TOKEN_PATTERN.test(token || '')) return notFound(res);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await redisServer.getAndDeleteKey(`${TEMPLATE_RESOURCE_KEY_PREFIX}${token}`);
|
||||||
|
if (!payload?.fileId || !mongoose.Types.ObjectId.isValid(payload.fileId)) {
|
||||||
|
return notFound(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await fileModel.findById(payload.fileId).lean();
|
||||||
|
if (!file?._id || typeof file.extension !== 'string') return notFound(res);
|
||||||
|
|
||||||
|
const body = await downloadFile(BUCKETS.FILES, `files/${file._id}${file.extension}`);
|
||||||
|
if (!body || (typeof body.pipe !== 'function' && !Buffer.isBuffer(body))) {
|
||||||
|
return notFound(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Content-Type', file.type);
|
||||||
|
const filename = `${file.name || 'resource'}${file.extension}`.replace(/["\r\n]/g, '_');
|
||||||
|
res.set('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
res.set('Cache-Control', 'no-store, no-transform');
|
||||||
|
if (Number.isFinite(file.size) && file.size >= 0) {
|
||||||
|
res.set('Content-Length', String(file.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(body)) return res.send(body);
|
||||||
|
body.on('error', () => {
|
||||||
|
if (!res.headersSent) notFound(res);
|
||||||
|
else res.destroy();
|
||||||
|
});
|
||||||
|
res.on('close', () => {
|
||||||
|
if (typeof body.destroy === 'function') body.destroy();
|
||||||
|
});
|
||||||
|
body.pipe(res);
|
||||||
|
} catch {
|
||||||
|
return notFound(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TOKEN_PATTERN };
|
||||||
@ -106,6 +106,9 @@ jest.unstable_mockModule('log4js', () => ({
|
|||||||
|
|
||||||
jest.unstable_mockModule('../../config.js', () => ({
|
jest.unstable_mockModule('../../config.js', () => ({
|
||||||
default: {
|
default: {
|
||||||
|
app: {
|
||||||
|
urlApi: 'https://api.example.test',
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
},
|
},
|
||||||
@ -244,6 +247,58 @@ describe('TemplateManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('renderTemplate', () => {
|
describe('renderTemplate', () => {
|
||||||
|
it('replaces each valid resource occurrence before rendering', async () => {
|
||||||
|
getObject.mockResolvedValue({
|
||||||
|
documentSize: { width: 100, height: 100, infiniteHeight: false },
|
||||||
|
global: true,
|
||||||
|
resources: [{ name: 'logo', file: { _id: 'file-1' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await templateManager.renderTemplate(
|
||||||
|
'temp-id',
|
||||||
|
'<Image src="@[logo]" /><Image src="@[logo]" />'
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderedSource = ejs.render.mock.calls[0][0];
|
||||||
|
const tokens = [...renderedSource.matchAll(/template-resources\/([a-f0-9]{64})/g)];
|
||||||
|
expect(tokens).toHaveLength(2);
|
||||||
|
expect(tokens[0][1]).not.toBe(tokens[1][1]);
|
||||||
|
expect(redisServer.setKey).toHaveBeenCalledTimes(2);
|
||||||
|
expect(redisServer.setKey).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^templateresources:[a-f0-9]{64}$/),
|
||||||
|
{ fileId: 'file-1' },
|
||||||
|
3
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves parent resources from the parent owner only', async () => {
|
||||||
|
getObject.mockResolvedValue({
|
||||||
|
_reference: 'CHILD',
|
||||||
|
documentSize: { width: 100, height: 100, infiniteHeight: false },
|
||||||
|
global: true,
|
||||||
|
content: '<Image src="@[logo]" />',
|
||||||
|
resources: [{ name: 'logo', file: 'child-file' }],
|
||||||
|
parent: {
|
||||||
|
_reference: 'PARENT',
|
||||||
|
content: '<Image src="@[logo]" /><%- content %>',
|
||||||
|
resources: [{ name: 'logo', file: 'parent-file' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await templateManager.renderTemplate('temp-id');
|
||||||
|
|
||||||
|
expect(redisServer.setKey).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
{ fileId: 'child-file' },
|
||||||
|
3
|
||||||
|
);
|
||||||
|
expect(redisServer.setKey).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
{ fileId: 'parent-file' },
|
||||||
|
3
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should render a template successfully', async () => {
|
it('should render a template successfully', async () => {
|
||||||
const mockTemplate = {
|
const mockTemplate = {
|
||||||
documentSize: { width: 100, height: 100, infiniteHeight: false },
|
documentSize: { width: 100, height: 100, infiniteHeight: false },
|
||||||
@ -281,12 +336,7 @@ describe('TemplateManager', () => {
|
|||||||
objectType: 'printer',
|
objectType: 'printer',
|
||||||
});
|
});
|
||||||
|
|
||||||
await templateManager.renderTemplate(
|
await templateManager.renderTemplate('temp-id', 'some content', { name: 'Test' }, 2.5);
|
||||||
'temp-id',
|
|
||||||
'some content',
|
|
||||||
{ name: 'Test' },
|
|
||||||
2.5
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ejs.render).toHaveBeenCalledWith(
|
expect(ejs.render).toHaveBeenCalledWith(
|
||||||
'<html><%- content %></html>',
|
'<html><%- content %></html>',
|
||||||
@ -626,6 +676,7 @@ describe('TemplateManager', () => {
|
|||||||
getObject.mockResolvedValue({
|
getObject.mockResolvedValue({
|
||||||
global: true,
|
global: true,
|
||||||
content: '<% const broken = %>',
|
content: '<% const broken = %>',
|
||||||
|
resources: [{ name: 'main-logo', file: { _id: 'file-1', type: 'image/png' } }],
|
||||||
});
|
});
|
||||||
ejs.compile.mockImplementationOnce(() => {
|
ejs.compile.mockImplementationOnce(() => {
|
||||||
throw new SyntaxError('Unexpected token');
|
throw new SyntaxError('Unexpected token');
|
||||||
@ -639,7 +690,9 @@ describe('TemplateManager', () => {
|
|||||||
'documentTemplate'
|
'documentTemplate'
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({});
|
expect(result).toEqual({
|
||||||
|
resources: ['main-logo'],
|
||||||
|
});
|
||||||
expect(result).not.toHaveProperty('intellisense');
|
expect(result).not.toHaveProperty('intellisense');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
81
src/templates/__tests__/templateresources.test.js
Normal file
81
src/templates/__tests__/templateresources.test.js
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { jest } from '@jest/globals';
|
||||||
|
|
||||||
|
const setKey = jest.fn();
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../../database/redis.js', () => ({
|
||||||
|
redisServer: { setKey },
|
||||||
|
}));
|
||||||
|
jest.unstable_mockModule('../../config.js', () => ({
|
||||||
|
default: { app: { urlApi: 'https://api.example.test' } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
createTemplateResourceUrl,
|
||||||
|
normalizeTemplateResourceName,
|
||||||
|
normalizeTemplateResources,
|
||||||
|
preprocessTemplateResources,
|
||||||
|
validateTemplateResources,
|
||||||
|
TEMPLATE_RESOURCE_KEY_PREFIX,
|
||||||
|
TEMPLATE_RESOURCE_TTL_SECONDS,
|
||||||
|
} = await import('../templateresources.js');
|
||||||
|
|
||||||
|
describe('template resources', () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
it('normalizes names and populated file values', () => {
|
||||||
|
expect(normalizeTemplateResourceName(' Main Logo (Dark) ')).toBe('main-logo-dark');
|
||||||
|
expect(normalizeTemplateResources([{ name: ' Main Logo ', file: { _id: 'file-1' } }])).toEqual([
|
||||||
|
{ _id: undefined, name: 'main-logo', file: 'file-1' },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
validateTemplateResources([
|
||||||
|
{ name: 'main-logo', file: 'file-1' },
|
||||||
|
{ name: 'main-logo', file: 'file-2' },
|
||||||
|
])
|
||||||
|
).toBe(false);
|
||||||
|
expect(validateTemplateResources([{ name: 'Main Logo', file: 'file-1' }])).toBe(false);
|
||||||
|
expect(validateTemplateResources([{ name: 'main-logo' }])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only replaces references in XML attributes and EJS JavaScript strings', async () => {
|
||||||
|
const source = [
|
||||||
|
'<Image src="@[logo]" />',
|
||||||
|
'<Text>@[logo]</Text>',
|
||||||
|
"<% const image = '@[logo]'; const outside = value@[logo] %>",
|
||||||
|
'<%= `@[logo]` %>',
|
||||||
|
'<%= `${value@[logo]}-${"@[logo]"}` %>',
|
||||||
|
'<Image src="<%= path %>/@[logo]" />',
|
||||||
|
'<Image src="@[unknown]" />',
|
||||||
|
].join('');
|
||||||
|
let occurrence = 0;
|
||||||
|
const result = await preprocessTemplateResources(
|
||||||
|
source,
|
||||||
|
[{ name: 'logo', file: 'file-1' }],
|
||||||
|
async () => `temp-${++occurrence}`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain('<Image src="temp-1" />');
|
||||||
|
expect(result).toContain('<Text>@[logo]</Text>');
|
||||||
|
expect(result).toContain("const image = 'temp-2'");
|
||||||
|
expect(result).toContain('value@[logo]');
|
||||||
|
expect(result).toContain('<%= `temp-3` %>');
|
||||||
|
expect(result).toContain('`${value@[logo]}-${"temp-4"}`');
|
||||||
|
expect(result).toContain('<Image src="<%= path %>/temp-5" />');
|
||||||
|
expect(result).toContain('<Image src="@[unknown]" />');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a crypto-random one-time token with an exact three-second TTL', async () => {
|
||||||
|
const first = await createTemplateResourceUrl('507f1f77bcf86cd799439011');
|
||||||
|
const second = await createTemplateResourceUrl('507f1f77bcf86cd799439011');
|
||||||
|
|
||||||
|
expect(first).toMatch(/^https:\/\/api\.example\.test\/template-resources\/[a-f0-9]{64}$/);
|
||||||
|
expect(second).not.toBe(first);
|
||||||
|
expect(setKey).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
`${TEMPLATE_RESOURCE_KEY_PREFIX}${first.split('/').at(-1)}`,
|
||||||
|
{ fileId: '507f1f77bcf86cd799439011' },
|
||||||
|
TEMPLATE_RESOURCE_TTL_SECONDS
|
||||||
|
);
|
||||||
|
expect(TEMPLATE_RESOURCE_TTL_SECONDS).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -24,6 +24,7 @@ import {
|
|||||||
} from './templateintellisense.js';
|
} from './templateintellisense.js';
|
||||||
import { eventManager } from '../events/eventmanager.js';
|
import { eventManager } from '../events/eventmanager.js';
|
||||||
import { redisServer } from '../database/redis.js';
|
import { redisServer } from '../database/redis.js';
|
||||||
|
import { preprocessTemplateResources } from './templateresources.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@ -467,6 +468,12 @@ function omitTemplateFc(data) {
|
|||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTemplateResourceSuggestions(resources) {
|
||||||
|
return (resources || [])
|
||||||
|
.map((resource) => resource?.name)
|
||||||
|
.filter((name) => typeof name === 'string' && name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function extractObjectId(value) {
|
function extractObjectId(value) {
|
||||||
if (value == null || value === '') {
|
if (value == null || value === '') {
|
||||||
return null;
|
return null;
|
||||||
@ -579,7 +586,11 @@ export class TemplateManager {
|
|||||||
const results = await listObjects({
|
const results = await listObjects({
|
||||||
model: documentTemplateModel,
|
model: documentTemplateModel,
|
||||||
filter: { _reference: strippedReference },
|
filter: { _reference: strippedReference },
|
||||||
populate: [{ path: 'parent', strictPopulate: false }, { path: 'documentSize' }],
|
populate: [
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'documentSize' },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
pagination: false,
|
pagination: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -626,7 +637,11 @@ export class TemplateManager {
|
|||||||
|
|
||||||
// Return a content fragment only. Page shells and previewPaginationScript
|
// Return a content fragment only. Page shells and previewPaginationScript
|
||||||
// already exist on the host document.
|
// already exist on the host document.
|
||||||
let nestedContent = await ejs.render(nestedTemplate.content, nestedData, renderOptions);
|
const nestedSource = await preprocessTemplateResources(
|
||||||
|
nestedTemplate.content,
|
||||||
|
nestedTemplate.resources
|
||||||
|
);
|
||||||
|
let nestedContent = await ejs.render(nestedSource, nestedData, renderOptions);
|
||||||
|
|
||||||
let parentTemplate = nestedTemplate.parent;
|
let parentTemplate = nestedTemplate.parent;
|
||||||
if (parentTemplate != undefined) {
|
if (parentTemplate != undefined) {
|
||||||
@ -638,7 +653,11 @@ export class TemplateManager {
|
|||||||
parentTemplate = await getObject({
|
parentTemplate = await getObject({
|
||||||
model: documentTemplateModel,
|
model: documentTemplateModel,
|
||||||
id: parentTemplate._id || parentTemplate.id || parentTemplate,
|
id: parentTemplate._id || parentTemplate.id || parentTemplate,
|
||||||
populate: [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }],
|
populate: [
|
||||||
|
{ path: 'documentSize' },
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@ -653,7 +672,11 @@ export class TemplateManager {
|
|||||||
}
|
}
|
||||||
const parentData = { content: nestedContent };
|
const parentData = { content: nestedContent };
|
||||||
parentData.fc = this.createTemplateFc(() => parentData, renderOptions, nextVisited);
|
parentData.fc = this.createTemplateFc(() => parentData, renderOptions, nextVisited);
|
||||||
nestedContent = await ejs.render(parentTemplate.content, parentData, renderOptions);
|
const parentSource = await preprocessTemplateResources(
|
||||||
|
parentTemplate.content,
|
||||||
|
parentTemplate.resources
|
||||||
|
);
|
||||||
|
nestedContent = await ejs.render(parentSource, parentData, renderOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nestedContent;
|
return nestedContent;
|
||||||
@ -682,7 +705,10 @@ export class TemplateManager {
|
|||||||
const results = await listObjects({
|
const results = await listObjects({
|
||||||
model: emailTemplateModel,
|
model: emailTemplateModel,
|
||||||
filter: { _reference: strippedReference },
|
filter: { _reference: strippedReference },
|
||||||
populate: [{ path: 'parent', strictPopulate: false }],
|
populate: [
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
pagination: false,
|
pagination: false,
|
||||||
});
|
});
|
||||||
if (!Array.isArray(results) || results.length === 0) {
|
if (!Array.isArray(results) || results.length === 0) {
|
||||||
@ -708,7 +734,8 @@ export class TemplateManager {
|
|||||||
let data;
|
let data;
|
||||||
const fc = this.createEmailTemplateFc(() => data, renderOptions, nextVisited);
|
const fc = this.createEmailTemplateFc(() => data, renderOptions, nextVisited);
|
||||||
data = buildTemplateData(template, object, fc);
|
data = buildTemplateData(template, object, fc);
|
||||||
let rendered = await ejs.render(template.content || '', data, renderOptions);
|
const source = await preprocessTemplateResources(template.content || '', template.resources);
|
||||||
|
let rendered = await ejs.render(source, data, renderOptions);
|
||||||
|
|
||||||
let parent = template.parent;
|
let parent = template.parent;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
@ -716,7 +743,10 @@ export class TemplateManager {
|
|||||||
parent = await getObject({
|
parent = await getObject({
|
||||||
model: emailTemplateModel,
|
model: emailTemplateModel,
|
||||||
id: parent._id || parent.id || parent,
|
id: parent._id || parent.id || parent,
|
||||||
populate: [{ path: 'parent', strictPopulate: false }],
|
populate: [
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (parent?.error || typeof parent.content !== 'string') {
|
if (parent?.error || typeof parent.content !== 'string') {
|
||||||
@ -724,7 +754,8 @@ export class TemplateManager {
|
|||||||
}
|
}
|
||||||
const parentData = { content: rendered };
|
const parentData = { content: rendered };
|
||||||
parentData.fc = this.createEmailTemplateFc(() => parentData, renderOptions, nextVisited);
|
parentData.fc = this.createEmailTemplateFc(() => parentData, renderOptions, nextVisited);
|
||||||
rendered = await ejs.render(parent.content, parentData, renderOptions);
|
const parentSource = await preprocessTemplateResources(parent.content, parent.resources);
|
||||||
|
rendered = await ejs.render(parentSource, parentData, renderOptions);
|
||||||
}
|
}
|
||||||
return rendered;
|
return rendered;
|
||||||
}
|
}
|
||||||
@ -742,7 +773,10 @@ export class TemplateManager {
|
|||||||
const template = await getObject({
|
const template = await getObject({
|
||||||
model: emailTemplateModel,
|
model: emailTemplateModel,
|
||||||
id,
|
id,
|
||||||
populate: [{ path: 'parent', strictPopulate: false }],
|
populate: [
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
if (template?.error) return template;
|
if (template?.error) return template;
|
||||||
if (template?.active === false && preview !== true) {
|
if (template?.active === false && preview !== true) {
|
||||||
@ -757,7 +791,10 @@ export class TemplateManager {
|
|||||||
const fc = this.createEmailTemplateFc(() => templateData, renderOptions, visited);
|
const fc = this.createEmailTemplateFc(() => templateData, renderOptions, visited);
|
||||||
templateData = buildTemplateData(template, resolvedData, fc);
|
templateData = buildTemplateData(template, resolvedData, fc);
|
||||||
const subjectSource = typeof subject === 'string' ? subject : template.subject || '';
|
const subjectSource = typeof subject === 'string' ? subject : template.subject || '';
|
||||||
const contentSource = typeof content === 'string' ? content : template.content || '';
|
const contentSource = await preprocessTemplateResources(
|
||||||
|
typeof content === 'string' ? content : template.content || '',
|
||||||
|
template.resources
|
||||||
|
);
|
||||||
const renderedSubject = await ejs.render(subjectSource, templateData, renderOptions);
|
const renderedSubject = await ejs.render(subjectSource, templateData, renderOptions);
|
||||||
let renderedContent = await ejs.render(contentSource, templateData, renderOptions);
|
let renderedContent = await ejs.render(contentSource, templateData, renderOptions);
|
||||||
|
|
||||||
@ -767,6 +804,7 @@ export class TemplateManager {
|
|||||||
parent = await getObject({
|
parent = await getObject({
|
||||||
model: emailTemplateModel,
|
model: emailTemplateModel,
|
||||||
id: parent._id || parent.id || parent,
|
id: parent._id || parent.id || parent,
|
||||||
|
populate: [{ path: 'resources.file', strictPopulate: false }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (parent?.error || typeof parent.content !== 'string') {
|
if (parent?.error || typeof parent.content !== 'string') {
|
||||||
@ -777,7 +815,8 @@ export class TemplateManager {
|
|||||||
}
|
}
|
||||||
const parentData = { content: renderedContent };
|
const parentData = { content: renderedContent };
|
||||||
parentData.fc = this.createEmailTemplateFc(() => parentData, renderOptions, visited);
|
parentData.fc = this.createEmailTemplateFc(() => parentData, renderOptions, visited);
|
||||||
renderedContent = await ejs.render(parent.content, parentData, renderOptions);
|
const parentSource = await preprocessTemplateResources(parent.content, parent.resources);
|
||||||
|
renderedContent = await ejs.render(parentSource, parentData, renderOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformed = await transformCustomElements(renderedContent);
|
const transformed = await transformCustomElements(renderedContent);
|
||||||
@ -870,7 +909,11 @@ export class TemplateManager {
|
|||||||
const documentTemplate = await getObject({
|
const documentTemplate = await getObject({
|
||||||
model: documentTemplateModel,
|
model: documentTemplateModel,
|
||||||
id,
|
id,
|
||||||
populate: [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }],
|
populate: [
|
||||||
|
{ path: 'documentSize' },
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
if (documentTemplate == null || documentTemplate.error) {
|
if (documentTemplate == null || documentTemplate.error) {
|
||||||
@ -882,8 +925,10 @@ export class TemplateManager {
|
|||||||
return { error: 'Document template size not found.', code: 400 };
|
return { error: 'Document template size not found.', code: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateContentSource =
|
const templateContentSource = await preprocessTemplateResources(
|
||||||
content != null && typeof content === 'string' ? content : documentTemplate.content;
|
content != null && typeof content === 'string' ? content : documentTemplate.content,
|
||||||
|
documentTemplate.resources
|
||||||
|
);
|
||||||
|
|
||||||
if (templateContentSource == null || typeof templateContentSource !== 'string') {
|
if (templateContentSource == null || typeof templateContentSource !== 'string') {
|
||||||
return { error: 'Template content is required and must be a string.', code: 400 };
|
return { error: 'Template content is required and must be a string.', code: 400 };
|
||||||
@ -920,11 +965,7 @@ export class TemplateManager {
|
|||||||
templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited);
|
templateData.fc = this.createTemplateFc(() => templateData, defaultOptions, visited);
|
||||||
|
|
||||||
await report(0.16, 'Rendering template content...');
|
await report(0.16, 'Rendering template content...');
|
||||||
const templateContent = await ejs.render(
|
const templateContent = await ejs.render(templateContentSource, templateData, defaultOptions);
|
||||||
templateContentSource,
|
|
||||||
templateData,
|
|
||||||
defaultOptions
|
|
||||||
);
|
|
||||||
|
|
||||||
var templateWithParentContent;
|
var templateWithParentContent;
|
||||||
var parentTemplate = documentTemplate.parent;
|
var parentTemplate = documentTemplate.parent;
|
||||||
@ -934,7 +975,11 @@ export class TemplateManager {
|
|||||||
parentTemplate = await getObject({
|
parentTemplate = await getObject({
|
||||||
model: documentTemplateModel,
|
model: documentTemplateModel,
|
||||||
id: parentTemplate,
|
id: parentTemplate,
|
||||||
populate: [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }],
|
populate: [
|
||||||
|
{ path: 'documentSize' },
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@ -959,11 +1004,11 @@ export class TemplateManager {
|
|||||||
const parentData = { content: templateContent };
|
const parentData = { content: templateContent };
|
||||||
parentData.fc = this.createTemplateFc(() => parentData, defaultOptions, visited);
|
parentData.fc = this.createTemplateFc(() => parentData, defaultOptions, visited);
|
||||||
await report(0.22, 'Rendering parent template...');
|
await report(0.22, 'Rendering parent template...');
|
||||||
templateWithParentContent = await ejs.render(
|
const parentSource = await preprocessTemplateResources(
|
||||||
parentTemplate.content,
|
parentTemplate.content,
|
||||||
parentData,
|
parentTemplate.resources
|
||||||
defaultOptions
|
|
||||||
);
|
);
|
||||||
|
templateWithParentContent = await ejs.render(parentSource, parentData, defaultOptions);
|
||||||
} else {
|
} else {
|
||||||
templateWithParentContent = templateContent;
|
templateWithParentContent = templateContent;
|
||||||
}
|
}
|
||||||
@ -1265,8 +1310,15 @@ export class TemplateManager {
|
|||||||
model: isEmail ? emailTemplateModel : documentTemplateModel,
|
model: isEmail ? emailTemplateModel : documentTemplateModel,
|
||||||
id,
|
id,
|
||||||
populate: isEmail
|
populate: isEmail
|
||||||
? [{ path: 'parent', strictPopulate: false }]
|
? [
|
||||||
: [{ path: 'documentSize' }, { path: 'parent', strictPopulate: false }],
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ path: 'documentSize' },
|
||||||
|
{ path: 'parent', strictPopulate: false },
|
||||||
|
{ path: 'resources.file', strictPopulate: false },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
if (template == null || template.error) {
|
if (template == null || template.error) {
|
||||||
return { error: template?.error || 'Template not found.', code: 404 };
|
return { error: template?.error || 'Template not found.', code: 404 };
|
||||||
@ -1298,7 +1350,7 @@ export class TemplateManager {
|
|||||||
compiledTemplate = ejs.compile(session.content, renderOptions);
|
compiledTemplate = ejs.compile(session.content, renderOptions);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug('Skipping intellisense update due to compile error:', error.message);
|
logger.debug('Skipping intellisense update due to compile error:', error.message);
|
||||||
return {};
|
return { resources: getTemplateResourceSuggestions(template.resources) };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await compiledTemplate(session.data);
|
await compiledTemplate(session.data);
|
||||||
@ -1310,12 +1362,8 @@ export class TemplateManager {
|
|||||||
(key) => key !== 'fc' && key !== '__fcCapture'
|
(key) => key !== 'fc' && key !== '__fcCapture'
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
intellisense: filterIntellisenseToCursor(
|
intellisense: filterIntellisenseToCursor(session.collect(), source, cursor, alwaysNames),
|
||||||
session.collect(),
|
resources: getTemplateResourceSuggestions(template.resources),
|
||||||
source,
|
|
||||||
cursor,
|
|
||||||
alwaysNames
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('Error collecting template intellisense:', error.message);
|
logger.warn('Error collecting template intellisense:', error.message);
|
||||||
|
|||||||
211
src/templates/templateresources.js
Normal file
211
src/templates/templateresources.js
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import config from '../config.js';
|
||||||
|
import { redisServer } from '../database/redis.js';
|
||||||
|
|
||||||
|
const RESOURCE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
const TEMPLATE_RESOURCE_KEY_PREFIX = 'templateresources:';
|
||||||
|
const TEMPLATE_RESOURCE_TTL_SECONDS = 3;
|
||||||
|
const RESOURCE_REFERENCE_PATTERN = /@\[([a-z0-9]+(?:-[a-z0-9]+)*)\]/g;
|
||||||
|
|
||||||
|
export function normalizeTemplateResourceName(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTemplateResources(resources) {
|
||||||
|
if (resources === undefined) return undefined;
|
||||||
|
if (!Array.isArray(resources)) return [];
|
||||||
|
return resources.map((resource) => ({
|
||||||
|
_id: resource?._id,
|
||||||
|
name: normalizeTemplateResourceName(resource?.name),
|
||||||
|
file: resource?.file?._id ?? resource?.file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTemplateResources(resources) {
|
||||||
|
const names = new Set();
|
||||||
|
for (const resource of resources || []) {
|
||||||
|
if (
|
||||||
|
!RESOURCE_NAME_PATTERN.test(resource?.name || '') ||
|
||||||
|
resource?.file == null ||
|
||||||
|
resource.file === ''
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (names.has(resource.name)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
names.add(resource.name);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplateResourceUrl(file) {
|
||||||
|
const fileId = file?._id ?? file;
|
||||||
|
if (!fileId) return null;
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
await redisServer.setKey(
|
||||||
|
`${TEMPLATE_RESOURCE_KEY_PREFIX}${token}`,
|
||||||
|
{ fileId: String(fileId) },
|
||||||
|
TEMPLATE_RESOURCE_TTL_SECONDS
|
||||||
|
);
|
||||||
|
return `${config.app.urlApi}/template-resources/${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectResourceReferences(source) {
|
||||||
|
const references = [];
|
||||||
|
let xmlTag = false;
|
||||||
|
let xmlQuote = null;
|
||||||
|
|
||||||
|
const collectAt = (index) => {
|
||||||
|
if (source[index] !== '@' || source[index + 1] !== '[') return null;
|
||||||
|
RESOURCE_REFERENCE_PATTERN.lastIndex = index;
|
||||||
|
const match = RESOURCE_REFERENCE_PATTERN.exec(source);
|
||||||
|
if (match?.index === index) {
|
||||||
|
references.push({ start: index, end: RESOURCE_REFERENCE_PATTERN.lastIndex, name: match[1] });
|
||||||
|
return RESOURCE_REFERENCE_PATTERN.lastIndex;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipComment = (index) => {
|
||||||
|
if (source[index] === '/' && source[index + 1] === '/') {
|
||||||
|
const newline = source.indexOf('\n', index + 2);
|
||||||
|
return newline === -1 ? source.length : newline;
|
||||||
|
}
|
||||||
|
if (source[index] === '/' && source[index + 1] === '*') {
|
||||||
|
const close = source.indexOf('*/', index + 2);
|
||||||
|
return close === -1 ? source.length : close + 2;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanQuotedString = (start, quote) => {
|
||||||
|
let index = start + 1;
|
||||||
|
while (index < source.length) {
|
||||||
|
if (source[index] === '\\') {
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (source[index] === quote) return index + 1;
|
||||||
|
const referenceEnd = collectAt(index);
|
||||||
|
index = referenceEnd ?? index + 1;
|
||||||
|
}
|
||||||
|
return source.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanCode = (start, stopAtInterpolationEnd = false) => {
|
||||||
|
let index = start;
|
||||||
|
let braceDepth = stopAtInterpolationEnd ? 1 : 0;
|
||||||
|
|
||||||
|
while (index < source.length) {
|
||||||
|
if (!stopAtInterpolationEnd && source[index] === '%' && source[index + 1] === '>') {
|
||||||
|
return index + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentEnd = skipComment(index);
|
||||||
|
if (commentEnd != null) {
|
||||||
|
index = commentEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char = source[index];
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
index = scanQuotedString(index, char);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '`') {
|
||||||
|
index = scanTemplateString(index);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (stopAtInterpolationEnd && char === '{') {
|
||||||
|
braceDepth += 1;
|
||||||
|
} else if (stopAtInterpolationEnd && char === '}') {
|
||||||
|
braceDepth -= 1;
|
||||||
|
if (braceDepth === 0) return index + 1;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return source.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scanTemplateString = (start) => {
|
||||||
|
let index = start + 1;
|
||||||
|
while (index < source.length) {
|
||||||
|
if (source[index] === '\\') {
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (source[index] === '`') return index + 1;
|
||||||
|
if (source[index] === '$' && source[index + 1] === '{') {
|
||||||
|
index = scanCode(index + 2, true);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const referenceEnd = collectAt(index);
|
||||||
|
index = referenceEnd ?? index + 1;
|
||||||
|
}
|
||||||
|
return source.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
let index = 0;
|
||||||
|
while (index < source.length) {
|
||||||
|
const char = source[index];
|
||||||
|
|
||||||
|
if (char === '<' && source[index + 1] === '%') {
|
||||||
|
index = scanCode(index + 2);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!xmlQuote && char === '<') {
|
||||||
|
xmlTag = true;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!xmlQuote && char === '>') {
|
||||||
|
xmlTag = false;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (xmlTag && !xmlQuote && (char === '"' || char === "'")) {
|
||||||
|
xmlQuote = char;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (xmlQuote && char === xmlQuote) {
|
||||||
|
xmlQuote = null;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceEnd = xmlQuote ? collectAt(index) : null;
|
||||||
|
index = referenceEnd ?? index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return references;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function preprocessTemplateResources(
|
||||||
|
source,
|
||||||
|
resources,
|
||||||
|
createUrl = createTemplateResourceUrl
|
||||||
|
) {
|
||||||
|
if (typeof source !== 'string' || !source.includes('@[')) return source;
|
||||||
|
const resourceMap = new Map((resources || []).map((resource) => [resource.name, resource.file]));
|
||||||
|
const references = collectResourceReferences(source);
|
||||||
|
let output = '';
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const reference of references) {
|
||||||
|
const file = resourceMap.get(reference.name);
|
||||||
|
if (!file) continue;
|
||||||
|
const url = await createUrl(file);
|
||||||
|
if (!url) continue;
|
||||||
|
output += source.slice(cursor, reference.start) + url;
|
||||||
|
cursor = reference.end;
|
||||||
|
}
|
||||||
|
return output + source.slice(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RESOURCE_NAME_PATTERN, TEMPLATE_RESOURCE_KEY_PREFIX, TEMPLATE_RESOURCE_TTL_SECONDS };
|
||||||
Loading…
x
Reference in New Issue
Block a user