From f467b1be3376e163e765fcd5bfb79762c630e0cd Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Wed, 19 Aug 2026 20:02:39 +0100 Subject: [PATCH] Add formatting functionality for document templates 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. --- package.json | 1 + pnpm-lock.yaml | 117 +++++++++++++++++- src/routes/management/documenttemplates.js | 5 + src/services/management/documenttemplates.js | 27 +++- .../__tests__/templateformatter.test.js | 52 ++++++++ src/templates/templateformatter.js | 85 +++++++++++++ 6 files changed, 284 insertions(+), 3 deletions(-) create mode 100644 src/templates/__tests__/templateformatter.test.js create mode 100644 src/templates/templateformatter.js diff --git a/package.json b/package.json index acd19c1..73e9288 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e81125..81bb7cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/src/routes/management/documenttemplates.js b/src/routes/management/documenttemplates.js index ca96b32..7a73bb0 100644 --- a/src/routes/management/documenttemplates.js +++ b/src/routes/management/documenttemplates.js @@ -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); diff --git a/src/services/management/documenttemplates.js b/src/services/management/documenttemplates.js index 81a8d4f..f00bb23 100644 --- a/src/services/management/documenttemplates.js +++ b/src/services/management/documenttemplates.js @@ -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; diff --git a/src/templates/__tests__/templateformatter.test.js b/src/templates/__tests__/templateformatter.test.js new file mode 100644 index 0000000..3ef4b27 --- /dev/null +++ b/src/templates/__tests__/templateformatter.test.js @@ -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 = 'Hello'; + const result = formatTemplateContent(input); + + expect(result.content).toBe(` + + Hello + +`); + }); + + it('should format XML while preserving EJS blocks', () => { + const input = + 'Hello<% if (name) { %><%= name %><% } %>'; + const result = formatTemplateContent(input); + + expect(result.content).toContain(''); + expect(result.content).toContain('<% if (name) { %>'); + expect(result.content).toContain('<%= name %>'); + expect(result.content).toContain('<% } %>'); + expect(result.content).toContain(''); + }); + + it('should leave EJS comments unchanged', () => { + const input = '<%# comment %>'; + const result = formatTemplateContent(input); + + expect(result.content).toContain('<%# comment %>'); + }); +}); diff --git a/src/templates/templateformatter.js b/src/templates/templateformatter.js new file mode 100644 index 0000000..9dd84aa --- /dev/null +++ b/src/templates/templateformatter.js @@ -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 = //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\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 }; + } +}