@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.
- package/lib/page/layouts/deploy/deploy.js +12 -48
- package/lib/page/layouts/deploy/deploy.spec.js +26 -8
- package/lib/page/layouts/validation/index.d.ts +1 -0
- package/lib/page/layouts/validation/index.js +5 -0
- package/lib/page/layouts/validation/zone-consistency.d.ts +7 -0
- package/lib/page/layouts/validation/zone-consistency.js +49 -0
- package/lib/page/layouts/validation/zone-consistency.spec.d.ts +1 -0
- package/lib/page/layouts/validation/zone-consistency.spec.js +101 -0
- package/lib/page/templates/validation.d.ts +11 -0
- package/lib/page/templates/validation.js +30 -0
- package/lib/page/templates/validation.spec.d.ts +1 -0
- package/lib/page/templates/validation.spec.js +89 -0
- package/lib/page/utils/definitions.d.ts +5 -0
- package/lib/page/utils/definitions.js +21 -1
- package/lib/page/utils/definitions.spec.js +62 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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: '
|
|
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: '
|
|
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 "
|
|
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: '
|
|
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: '
|
|
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 "
|
|
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: '
|
|
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: '
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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
|
+
});
|