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

This update introduces a new route for formatting document templates, utilizing the `formatTemplateContent` function to beautify HTML and JavaScript within templates. The `formatDocumentTemplateRouteHandler` is added to handle formatting requests, and the content is processed to maintain EJS blocks. Additionally, a new `templateformatter.js` file is created, containing the logic for formatting templates, along with corresponding tests to ensure functionality. This enhancement improves the usability and presentation of document templates in the application.
This commit is contained in:
Tom Butcher 2026-08-19 20:02:39 +01:00
parent 8af8c3364f
commit f467b1be33
6 changed files with 284 additions and 3 deletions

View File

@ -23,6 +23,7 @@
"express-session": "^1.18.2",
"i": "^0.3.7",
"ioredis": "^6.0.0",
"js-beautify": "^2.0.3",
"jsonwebtoken": "^9.0.2",
"keycloak-connect": "^26.1.1",
"lodash": "^4.17.23",

117
pnpm-lock.yaml generated
View File

@ -65,6 +65,9 @@ importers:
ioredis:
specifier: ^6.0.0
version: 6.0.0
js-beautify:
specifier: ^2.0.3
version: 2.0.3
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.3
@ -1425,6 +1428,9 @@ packages:
'@one-ini/wasm@0.1.1':
resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==}
'@one-ini/wasm@0.2.1':
resolution: {integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@ -1849,6 +1855,10 @@ packages:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
abbrev@5.0.0:
resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@ -2057,6 +2067,10 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
bare-events@2.8.2:
resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==}
peerDependencies:
@ -2154,6 +2168,10 @@ packages:
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@ -2312,6 +2330,10 @@ packages:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@6.2.1:
resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==}
engines: {node: '>= 6'}
@ -2574,6 +2596,11 @@ packages:
engines: {node: '>=14'}
hasBin: true
editorconfig@3.0.2:
resolution: {integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==}
engines: {node: '>=20'}
hasBin: true
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -3107,8 +3134,13 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
@ -3608,10 +3640,18 @@ packages:
engines: {node: '>=14'}
hasBin: true
js-beautify@2.0.3:
resolution: {integrity: sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==}
engines: {node: '>=14'}
hasBin: true
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-cookie@3.0.8:
resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@ -3805,6 +3845,10 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -3884,6 +3928,10 @@ packages:
minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==}
minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@ -3906,6 +3954,10 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
@ -4068,6 +4120,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
nopt@10.0.1:
resolution: {integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
hasBin: true
nopt@7.2.1:
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@ -4231,6 +4288,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
@ -7054,6 +7115,8 @@ snapshots:
'@one-ini/wasm@0.1.1': {}
'@one-ini/wasm@0.2.1': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
@ -7577,6 +7640,8 @@ snapshots:
abbrev@2.0.0: {}
abbrev@5.0.0: {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@ -7883,6 +7948,8 @@ snapshots:
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
bare-events@2.8.2: {}
bare-fs@4.5.5:
@ -7978,6 +8045,10 @@ snapshots:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.9:
dependencies:
balanced-match: 4.0.4
braces@3.0.3:
dependencies:
fill-range: 7.1.1
@ -8138,6 +8209,8 @@ snapshots:
commander@10.0.1: {}
commander@14.0.3: {}
commander@6.2.1: {}
commondir@1.0.1: {}
@ -8374,6 +8447,13 @@ snapshots:
minimatch: 9.0.1
semver: 7.7.4
editorconfig@3.0.2:
dependencies:
'@one-ini/wasm': 0.2.1
commander: 14.0.3
minimatch: 10.2.6
semver: 7.7.4
ee-first@1.1.1: {}
ejs@3.1.10:
@ -8542,7 +8622,7 @@ snapshots:
eslint: 8.57.1
eslint-plugin-react: 7.37.5(eslint@8.57.1)
eslint-config-standard@17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1):
eslint-config-standard@17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@9.39.2))(eslint-plugin-promise@6.6.0(eslint@9.39.2))(eslint@8.57.1):
dependencies:
eslint: 8.57.1
eslint-plugin-import: 2.32.0(eslint@8.57.1)
@ -9132,6 +9212,12 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@13.0.6:
dependencies:
minimatch: 10.2.6
minipass: 7.1.3
path-scurry: 2.0.2
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@ -9862,8 +9948,18 @@ snapshots:
js-cookie: 3.0.5
nopt: 7.2.1
js-beautify@2.0.3:
dependencies:
config-chain: 1.1.13
editorconfig: 3.0.2
glob: 13.0.6
js-cookie: 3.0.8
nopt: 10.0.1
js-cookie@3.0.5: {}
js-cookie@3.0.8: {}
js-tokens@4.0.0: {}
js-yaml@3.14.2:
@ -10063,6 +10159,8 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.5.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -10121,6 +10219,10 @@ snapshots:
minimalistic-crypto-utils@1.0.1: {}
minimatch@10.2.6:
dependencies:
brace-expansion: 5.0.9
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@ -10141,6 +10243,8 @@ snapshots:
minipass@7.1.2: {}
minipass@7.1.3: {}
mitt@3.0.1: {}
mkdirp@0.5.6:
@ -10267,6 +10371,10 @@ snapshots:
touch: 3.1.1
undefsafe: 2.0.5
nopt@10.0.1:
dependencies:
abbrev: 5.0.0
nopt@7.2.1:
dependencies:
abbrev: 2.0.0
@ -10459,6 +10567,11 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
path-scurry@2.0.2:
dependencies:
lru-cache: 11.5.2
minipass: 7.1.3
path-to-regexp@8.3.0: {}
pdf-to-img@6.2.0:
@ -11107,7 +11220,7 @@ snapshots:
standard@17.1.2:
dependencies:
eslint: 8.57.1
eslint-config-standard: 17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)
eslint-config-standard: 17.1.0(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@9.39.2))(eslint-plugin-promise@6.6.0(eslint@9.39.2))(eslint@8.57.1)
eslint-config-standard-jsx: 11.0.0(eslint-plugin-react@7.37.5(eslint@8.57.1))(eslint@8.57.1)
eslint-plugin-import: 2.32.0(eslint@8.57.1)
eslint-plugin-n: 15.7.0(eslint@8.57.1)

View File

@ -43,6 +43,7 @@ import {
getDocumentTemplateNeighborsRouteHandler,
previewDocumentTemplateRouteHandler,
downloadDocumentTemplateRouteHandler,
formatDocumentTemplateRouteHandler,
} from '../../services/management/documenttemplates.js';
// list of document templates
@ -76,6 +77,10 @@ router.post('/', isAuthenticated, async (req, res) => {
newDocumentTemplateRouteHandler(req, res);
});
router.post('/format', isAuthenticated, async (req, res) => {
formatDocumentTemplateRouteHandler(req, res);
});
// get document template stats
router.get('/stats', isAuthenticated, async (req, res) => {
getDocumentTemplateStatsRouteHandler(req, res);

View File

@ -16,6 +16,7 @@ import {
getObjectNeighbors,
} from '../../database/database.js';
import { templateManager } from '../../templates/templatemanager.js';
import { formatTemplateContent } from '../../templates/templateformatter.js';
const logger = log4js.getLogger('Document Templates');
logger.level = config.server.logLevel;
@ -124,6 +125,16 @@ export const editDocumentTemplateRouteHandler = async (req, res) => {
logger.trace(`Document Template with ID: ${id}`);
let formattedContent = req.body.content;
if (req.body.content != null && typeof req.body.content === 'string') {
const formatResult = formatTemplateContent(req.body.content);
if (formatResult?.error) {
logger.warn('Error formatting document template content:', formatResult.error);
return res.status(formatResult.code || 400).send(formatResult);
}
formattedContent = formatResult.content;
}
const updateData = {
updatedAt: new Date(),
name: req.body.name,
@ -134,7 +145,7 @@ export const editDocumentTemplateRouteHandler = async (req, res) => {
objectType: req.body.objectType,
documentSize: req.body.documentSize,
documentPrinters: req.body.documentPrinters,
content: req.body.content,
content: formattedContent,
testObject: req.body.testObject,
};
// Create audit log before updating
@ -263,6 +274,20 @@ export const getDocumentTemplateNeighborsRouteHandler = async (
res.send(result);
};
export const formatDocumentTemplateRouteHandler = async (req, res) => {
const content = req.body?.content;
logger.debug('Formatting document template content');
const result = formatTemplateContent(content);
if (result?.error) {
logger.warn('Error formatting document template content:', result.error);
return res.status(result.code || 400).send(result);
}
res.send({ content: result.content });
};
export const previewDocumentTemplateRouteHandler = async (req, res) => {
const id = req.params.id;
const content = req.body?.content;

View File

@ -0,0 +1,52 @@
import { formatTemplateContent } from '../templateformatter.js';
describe('formatTemplateContent', () => {
it('should return an error when content is missing', () => {
expect(formatTemplateContent()).toEqual({
error: 'Content is required and must be a string.',
code: 400,
});
});
it('should return an error when content is not a string', () => {
expect(formatTemplateContent({ foo: 'bar' })).toEqual({
error: 'Content is required and must be a string.',
code: 400,
});
});
it('should return empty content unchanged', () => {
expect(formatTemplateContent('')).toEqual({ content: '' });
expect(formatTemplateContent(' ')).toEqual({ content: '' });
});
it('should format custom XML tags', () => {
const input = '<Container><Flex vertical="true"><Title1>Hello</Title1></Flex></Container>';
const result = formatTemplateContent(input);
expect(result.content).toBe(`<Container>
<Flex vertical="true">
<Title1>Hello</Title1>
</Flex>
</Container>`);
});
it('should format XML while preserving EJS blocks', () => {
const input =
'<Container><Flex vertical="true"><Title1>Hello</Title1><% if (name) { %><Text><%= name %></Text><% } %></Flex></Container>';
const result = formatTemplateContent(input);
expect(result.content).toContain('<Container>');
expect(result.content).toContain('<% if (name) { %>');
expect(result.content).toContain('<%= name %>');
expect(result.content).toContain('<% } %>');
expect(result.content).toContain('<Text>');
});
it('should leave EJS comments unchanged', () => {
const input = '<Container><%# comment %></Container>';
const result = formatTemplateContent(input);
expect(result.content).toContain('<%# comment %>');
});
});

View File

@ -0,0 +1,85 @@
import beautify from 'js-beautify';
const { html: beautifyHtml, js: beautifyJs } = beautify;
const EJS_BLOCK_REGEX = /<%[\s\S]*?%>/g;
const EJS_TAG_REGEX = /^<%([=#-]?)([\s\S]*?)%>/;
const EJS_PLACEHOLDER_REGEX = /<!--EJS_PH_(\d+)-->/g;
const HTML_BEAUTIFY_OPTIONS = {
indent_size: 2,
wrap_line_length: 0,
preserve_newlines: false,
max_preserve_newlines: 1,
indent_inner_html: true,
extra_liners: [],
};
const JS_BEAUTIFY_OPTIONS = {
indent_size: 2,
preserve_newlines: false,
};
function formatEjsTag(tag) {
const match = tag.match(EJS_TAG_REGEX);
if (!match) {
return tag;
}
const [, modifier, body] = match;
if (modifier === '#') {
return tag;
}
const trimmedBody = body.trim();
if (!trimmedBody) {
return tag;
}
try {
const formattedBody = beautifyJs(trimmedBody, JS_BEAUTIFY_OPTIONS);
if (modifier) {
return `<%${modifier} ${formattedBody} %>`;
}
return `<% ${formattedBody} %>`;
} catch {
return tag;
}
}
function maskEjsBlocks(content) {
const ejsBlocks = [];
const masked = content.replace(EJS_BLOCK_REGEX, (match) => {
const index = ejsBlocks.length;
ejsBlocks.push(match);
return `\n<!--EJS_PH_${index}-->\n`;
});
return { masked, ejsBlocks };
}
function restoreEjsBlocks(content, ejsBlocks) {
return content.replace(EJS_PLACEHOLDER_REGEX, (_, index) =>
formatEjsTag(ejsBlocks[Number(index)])
);
}
export function formatTemplateContent(content) {
if (content == null || typeof content !== 'string') {
return { error: 'Content is required and must be a string.', code: 400 };
}
if (content.trim() === '') {
return { content: '' };
}
try {
const { masked, ejsBlocks } = maskEjsBlocks(content);
let formatted = beautifyHtml(masked, HTML_BEAUTIFY_OPTIONS);
formatted = restoreEjsBlocks(formatted, ejsBlocks);
return { content: formatted.trimEnd() };
} catch (error) {
return { error: error.message || 'Failed to format template content.', code: 400 };
}
}