@adobe/spacecat-shared-tokowaka-client 1.0.0

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.
@@ -0,0 +1,118 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import { hasText } from '@adobe/spacecat-shared-utils';
14
+ import { TARGET_USER_AGENTS_CATEGORIES } from '../constants.js';
15
+ import BaseOpportunityMapper from './base-mapper.js';
16
+
17
+ /**
18
+ * Mapper for headings opportunity
19
+ * Handles conversion of heading suggestions to Tokowaka patches
20
+ */
21
+ export default class HeadingsMapper extends BaseOpportunityMapper {
22
+ constructor(log) {
23
+ super(log);
24
+ this.opportunityType = 'headings';
25
+ this.prerenderRequired = true;
26
+ }
27
+
28
+ getOpportunityType() {
29
+ return this.opportunityType;
30
+ }
31
+
32
+ requiresPrerender() {
33
+ return this.prerenderRequired;
34
+ }
35
+
36
+ suggestionToPatch(suggestion, opportunityId) {
37
+ const eligibility = this.canDeploy(suggestion);
38
+ if (!eligibility.eligible) {
39
+ this.log.warn(`Headings suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
40
+ return null;
41
+ }
42
+
43
+ const data = suggestion.getData();
44
+ const { checkType, transformRules } = data;
45
+
46
+ const patch = {
47
+ ...this.createBasePatch(suggestion, opportunityId),
48
+ op: transformRules.action,
49
+ selector: transformRules.selector,
50
+ value: data.recommendedAction,
51
+ valueFormat: 'text',
52
+ ...(data.currentValue !== null && { currValue: data.currentValue }),
53
+ target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
54
+ };
55
+
56
+ if (checkType === 'heading-missing-h1' && transformRules.tag) {
57
+ patch.tag = transformRules.tag;
58
+ }
59
+ return patch;
60
+ }
61
+
62
+ /**
63
+ * Checks if a heading suggestion can be deployed
64
+ * Supports: heading-empty, heading-missing-h1, heading-h1-length
65
+ * @param {Object} suggestion - Suggestion object
66
+ * @returns {Object} { eligible: boolean, reason?: string }
67
+ */
68
+ // eslint-disable-next-line class-methods-use-this
69
+ canDeploy(suggestion) {
70
+ const data = suggestion.getData();
71
+ const checkType = data?.checkType;
72
+
73
+ // Check if checkType is eligible
74
+ const eligibleCheckTypes = ['heading-empty', 'heading-missing-h1', 'heading-h1-length'];
75
+ if (!eligibleCheckTypes.includes(checkType)) {
76
+ return {
77
+ eligible: false,
78
+ reason: `Only ${eligibleCheckTypes.join(', ')} can be deployed. This suggestion has checkType: ${checkType}`,
79
+ };
80
+ }
81
+
82
+ // Validate required fields
83
+ if (!data?.recommendedAction) {
84
+ return { eligible: false, reason: 'recommendedAction is required' };
85
+ }
86
+
87
+ if (!hasText(data.transformRules?.selector)) {
88
+ return { eligible: false, reason: 'transformRules.selector is required' };
89
+ }
90
+
91
+ // Validate based on checkType
92
+ if (checkType === 'heading-missing-h1') {
93
+ if (!['insertBefore', 'insertAfter'].includes(data.transformRules?.action)) {
94
+ return {
95
+ eligible: false,
96
+ reason: 'transformRules.action must be insertBefore or insertAfter for heading-missing-h1',
97
+ };
98
+ }
99
+ if (!hasText(data.transformRules?.tag)) {
100
+ return {
101
+ eligible: false,
102
+ reason: 'transformRules.tag is required for heading-missing-h1',
103
+ };
104
+ }
105
+ }
106
+
107
+ if (checkType === 'heading-h1-length' || checkType === 'heading-empty') {
108
+ if (data.transformRules?.action !== 'replace') {
109
+ return {
110
+ eligible: false,
111
+ reason: `transformRules.action must be replace for ${checkType}`,
112
+ };
113
+ }
114
+ }
115
+
116
+ return { eligible: true };
117
+ }
118
+ }
@@ -0,0 +1,87 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import HeadingsMapper from './headings-mapper.js';
14
+ import ContentSummarizationMapper from './content-summarization-mapper.js';
15
+
16
+ /**
17
+ * Registry for opportunity mappers
18
+ * Implements Factory Pattern to get the appropriate mapper for an opportunity type
19
+ */
20
+ export default class MapperRegistry {
21
+ constructor(log) {
22
+ this.log = log;
23
+ this.mappers = new Map();
24
+ this.#registerDefaultMappers();
25
+ }
26
+
27
+ /**
28
+ * Registers default mappers for built-in opportunity types
29
+ * @private
30
+ */
31
+ #registerDefaultMappers() {
32
+ const defaultMappers = [
33
+ HeadingsMapper,
34
+ ContentSummarizationMapper,
35
+ // more mappers here
36
+ ];
37
+
38
+ defaultMappers.forEach((MapperClass) => {
39
+ const mapper = new MapperClass(this.log);
40
+ this.registerMapper(mapper);
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Registers a mapper for an opportunity type
46
+ * @param {BaseOpportunityMapper} mapper - Mapper instance
47
+ */
48
+ registerMapper(mapper) {
49
+ const opportunityType = mapper.getOpportunityType();
50
+ if (this.mappers.has(opportunityType)) {
51
+ this.log.debug(`Mapper for opportunity type "${opportunityType}" is being overridden`);
52
+ }
53
+ this.mappers.set(opportunityType, mapper);
54
+ this.log.info(`Registered mapper for opportunity type: ${opportunityType}`);
55
+ }
56
+
57
+ /**
58
+ * Gets mapper for an opportunity type
59
+ * @param {string} opportunityType - Type of opportunity
60
+ * @returns {BaseOpportunityMapper|null} - Mapper instance or null if not found
61
+ */
62
+ getMapper(opportunityType) {
63
+ const mapper = this.mappers.get(opportunityType);
64
+ if (!mapper) {
65
+ this.log.warn(`No mapper found for opportunity type: ${opportunityType}`);
66
+ return null;
67
+ }
68
+ return mapper;
69
+ }
70
+
71
+ /**
72
+ * Checks if a mapper exists for an opportunity type
73
+ * @param {string} opportunityType - Type of opportunity
74
+ * @returns {boolean} - True if mapper exists
75
+ */
76
+ hasMapper(opportunityType) {
77
+ return this.mappers.has(opportunityType);
78
+ }
79
+
80
+ /**
81
+ * Gets all registered opportunity types
82
+ * @returns {string[]} - Array of opportunity types
83
+ */
84
+ getSupportedOpportunityTypes() {
85
+ return Array.from(this.mappers.keys());
86
+ }
87
+ }
@@ -0,0 +1,52 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-env mocha */
14
+
15
+ import { expect } from 'chai';
16
+ import BaseCdnClient from '../../src/cdn/base-cdn-client.js';
17
+
18
+ describe('BaseCdnClient', () => {
19
+ let client;
20
+
21
+ beforeEach(() => {
22
+ client = new BaseCdnClient({}, console);
23
+ });
24
+
25
+ describe('constructor', () => {
26
+ it('should use console as default logger if log is not provided', () => {
27
+ const clientWithoutLog = new BaseCdnClient({});
28
+ expect(clientWithoutLog.log).to.equal(console);
29
+ });
30
+ });
31
+
32
+ describe('abstract methods', () => {
33
+ it('getProviderName should throw error', () => {
34
+ expect(() => client.getProviderName())
35
+ .to.throw('getProviderName() must be implemented by subclass');
36
+ });
37
+
38
+ it('validateConfig should throw error', () => {
39
+ expect(() => client.validateConfig())
40
+ .to.throw('validateConfig() must be implemented by subclass');
41
+ });
42
+
43
+ it('invalidateCache should throw error', async () => {
44
+ try {
45
+ await client.invalidateCache(['/test']);
46
+ expect.fail('Should have thrown error');
47
+ } catch (error) {
48
+ expect(error.message).to.equal('invalidateCache() must be implemented by subclass');
49
+ }
50
+ });
51
+ });
52
+ });
@@ -0,0 +1,179 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-env mocha */
14
+ /* eslint-disable max-classes-per-file */
15
+
16
+ import { expect, use } from 'chai';
17
+ import sinon from 'sinon';
18
+ import sinonChai from 'sinon-chai';
19
+ import CdnClientRegistry from '../../src/cdn/cdn-client-registry.js';
20
+ import CloudFrontCdnClient from '../../src/cdn/cloudfront-cdn-client.js';
21
+ import BaseCdnClient from '../../src/cdn/base-cdn-client.js';
22
+
23
+ use(sinonChai);
24
+
25
+ describe('CdnClientRegistry', () => {
26
+ let registry;
27
+ let log;
28
+ let env;
29
+
30
+ beforeEach(() => {
31
+ log = {
32
+ info: sinon.stub(),
33
+ warn: sinon.stub(),
34
+ error: sinon.stub(),
35
+ };
36
+
37
+ env = {
38
+ TOKOWAKA_CDN_CONFIG: JSON.stringify({
39
+ cloudfront: {
40
+ distributionId: 'E123456',
41
+ region: 'us-east-1',
42
+ },
43
+ }),
44
+ };
45
+
46
+ registry = new CdnClientRegistry(env, log);
47
+ });
48
+
49
+ afterEach(() => {
50
+ sinon.restore();
51
+ });
52
+
53
+ describe('constructor', () => {
54
+ it('should create an instance and register default clients', () => {
55
+ expect(registry).to.be.instanceOf(CdnClientRegistry);
56
+ expect(registry.clients).to.be.instanceOf(Map);
57
+ expect(registry.clients.size).to.be.greaterThan(0);
58
+ });
59
+ });
60
+
61
+ describe('registerClient', () => {
62
+ it('should register a custom CDN client', () => {
63
+ class CustomCdnClient extends BaseCdnClient {}
64
+
65
+ registry.registerClient('custom', CustomCdnClient);
66
+
67
+ expect(registry.clients.has('custom')).to.be.true;
68
+ expect(registry.getSupportedProviders()).to.include('custom');
69
+ });
70
+
71
+ it('should register client with case-insensitive provider name', () => {
72
+ class CustomCdnClient extends BaseCdnClient {}
73
+
74
+ registry.registerClient('CUSTOM', CustomCdnClient);
75
+
76
+ expect(registry.clients.has('custom')).to.be.true;
77
+ });
78
+ });
79
+
80
+ describe('getClient', () => {
81
+ it('should return CloudFront client for cloudfront provider', () => {
82
+ const client = registry.getClient('cloudfront');
83
+
84
+ expect(client).to.be.instanceOf(CloudFrontCdnClient);
85
+ expect(client.cdnConfig).to.deep.equal({
86
+ distributionId: 'E123456',
87
+ region: 'us-east-1',
88
+ });
89
+ });
90
+
91
+ it('should be case-insensitive for provider names', () => {
92
+ const client = registry.getClient('CloudFront');
93
+
94
+ expect(client).to.be.instanceOf(CloudFrontCdnClient);
95
+ });
96
+
97
+ it('should return null if provider is not specified', () => {
98
+ const client = registry.getClient('');
99
+
100
+ expect(client).to.be.null;
101
+ expect(log.warn).to.have.been.calledWith('No CDN provider specified');
102
+ });
103
+
104
+ it('should return null if provider is null', () => {
105
+ const client = registry.getClient(null);
106
+
107
+ expect(client).to.be.null;
108
+ expect(log.warn).to.have.been.calledWith('No CDN provider specified');
109
+ });
110
+
111
+ it('should return null for unsupported provider', () => {
112
+ const client = registry.getClient('unsupported-provider');
113
+
114
+ expect(client).to.be.null;
115
+ expect(log.warn).to.have.been.calledWith(
116
+ 'No CDN client found for provider: unsupported-provider',
117
+ );
118
+ });
119
+
120
+ it('should handle client creation errors gracefully', () => {
121
+ class FailingCdnClient extends BaseCdnClient {
122
+ constructor() {
123
+ throw new Error('Construction failed');
124
+ }
125
+ }
126
+
127
+ registry.registerClient('failing', FailingCdnClient);
128
+
129
+ const client = registry.getClient('failing');
130
+
131
+ expect(client).to.be.null;
132
+ expect(log.error).to.have.been.calledWith(
133
+ sinon.match(/Failed to create CDN client for failing/),
134
+ );
135
+ });
136
+ });
137
+
138
+ describe('getSupportedProviders', () => {
139
+ it('should return list of supported providers', () => {
140
+ const providers = registry.getSupportedProviders();
141
+
142
+ expect(providers).to.be.an('array');
143
+ expect(providers).to.include('cloudfront');
144
+ });
145
+
146
+ it('should include custom registered providers', () => {
147
+ class CustomCdnClient extends BaseCdnClient {}
148
+
149
+ registry.registerClient('custom', CustomCdnClient);
150
+
151
+ const providers = registry.getSupportedProviders();
152
+
153
+ expect(providers).to.include('custom');
154
+ expect(providers).to.include('cloudfront');
155
+ });
156
+ });
157
+
158
+ describe('isProviderSupported', () => {
159
+ it('should return true for supported provider', () => {
160
+ expect(registry.isProviderSupported('cloudfront')).to.be.true;
161
+ });
162
+
163
+ it('should return true for supported provider (case-insensitive)', () => {
164
+ expect(registry.isProviderSupported('CloudFront')).to.be.true;
165
+ });
166
+
167
+ it('should return false for unsupported provider', () => {
168
+ expect(registry.isProviderSupported('unsupported')).to.be.false;
169
+ });
170
+
171
+ it('should return false for null provider', () => {
172
+ expect(registry.isProviderSupported(null)).to.be.false;
173
+ });
174
+
175
+ it('should return false for undefined provider', () => {
176
+ expect(registry.isProviderSupported(undefined)).to.be.false;
177
+ });
178
+ });
179
+ });