@squiz/dxp-cli-next 5.30.0-develop.1 → 5.30.0-develop.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,6 +19,7 @@ const ApiService_1 = require("../../../ApiService");
19
19
  const ApplicationConfig_1 = require("../../../ApplicationConfig");
20
20
  const constants_1 = require("../../../constants");
21
21
  const definitions_1 = require("../../utils/definitions");
22
+ const validation_1 = require("../validation");
22
23
  const dx_logger_lib_1 = require("@squiz/dx-logger-lib");
23
24
  exports.logger = (0, dx_logger_lib_1.getLogger)({
24
25
  name: 'upload-layout',
@@ -35,7 +36,7 @@ const createDeployCommand = () => {
35
36
  .addOption(new commander_1.Option('-t, --tenant <string>', 'Tenant ID to deploy to. If not provided will use configured tenant from login').env('SQUIZ_DXP_TENANT_ID'))
36
37
  .addOption(new commander_1.Option('--dry-run', 'Run all pre-deployment processes without deploying').default(false))
37
38
  .action((options) => __awaiter(void 0, void 0, void 0, function* () {
38
- var _a, _b, _c, _d;
39
+ var _a, _b, _c, _d, _e;
39
40
  if (options.contentServiceUrl) {
40
41
  console.log(`NOTICE: CONTENT_SERVICE_URL is set and will deploy to ${options.contentServiceUrl}`);
41
42
  }
@@ -52,13 +53,16 @@ const createDeployCommand = () => {
52
53
  const layout = yield (0, definitions_1.loadLayoutDefinition)(layoutFile);
53
54
  if (layout !== undefined) {
54
55
  // Pre-deployment validation: check zones match between YAML and template
55
- const zoneValidationError = validateZoneConsistency(layout);
56
+ const zoneValidationError = (0, validation_1.validateZoneConsistency)(layout);
56
57
  if (zoneValidationError) {
57
58
  throw new Error(zoneValidationError);
58
59
  }
59
60
  const response = yield uploadLayout(apiService.client, layout, contentServiceUrl, options.dryRun);
60
61
  if (!options.dryRun) {
61
62
  exports.logger.info(`Layout "${layout.name}" version ${response.data.version} deployed successfully.`);
63
+ // Log the deployed layout URL (clickable)
64
+ const layoutUrl = `${baseUrl}/organization/${(_e = options.tenant) !== null && _e !== void 0 ? _e : maybeConfig === null || maybeConfig === void 0 ? void 0 : maybeConfig.tenant}/component-service/all-layouts/${encodeURIComponent(layout.name)}`;
65
+ exports.logger.info(`Deployed layout URL: \u001b]8;;${layoutUrl}\u001b\\${layoutUrl}\u001b]8;;\u001b\\`);
62
66
  }
63
67
  else {
64
68
  exports.logger.info(`Layout "${layout.name}" dry run successful.`);
@@ -87,6 +91,7 @@ const createDeployCommand = () => {
87
91
  };
88
92
  exports.default = createDeployCommand;
89
93
  function uploadLayout(client, layout, contentServiceUrl, dryRun) {
94
+ var _a, _b;
90
95
  return __awaiter(this, void 0, void 0, function* () {
91
96
  try {
92
97
  const queryParam = dryRun ? '?_dryRun=true' : '';
@@ -96,7 +101,11 @@ function uploadLayout(client, layout, contentServiceUrl, dryRun) {
96
101
  if (response.status === 200) {
97
102
  return response;
98
103
  }
99
- throw new Error(response.data.message);
104
+ const error = new Error(response.data.message);
105
+ if ((_b = (_a = response.data.details) === null || _a === void 0 ? void 0 : _a.input) === null || _b === void 0 ? void 0 : _b.message) {
106
+ error.message += `: ${response.data.details.input.message}`;
107
+ }
108
+ throw error;
100
109
  }
101
110
  catch (error) {
102
111
  throw error;
@@ -111,48 +120,3 @@ function maybeGetApplicationConfig() {
111
120
  catch (_a) { }
112
121
  });
113
122
  }
114
- /**
115
- * Validates that zones defined in YAML match zones used in the Handlebars template
116
- * @param layout The layout definition containing zones and template
117
- * @returns Error message if validation fails, null if validation passes
118
- */
119
- function validateZoneConsistency(layout) {
120
- const yamlZones = Object.keys(layout.zones || {});
121
- const template = layout.template;
122
- // If no template is provided, skip validation
123
- if (!template) {
124
- return null;
125
- }
126
- // Extract zone references from Handlebars template
127
- // Look for patterns like {{#each zones.zoneName}}, {{zones.zoneName}}, {{#if zones.zoneName}}
128
- const zonePattern = /\{\{(?:#(?:each|if)\s+)?zones\.(\w+)/g;
129
- const templateZones = new Set();
130
- let match;
131
- // Loop through the template and extract the zone names
132
- while ((match = zonePattern.exec(template)) !== null) {
133
- templateZones.add(match[1]);
134
- }
135
- // Convert the set to an array
136
- const templateZoneArray = Array.from(templateZones);
137
- // Find zones defined in YAML but not used in template
138
- const unusedYamlZones = yamlZones.filter(zone => !templateZones.has(zone));
139
- // Find zones used in template but not defined in YAML
140
- const undefinedTemplateZones = templateZoneArray.filter(zone => !yamlZones.includes(zone));
141
- // Create an array of errors
142
- const errors = [];
143
- // Add the unused zones to the errors
144
- if (unusedYamlZones.length > 0) {
145
- errors.push(`Zones defined in YAML but not used in template: ${unusedYamlZones.join(', ')}`);
146
- }
147
- // Add the undefined zones to the errors
148
- if (undefinedTemplateZones.length > 0) {
149
- errors.push(`Zones used in template but not defined in YAML: ${undefinedTemplateZones.join(', ')}`);
150
- }
151
- // If there are errors, return the errors
152
- if (errors.length > 0) {
153
- return `Zone consistency validation failed:\n${errors
154
- .map(err => ` - ${err}`)
155
- .join('\n')}`;
156
- }
157
- return null;
158
- }
@@ -93,13 +93,13 @@ describe('deployCommand', () => {
93
93
  const file = './src/__tests__/layouts/page-layout.yaml';
94
94
  const dxpBaseUrl = 'http://dxp-base-url.com';
95
95
  const mockLayout = {
96
- name: 'Test Layout',
96
+ name: 'test-layout',
97
97
  zones: {
98
98
  content: { displayName: 'Content', description: 'Main content' },
99
99
  },
100
100
  template: '<div>{{zones.content}}</div>',
101
101
  };
102
- const mockResponse = { name: 'Test Layout', version: '12345' };
102
+ const mockResponse = { name: 'test-layout', version: '12345' };
103
103
  definitions_1.loadLayoutDefinition.mockResolvedValue(mockLayout);
104
104
  (0, nock_1.default)(dxpBaseUrl + '/__dxp/service/components-content')
105
105
  .post('/page-layout', mockLayout)
@@ -107,20 +107,21 @@ describe('deployCommand', () => {
107
107
  const program = (0, deploy_1.default)();
108
108
  yield program.parseAsync(createMockArgs({ config: file, dxpBaseUrl }));
109
109
  expect(mockLoggerInfoFn).toHaveBeenNthCalledWith(1, `Loading layout data from the file ${file}`);
110
- expect(mockLoggerInfoFn).toHaveBeenNthCalledWith(2, 'Layout "Test Layout" version 12345 deployed successfully.');
110
+ expect(mockLoggerInfoFn).toHaveBeenNthCalledWith(2, 'Layout "test-layout" version 12345 deployed successfully.');
111
+ expect(mockLoggerInfoFn).toHaveBeenNthCalledWith(3, 'Deployed layout URL: \u001b]8;;http://dxp-base-url.com/organization/myTenant/component-service/all-layouts/test-layout\u001b\\http://dxp-base-url.com/organization/myTenant/component-service/all-layouts/test-layout\u001b]8;;\u001b\\');
111
112
  }));
112
113
  it('deploys a layout with dry-run option', () => __awaiter(void 0, void 0, void 0, function* () {
113
114
  const file = './src/__tests__/layout.yaml';
114
115
  const dxpBaseUrl = 'http://dxp-base-url.com';
115
116
  const dryRun = true;
116
117
  const mockLayout = {
117
- name: 'Test Layout',
118
+ name: 'test-layout',
118
119
  zones: {
119
120
  content: { displayName: 'Content', description: 'Main content' },
120
121
  },
121
122
  template: '<div>{{zones.content}}</div>',
122
123
  };
123
- const mockResponse = { name: 'Test Layout', version: '12345' };
124
+ const mockResponse = { name: 'test-layout', version: '12345' };
124
125
  definitions_1.loadLayoutDefinition.mockResolvedValue(mockLayout);
125
126
  (0, nock_1.default)(dxpBaseUrl + '/__dxp/service/components-content')
126
127
  .post('/page-layout', mockLayout)
@@ -129,7 +130,7 @@ describe('deployCommand', () => {
129
130
  const program = (0, deploy_1.default)();
130
131
  yield program.parseAsync(createMockArgs({ config: file, dxpBaseUrl, dryRun }));
131
132
  expect(mockLoggerInfoFn).toHaveBeenNthCalledWith(1, `Loading layout data from the file ${file}`);
132
- expect(mockLoggerInfoFn).toHaveBeenNthCalledWith(2, 'Layout "Test Layout" dry run successful.');
133
+ expect(mockLoggerInfoFn).toHaveBeenNthCalledWith(2, 'Layout "test-layout" dry run successful.');
133
134
  }));
134
135
  it('handles InvalidLoginSessionError', () => __awaiter(void 0, void 0, void 0, function* () {
135
136
  const dxpBaseUrl = 'http://dxp-base-url.com';
@@ -171,13 +172,30 @@ describe('deployCommand', () => {
171
172
  yield program.parseAsync(createMockArgs({ contentServiceUrl }));
172
173
  expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('An unknown error occurred'));
173
174
  }));
175
+ it('should log additional details when layout validation fails', () => __awaiter(void 0, void 0, void 0, function* () {
176
+ const dxpBaseUrl = 'http://dxp-base-url.com';
177
+ (0, nock_1.default)(dxpBaseUrl + '/__dxp/service/components-content')
178
+ .post('/page-layout')
179
+ .reply(400, {
180
+ message: 'Layout validation failed',
181
+ details: {
182
+ input: {
183
+ message: 'ERROR: Validation failed: "version" is an excess property and therefore is not allowed',
184
+ },
185
+ },
186
+ });
187
+ const program = (0, deploy_1.default)();
188
+ errorSpy = jest.spyOn(program, 'error').mockImplementation();
189
+ yield program.parseAsync(createMockArgs({ dxpBaseUrl }));
190
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Layout validation failed: ERROR: Validation failed: "version" is an excess property and therefore is not allowed'));
191
+ }));
174
192
  describe('zone consistency validation', () => {
175
193
  it('should handle zone consistency validation errors where zones are used but not defined in the layout', () => __awaiter(void 0, void 0, void 0, function* () {
176
194
  const file = './src/__tests__/layout.yaml';
177
195
  const dxpBaseUrl = 'http://dxp-base-url.com';
178
196
  // Mock layout with zones that don't match the template
179
197
  const mockLayout = {
180
- name: 'Test Layout',
198
+ name: 'test-layout',
181
199
  zones: {
182
200
  col1: { displayName: 'Column 1', description: 'The first column' },
183
201
  },
@@ -193,7 +211,7 @@ describe('deployCommand', () => {
193
211
  const file = './src/__tests__/layout.yaml';
194
212
  const dxpBaseUrl = 'http://dxp-base-url.com';
195
213
  const mockLayout = {
196
- name: 'Test Layout',
214
+ name: 'test-layout',
197
215
  zones: {
198
216
  col1: { displayName: 'Column 1', description: 'The first column' },
199
217
  col2: { displayName: 'Column 2', description: 'The second column' }, // col2 is defined but not used in the template
@@ -0,0 +1 @@
1
+ export { validateZoneConsistency } from './zone-consistency';
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateZoneConsistency = void 0;
4
+ var zone_consistency_1 = require("./zone-consistency");
5
+ Object.defineProperty(exports, "validateZoneConsistency", { enumerable: true, get: function () { return zone_consistency_1.validateZoneConsistency; } });
@@ -0,0 +1,7 @@
1
+ import { LayoutDefinition } from '../../utils/definitions';
2
+ /**
3
+ * Validates that zones defined in YAML match zones used in the Handlebars template
4
+ * @param layout The layout definition containing zones and template
5
+ * @returns Error message if validation fails, null if validation passes
6
+ */
7
+ export declare function validateZoneConsistency(layout: LayoutDefinition): string | null;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateZoneConsistency = void 0;
4
+ /**
5
+ * Validates that zones defined in YAML match zones used in the Handlebars template
6
+ * @param layout The layout definition containing zones and template
7
+ * @returns Error message if validation fails, null if validation passes
8
+ */
9
+ function validateZoneConsistency(layout) {
10
+ const yamlZones = Object.keys(layout.zones || {});
11
+ const template = layout.template;
12
+ // If no template is provided, skip validation
13
+ if (!template) {
14
+ return null;
15
+ }
16
+ // Extract zone references from Handlebars template
17
+ // Look for patterns like {{#each zones.zoneName}}, {{zones.zoneName}}, {{#if zones.zoneName}}
18
+ const zonePattern = /\{\{(?:#(?:each|if)\s+)?zones\.(\w+)/g;
19
+ const templateZones = new Set();
20
+ let match;
21
+ // Loop through the template and extract the zone names
22
+ while ((match = zonePattern.exec(template)) !== null) {
23
+ templateZones.add(match[1]);
24
+ }
25
+ // Convert the set to an array
26
+ const templateZoneArray = Array.from(templateZones);
27
+ // Find zones defined in YAML but not used in template
28
+ const unusedYamlZones = yamlZones.filter(zone => !templateZones.has(zone));
29
+ // Find zones used in template but not defined in YAML
30
+ const undefinedTemplateZones = templateZoneArray.filter(zone => !yamlZones.includes(zone));
31
+ // Create an array of errors
32
+ const errors = [];
33
+ // Add the unused zones to the errors
34
+ if (unusedYamlZones.length > 0) {
35
+ errors.push(`Zones defined in YAML but not used in template: ${unusedYamlZones.join(', ')}`);
36
+ }
37
+ // Add the undefined zones to the errors
38
+ if (undefinedTemplateZones.length > 0) {
39
+ errors.push(`Zones used in template but not defined in YAML: ${undefinedTemplateZones.join(', ')}`);
40
+ }
41
+ // If there are errors, return the errors
42
+ if (errors.length > 0) {
43
+ return `Zone consistency validation failed:\n${errors
44
+ .map(err => ` - ${err}`)
45
+ .join('\n')}`;
46
+ }
47
+ return null;
48
+ }
49
+ exports.validateZoneConsistency = validateZoneConsistency;
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const zone_consistency_1 = require("./zone-consistency");
4
+ describe('validateZoneConsistency', () => {
5
+ const createMockLayout = (zones, template) => ({
6
+ name: 'test-layout',
7
+ displayName: 'Test Layout',
8
+ description: 'A test layout',
9
+ zones,
10
+ template,
11
+ });
12
+ it('should return null for valid zone consistency', () => {
13
+ const layout = createMockLayout({
14
+ header: { displayName: 'Header', description: 'Header zone' },
15
+ content: { displayName: 'Content', description: 'Content zone' },
16
+ }, '<div>{{zones.header}}</div><div>{{zones.content}}</div>');
17
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
18
+ expect(result).toBeNull();
19
+ });
20
+ it('should return null when no template is provided', () => {
21
+ const layout = createMockLayout({
22
+ header: { displayName: 'Header', description: 'Header zone' },
23
+ }, '');
24
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
25
+ expect(result).toBeNull();
26
+ });
27
+ it('should detect zones defined in YAML but not used in template', () => {
28
+ const layout = createMockLayout({
29
+ header: { displayName: 'Header', description: 'Header zone' },
30
+ sidebar: { displayName: 'Sidebar', description: 'Sidebar zone' },
31
+ }, '<div>{{zones.header}}</div>');
32
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
33
+ expect(result).toContain('Zone consistency validation failed');
34
+ expect(result).toContain('Zones defined in YAML but not used in template: sidebar');
35
+ });
36
+ it('should detect zones used in template but not defined in YAML', () => {
37
+ const layout = createMockLayout({
38
+ header: { displayName: 'Header', description: 'Header zone' },
39
+ }, '<div>{{zones.header}}</div><div>{{zones.footer}}</div>');
40
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
41
+ expect(result).toContain('Zone consistency validation failed');
42
+ expect(result).toContain('Zones used in template but not defined in YAML: footer');
43
+ });
44
+ it('should detect both unused and undefined zones', () => {
45
+ const layout = createMockLayout({
46
+ header: { displayName: 'Header', description: 'Header zone' },
47
+ sidebar: { displayName: 'Sidebar', description: 'Sidebar zone' },
48
+ }, '<div>{{zones.header}}</div><div>{{zones.footer}}</div>');
49
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
50
+ expect(result).toContain('Zone consistency validation failed');
51
+ expect(result).toContain('Zones defined in YAML but not used in template: sidebar');
52
+ expect(result).toContain('Zones used in template but not defined in YAML: footer');
53
+ });
54
+ it('should handle Handlebars each loops', () => {
55
+ const layout = createMockLayout({
56
+ items: { displayName: 'Items', description: 'Items zone' },
57
+ }, '<div>{{#each zones.items}}<p>{{this}}</p>{{/each}}</div>');
58
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
59
+ expect(result).toBeNull();
60
+ });
61
+ it('should handle Handlebars if conditions', () => {
62
+ const layout = createMockLayout({
63
+ optional: { displayName: 'Optional', description: 'Optional zone' },
64
+ }, '<div>{{#if zones.optional}}{{zones.optional}}{{/if}}</div>');
65
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
66
+ expect(result).toBeNull();
67
+ });
68
+ it('should handle complex Handlebars patterns', () => {
69
+ const layout = createMockLayout({
70
+ header: { displayName: 'Header', description: 'Header zone' },
71
+ content: { displayName: 'Content', description: 'Content zone' },
72
+ sidebar: { displayName: 'Sidebar', description: 'Sidebar zone' },
73
+ }, `
74
+ <div>{{zones.header}}</div>
75
+ <div>
76
+ {{#if zones.sidebar}}
77
+ <aside>{{zones.sidebar}}</aside>
78
+ {{/if}}
79
+ <main>{{zones.content}}</main>
80
+ </div>
81
+ `);
82
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
83
+ expect(result).toBeNull();
84
+ });
85
+ it('should handle empty zones object', () => {
86
+ const layout = createMockLayout({}, '<div>Static content only</div>');
87
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
88
+ expect(result).toBeNull();
89
+ });
90
+ it('should handle undefined zones property', () => {
91
+ const layout = {
92
+ name: 'test-layout',
93
+ displayName: 'Test Layout',
94
+ description: 'A test layout',
95
+ zones: undefined,
96
+ template: '<div>Static content only</div>',
97
+ };
98
+ const result = (0, zone_consistency_1.validateZoneConsistency)(layout);
99
+ expect(result).toBeNull();
100
+ });
101
+ });
@@ -0,0 +1,11 @@
1
+ /*!
2
+ * @license
3
+ * Copyright (c) 2025, Squiz Australia Pty Ltd.
4
+ * All rights reserved.
5
+ */
6
+ /**
7
+ * Validates a template file and returns detailed results
8
+ * @param content - The template to validate
9
+ * @returns An array of errors
10
+ */
11
+ export declare function validateTemplateFile(content: string): string[];
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ /*!
3
+ * @license
4
+ * Copyright (c) 2025, Squiz Australia Pty Ltd.
5
+ * All rights reserved.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.validateTemplateFile = void 0;
9
+ /**
10
+ * Validates a template file and returns detailed results
11
+ * @param content - The template to validate
12
+ * @returns An array of errors
13
+ */
14
+ function validateTemplateFile(content) {
15
+ const errors = [];
16
+ // Check for top-level HTML structure tags that should not be in body content
17
+ const topLevelTags = [
18
+ { name: 'html', regex: /<html(\s|>)/gi },
19
+ { name: 'head', regex: /<head(\s|>)/gi },
20
+ { name: 'body', regex: /<body(\s|>)/gi },
21
+ { name: 'doctype', regex: /<!DOCTYPE[^>]*>/gi },
22
+ ];
23
+ for (const tag of topLevelTags) {
24
+ if (tag.regex.test(content)) {
25
+ errors.push(`Template should not contain top-level HTML structure tag: <${tag.name}>. Templates should only contain body content.`);
26
+ }
27
+ }
28
+ return errors;
29
+ }
30
+ exports.validateTemplateFile = validateTemplateFile;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const validation_1 = require("./validation");
4
+ describe('Handlebars Template Validation', () => {
5
+ describe('validateTemplateFile', () => {
6
+ it('should validate an empty template', () => {
7
+ const template = '';
8
+ const errors = (0, validation_1.validateTemplateFile)(template);
9
+ expect(errors).toHaveLength(0);
10
+ });
11
+ it('should validate a simple valid template', () => {
12
+ const template = '<div>{{zones.content}}</div>';
13
+ const errors = (0, validation_1.validateTemplateFile)(template);
14
+ expect(errors).toHaveLength(0);
15
+ });
16
+ it('should detect top-level HTML tags', () => {
17
+ const template = '<html><body><div>{{zones.content}}</div></body></html>';
18
+ const errors = (0, validation_1.validateTemplateFile)(template);
19
+ expect(errors.length).toBeGreaterThan(0);
20
+ expect(errors).toEqual(expect.arrayContaining([
21
+ expect.stringContaining('Template should not contain top-level HTML structure tag: <html>'),
22
+ expect.stringContaining('Template should not contain top-level HTML structure tag: <body>'),
23
+ ]));
24
+ });
25
+ it('should detect DOCTYPE declarations', () => {
26
+ const template = '<!DOCTYPE html><div>{{zones.content}}</div>';
27
+ const errors = (0, validation_1.validateTemplateFile)(template);
28
+ expect(errors.length).toBeGreaterThan(0);
29
+ expect(errors).toEqual(expect.arrayContaining([
30
+ expect.stringContaining('Template should not contain top-level HTML structure tag: <doctype>'),
31
+ ]));
32
+ });
33
+ it('should detect head tags', () => {
34
+ const template = '<head><title>Test</title></head><div>{{zones.content}}</div>';
35
+ const errors = (0, validation_1.validateTemplateFile)(template);
36
+ expect(errors.length).toBeGreaterThan(0);
37
+ expect(errors).toEqual(expect.arrayContaining([
38
+ expect.stringContaining('Template should not contain top-level HTML structure tag: <head>'),
39
+ ]));
40
+ });
41
+ it('should allow body content elements', () => {
42
+ const template = `
43
+ <div class="layout">
44
+ <header>{{zones.header}}</header>
45
+ <main>{{zones.content}}</main>
46
+ <aside>{{zones.sidebar}}</aside>
47
+ <footer>{{zones.footer}}</footer>
48
+ </div>
49
+ `;
50
+ const errors = (0, validation_1.validateTemplateFile)(template);
51
+ expect(errors).toHaveLength(0);
52
+ });
53
+ it('should allow complex Handlebars expressions', () => {
54
+ const template = `
55
+ <div class="layout">
56
+ {{#if zones.header}}
57
+ <header>{{zones.header}}</header>
58
+ {{/if}}
59
+ <main>{{zones.content}}</main>
60
+ {{#each zones.sidebar}}
61
+ <aside>{{this}}</aside>
62
+ {{/each}}
63
+ </div>
64
+ `;
65
+ const errors = (0, validation_1.validateTemplateFile)(template);
66
+ expect(errors).toHaveLength(0);
67
+ });
68
+ it('should allow all valid HTML body elements', () => {
69
+ const template = `
70
+ <div>{{zones.content}}</div>
71
+ <p>{{zones.text}}</p>
72
+ <span>{{zones.inline}}</span>
73
+ <section>{{zones.section}}</section>
74
+ <article>{{zones.article}}</article>
75
+ <nav>{{zones.navigation}}</nav>
76
+ <header>{{zones.header}}</header>
77
+ <footer>{{zones.footer}}</footer>
78
+ <aside>{{zones.sidebar}}</aside>
79
+ <main>{{zones.main}}</main>
80
+ <h1>{{zones.title}}</h1>
81
+ <img src="{{zones.image}}" alt="{{zones.alt}}">
82
+ <a href="{{zones.link}}">{{zones.linkText}}</a>
83
+ <ul>{{#each zones.list}}<li>{{this}}</li>{{/each}}</ul>
84
+ `;
85
+ const errors = (0, validation_1.validateTemplateFile)(template);
86
+ expect(errors).toHaveLength(0);
87
+ });
88
+ });
89
+ });
@@ -1,3 +1,8 @@
1
+ /*!
2
+ * @license
3
+ * Copyright (c) 2025, Squiz Australia Pty Ltd.
4
+ * All rights reserved.
5
+ */
1
6
  import { z } from 'zod';
2
7
  export declare function loadLayoutDefinition(layoutFile: string): Promise<LayoutDefinition>;
3
8
  export declare function loadLayoutFromFile(layoutFile: string): Promise<InputLayoutDefinition>;
@@ -1,4 +1,9 @@
1
1
  "use strict";
2
+ /*!
3
+ * @license
4
+ * Copyright (c) 2025, Squiz Australia Pty Ltd.
5
+ * All rights reserved.
6
+ */
2
7
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
8
  if (k2 === undefined) k2 = k;
4
9
  var desc = Object.getOwnPropertyDescriptor(m, k);
@@ -44,10 +49,13 @@ var __rest = (this && this.__rest) || function (s, e) {
44
49
  };
45
50
  Object.defineProperty(exports, "__esModule", { value: true });
46
51
  exports.LayoutDefinition = exports.InputLayoutDefinition = exports.BaseLayoutDefinition = exports.loadLayoutFromFile = exports.loadLayoutDefinition = void 0;
52
+ // External
47
53
  const fs = __importStar(require("node:fs/promises"));
48
54
  const path = __importStar(require("node:path"));
49
55
  const zod_1 = require("zod");
50
56
  const yaml_1 = require("yaml");
57
+ // Local
58
+ const validation_1 = require("../templates/validation");
51
59
  function loadLayoutDefinition(layoutFile) {
52
60
  return __awaiter(this, void 0, void 0, function* () {
53
61
  try {
@@ -83,12 +91,24 @@ function loadLayoutFromFile(layoutFile) {
83
91
  });
84
92
  }
85
93
  exports.loadLayoutFromFile = loadLayoutFromFile;
94
+ /**
95
+ * Loads a template file from a file system
96
+ * @param layoutDirectory - The path to the layout directory
97
+ * @param templateFile - The path to the template file
98
+ * @returns The template
99
+ */
86
100
  function loadTemplate(layoutDirectory, templateFile) {
87
101
  return __awaiter(this, void 0, void 0, function* () {
88
102
  try {
89
- return yield fs.readFile(path.resolve(layoutDirectory, templateFile), {
103
+ const template = yield fs.readFile(path.resolve(layoutDirectory, templateFile), {
90
104
  encoding: 'utf-8',
91
105
  });
106
+ // Validate the template
107
+ const validationErrors = yield (0, validation_1.validateTemplateFile)(template);
108
+ if (validationErrors.length > 0) {
109
+ throw new Error(validationErrors.join('\n'));
110
+ }
111
+ return template;
92
112
  }
93
113
  catch (e) {
94
114
  throw Error(`Failed loading template file "${templateFile}": ${e.message}`);
@@ -34,6 +34,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
35
  const fs = __importStar(require("node:fs/promises"));
36
36
  const definitions_1 = require("./definitions");
37
+ const validation_1 = require("../templates/validation");
37
38
  jest.mock('node:fs/promises');
38
39
  describe('loadLayoutDefinition', () => {
39
40
  const paintLayoutFileYaml = './some-dir/page-layout.yaml';
@@ -436,3 +437,64 @@ describe('LayoutDefinitionParse', () => {
436
437
  expect(layoutDefinition.zones.main.minNodes).toBe(0);
437
438
  });
438
439
  });
440
+ describe('validateTemplateFile', () => {
441
+ describe('valid templates', () => {
442
+ it('should validate a simple valid template', () => __awaiter(void 0, void 0, void 0, function* () {
443
+ const template = '<div>{{zones.content}}</div>';
444
+ const errors = yield (0, validation_1.validateTemplateFile)(template);
445
+ expect(errors).toHaveLength(0);
446
+ }));
447
+ it('should validate a complex valid template', () => __awaiter(void 0, void 0, void 0, function* () {
448
+ const template = `
449
+ <div class="layout">
450
+ {{#if zones.header}}
451
+ <header>{{zones.header}}</header>
452
+ {{/if}}
453
+ <main>{{zones.content}}</main>
454
+ {{#each zones.sidebar}}
455
+ <aside>{{this}}</aside>
456
+ {{/each}}
457
+ </div>
458
+ `;
459
+ const errors = yield (0, validation_1.validateTemplateFile)(template);
460
+ expect(errors).toHaveLength(0);
461
+ }));
462
+ it('should validate template with options', () => __awaiter(void 0, void 0, void 0, function* () {
463
+ const template = '<div class="{{options.theme}}">{{zones.content}}</div>';
464
+ const errors = yield (0, validation_1.validateTemplateFile)(template);
465
+ expect(errors).toHaveLength(0);
466
+ }));
467
+ it('should allow empty template', () => __awaiter(void 0, void 0, void 0, function* () {
468
+ const template = '';
469
+ const errors = yield (0, validation_1.validateTemplateFile)(template);
470
+ expect(errors).toHaveLength(0);
471
+ }));
472
+ });
473
+ describe('invalid templates', () => {
474
+ it('should detect top-level HTML tags', () => __awaiter(void 0, void 0, void 0, function* () {
475
+ const template = '<html><body>{{zones.content}}</body></html>';
476
+ const errors = yield (0, validation_1.validateTemplateFile)(template);
477
+ expect(errors.length).toBeGreaterThan(0);
478
+ expect(errors).toEqual(expect.arrayContaining([
479
+ expect.stringContaining('Template should not contain top-level HTML structure tag: <html>'),
480
+ expect.stringContaining('Template should not contain top-level HTML structure tag: <body>'),
481
+ ]));
482
+ }));
483
+ it('should detect DOCTYPE declarations', () => __awaiter(void 0, void 0, void 0, function* () {
484
+ const template = '<!DOCTYPE html><div>{{zones.content}}</div>';
485
+ const errors = yield (0, validation_1.validateTemplateFile)(template);
486
+ expect(errors.length).toBeGreaterThan(0);
487
+ expect(errors).toEqual(expect.arrayContaining([
488
+ expect.stringContaining('Template should not contain top-level HTML structure tag: <doctype>'),
489
+ ]));
490
+ }));
491
+ });
492
+ describe('error handling', () => {
493
+ it('should handle null template gracefully', () => __awaiter(void 0, void 0, void 0, function* () {
494
+ // Test with a null template
495
+ const template = null;
496
+ const errors = yield (0, validation_1.validateTemplateFile)(template);
497
+ expect(errors).toHaveLength(0);
498
+ }));
499
+ });
500
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@squiz/dxp-cli-next",
3
- "version": "5.30.0-develop.1",
3
+ "version": "5.30.0-develop.3",
4
4
  "repository": {
5
5
  "url": "https://gitlab.squiz.net/dxp/dxp-cli-next"
6
6
  },