docusaurus-plugin-generate-schema-docs 1.7.0 → 1.7.1
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/README.md +17 -0
- package/__tests__/syncGtm.test.js +346 -0
- package/index.js +34 -0
- package/package.json +1 -1
- package/scripts/sync-gtm.js +397 -0
package/README.md
CHANGED
|
@@ -73,6 +73,23 @@ npm run update-schema-ids
|
|
|
73
73
|
|
|
74
74
|
This command will update the `$id` of all schemas in the versioned directories.
|
|
75
75
|
|
|
76
|
+
### Sync GTM Variables (Optional)
|
|
77
|
+
|
|
78
|
+
If you use Google Tag Manager, you can sync Data Layer Variables from your schemas:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm install --save-optional @owntag/gtm-cli
|
|
82
|
+
npm run sync:gtm
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The Docusaurus CLI command is:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
docusaurus sync-gtm
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
By default, it resolves schemas from the project root. Use `--path=<siteDir>` to target a different site directory.
|
|
92
|
+
|
|
76
93
|
## How it Works
|
|
77
94
|
|
|
78
95
|
The plugin reads your JSON schemas, dereferences any `$ref` properties, and merges `allOf` properties. It then generates an MDX file for each schema, which uses custom React components to render the schema details.
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/* eslint-env jest */
|
|
2
|
+
/**
|
|
3
|
+
* @jest-environment node
|
|
4
|
+
*/
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const gtmScript = require('../scripts/sync-gtm');
|
|
8
|
+
const { execSync } = require('child_process');
|
|
9
|
+
const RefParser = require('@apidevtools/json-schema-ref-parser');
|
|
10
|
+
|
|
11
|
+
jest.mock('fs');
|
|
12
|
+
jest.mock('child_process');
|
|
13
|
+
jest.mock('@apidevtools/json-schema-ref-parser');
|
|
14
|
+
|
|
15
|
+
describe('parseArgs', () => {
|
|
16
|
+
it('should parse --quiet, --json, and --skip-array-sub-properties flags', () => {
|
|
17
|
+
const argv = [
|
|
18
|
+
'node',
|
|
19
|
+
'script.js',
|
|
20
|
+
'--quiet',
|
|
21
|
+
'--json',
|
|
22
|
+
'--skip-array-sub-properties',
|
|
23
|
+
];
|
|
24
|
+
const { isQuiet, isJson, skipArraySubProperties } =
|
|
25
|
+
gtmScript.parseArgs(argv);
|
|
26
|
+
expect(isQuiet).toBe(true);
|
|
27
|
+
expect(isJson).toBe(true);
|
|
28
|
+
expect(skipArraySubProperties).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should parse --path argument', () => {
|
|
32
|
+
const argv = ['node', 'script.js', '--path=./my-demo'];
|
|
33
|
+
const { siteDir } = gtmScript.parseArgs(argv);
|
|
34
|
+
expect(siteDir).toBe('./my-demo');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should default siteDir to .', () => {
|
|
38
|
+
const argv = ['node', 'script.js'];
|
|
39
|
+
const { siteDir } = gtmScript.parseArgs(argv);
|
|
40
|
+
expect(siteDir).toBe('.');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getLatestSchemaPath', () => {
|
|
45
|
+
const SITE_DIR = '/fake/site';
|
|
46
|
+
const SCHEMAS_BASE_PATH = path.join(SITE_DIR, 'static', 'schemas');
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
jest.clearAllMocks();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should return path to latest version when versions.json exists', () => {
|
|
53
|
+
fs.existsSync.mockImplementation(
|
|
54
|
+
(p) => p === path.join(SITE_DIR, 'versions.json'),
|
|
55
|
+
);
|
|
56
|
+
fs.readFileSync.mockReturnValue('["1.2.0", "1.1.1"]');
|
|
57
|
+
const result = gtmScript.getLatestSchemaPath(SITE_DIR);
|
|
58
|
+
expect(result).toBe(path.join(SCHEMAS_BASE_PATH, '1.2.0'));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should return path to "next" when versions.json does not exist', () => {
|
|
62
|
+
const nextPath = path.join(SCHEMAS_BASE_PATH, 'next');
|
|
63
|
+
fs.existsSync.mockImplementation((p) => p === nextPath);
|
|
64
|
+
const result = gtmScript.getLatestSchemaPath(SITE_DIR);
|
|
65
|
+
expect(result).toBe(nextPath);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should return base schemas path as fallback', () => {
|
|
69
|
+
fs.existsSync.mockReturnValue(false);
|
|
70
|
+
const result = gtmScript.getLatestSchemaPath(SITE_DIR);
|
|
71
|
+
expect(result).toBe(SCHEMAS_BASE_PATH);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('getVariablesFromSchemas', () => {
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
jest.resetAllMocks();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const SCHEMA_PATH = '/fake/schemas';
|
|
81
|
+
const mockFiles = {
|
|
82
|
+
[SCHEMA_PATH]: ['complex-event.json', 'components'],
|
|
83
|
+
[path.join(SCHEMA_PATH, 'components')]: ['address.json'],
|
|
84
|
+
};
|
|
85
|
+
const addressSchema = {
|
|
86
|
+
title: 'Address',
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
street: { type: 'string', description: 'Street name.' },
|
|
90
|
+
city: { type: 'string', description: 'City name.' },
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
const complexEventSchema = {
|
|
94
|
+
title: 'Complex Event',
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
$schema: { type: 'string', description: 'Should now be included.' },
|
|
98
|
+
event: { type: 'string', const: 'user_update' },
|
|
99
|
+
user_data: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
user_id: { type: 'string', description: 'The user ID.' },
|
|
103
|
+
addresses: {
|
|
104
|
+
type: 'array',
|
|
105
|
+
description: 'List of addresses.',
|
|
106
|
+
items: { $ref: './components/address.json' },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
timestamp: { type: 'number', description: 'Event timestamp.' },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
const mockFileContents = {
|
|
114
|
+
[path.join(SCHEMA_PATH, 'complex-event.json')]:
|
|
115
|
+
JSON.stringify(complexEventSchema),
|
|
116
|
+
[path.join(SCHEMA_PATH, 'components', 'address.json')]:
|
|
117
|
+
JSON.stringify(addressSchema),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
fs.readdirSync.mockImplementation((p) => mockFiles[p] || []);
|
|
122
|
+
fs.statSync.mockImplementation((p) => ({
|
|
123
|
+
isDirectory: () => !!mockFiles[p],
|
|
124
|
+
}));
|
|
125
|
+
fs.readFileSync.mockImplementation((p) => mockFileContents[p] || '');
|
|
126
|
+
fs.existsSync.mockImplementation(
|
|
127
|
+
(p) => !!mockFiles[p] || !!mockFileContents[p],
|
|
128
|
+
);
|
|
129
|
+
RefParser.bundle.mockClear();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should find all top-level variables, including $schema', async () => {
|
|
133
|
+
const bundledSchema = JSON.parse(JSON.stringify(complexEventSchema));
|
|
134
|
+
bundledSchema.properties.user_data.properties.addresses.items =
|
|
135
|
+
addressSchema;
|
|
136
|
+
RefParser.bundle.mockResolvedValue(bundledSchema);
|
|
137
|
+
|
|
138
|
+
const result = await gtmScript.getVariablesFromSchemas(SCHEMA_PATH, {});
|
|
139
|
+
|
|
140
|
+
const expected = expect.arrayContaining([
|
|
141
|
+
expect.objectContaining({ name: '$schema' }),
|
|
142
|
+
expect.objectContaining({ name: 'event' }),
|
|
143
|
+
expect.objectContaining({ name: 'user_data' }),
|
|
144
|
+
expect.objectContaining({ name: 'user_data.user_id' }),
|
|
145
|
+
expect.objectContaining({ name: 'user_data.addresses' }),
|
|
146
|
+
expect.objectContaining({ name: 'user_data.addresses.0.street' }),
|
|
147
|
+
expect.objectContaining({ name: 'user_data.addresses.0.city' }),
|
|
148
|
+
expect.objectContaining({ name: 'timestamp' }),
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
expect(result.length).toBe(8);
|
|
152
|
+
expect(result).toEqual(expected);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should skip array sub-properties when skipArraySubProperties is true', async () => {
|
|
156
|
+
const bundledSchema = JSON.parse(JSON.stringify(complexEventSchema));
|
|
157
|
+
bundledSchema.properties.user_data.properties.addresses.items =
|
|
158
|
+
addressSchema;
|
|
159
|
+
RefParser.bundle.mockResolvedValue(bundledSchema);
|
|
160
|
+
|
|
161
|
+
const result = await gtmScript.getVariablesFromSchemas(SCHEMA_PATH, {
|
|
162
|
+
skipArraySubProperties: true,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const expected = [
|
|
166
|
+
'$schema',
|
|
167
|
+
'event',
|
|
168
|
+
'user_data',
|
|
169
|
+
'user_data.user_id',
|
|
170
|
+
'user_data.addresses',
|
|
171
|
+
'timestamp',
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
expect(result.map((r) => r.name)).toEqual(expect.arrayContaining(expected));
|
|
175
|
+
expect(result.length).toBe(expected.length);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('GTM Synchronization Logic', () => {
|
|
180
|
+
const schemaVariables = [
|
|
181
|
+
{ name: '$schema', description: 'The schema version.' },
|
|
182
|
+
{ name: 'event', description: 'The event name.' },
|
|
183
|
+
{ name: 'user_id', description: 'The user ID.' },
|
|
184
|
+
];
|
|
185
|
+
const gtmVariables = [
|
|
186
|
+
{
|
|
187
|
+
name: 'DLV - event',
|
|
188
|
+
variableId: '1',
|
|
189
|
+
type: 'v',
|
|
190
|
+
parameter: [{ key: 'name', value: 'event' }],
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: 'DLV - old_variable',
|
|
194
|
+
variableId: '123',
|
|
195
|
+
type: 'v',
|
|
196
|
+
parameter: [{ key: 'name', value: 'old_variable' }],
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
jest.clearAllMocks();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('getVariablesToCreate', () => {
|
|
205
|
+
it('should return variables that are in schema but not in GTM', () => {
|
|
206
|
+
const toCreate = gtmScript.getVariablesToCreate(
|
|
207
|
+
schemaVariables,
|
|
208
|
+
gtmVariables,
|
|
209
|
+
);
|
|
210
|
+
expect(toCreate).toEqual([
|
|
211
|
+
{ name: '$schema', description: 'The schema version.' },
|
|
212
|
+
{ name: 'user_id', description: 'The user ID.' },
|
|
213
|
+
]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe('getVariablesToDelete', () => {
|
|
218
|
+
it('should return variables that are in GTM but not in schema', () => {
|
|
219
|
+
const toDelete = gtmScript.getVariablesToDelete(
|
|
220
|
+
schemaVariables,
|
|
221
|
+
gtmVariables,
|
|
222
|
+
);
|
|
223
|
+
expect(toDelete).toEqual([
|
|
224
|
+
{
|
|
225
|
+
name: 'DLV - old_variable',
|
|
226
|
+
variableId: '123',
|
|
227
|
+
type: 'v',
|
|
228
|
+
parameter: [{ key: 'name', value: 'old_variable' }],
|
|
229
|
+
},
|
|
230
|
+
]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe('createGtmVariables', () => {
|
|
235
|
+
it('should call execSync to create variables and return their names', () => {
|
|
236
|
+
const toCreate = gtmScript.getVariablesToCreate(
|
|
237
|
+
schemaVariables,
|
|
238
|
+
gtmVariables,
|
|
239
|
+
);
|
|
240
|
+
const created = gtmScript.createGtmVariables(toCreate);
|
|
241
|
+
expect(execSync).toHaveBeenCalledTimes(2);
|
|
242
|
+
|
|
243
|
+
const schemaVarConfig = JSON.stringify({
|
|
244
|
+
notes: "References the '$schema' property. The schema version.",
|
|
245
|
+
parameter: [
|
|
246
|
+
{ type: 'INTEGER', key: 'dataLayerVersion', value: '2' },
|
|
247
|
+
{ type: 'BOOLEAN', key: 'setDefaultValue', value: 'false' },
|
|
248
|
+
{ type: 'TEMPLATE', key: 'name', value: '$schema' },
|
|
249
|
+
],
|
|
250
|
+
});
|
|
251
|
+
expect(execSync).toHaveBeenCalledWith(
|
|
252
|
+
`gtm variables create --name "DLV - \\$schema" --type v --config '${schemaVarConfig}' --quiet`,
|
|
253
|
+
{ stdio: 'inherit' },
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const userIdVarConfig = JSON.stringify({
|
|
257
|
+
notes: 'The user ID.',
|
|
258
|
+
parameter: [
|
|
259
|
+
{ type: 'INTEGER', key: 'dataLayerVersion', value: '2' },
|
|
260
|
+
{ type: 'BOOLEAN', key: 'setDefaultValue', value: 'false' },
|
|
261
|
+
{ type: 'TEMPLATE', key: 'name', value: 'user_id' },
|
|
262
|
+
],
|
|
263
|
+
});
|
|
264
|
+
expect(execSync).toHaveBeenCalledWith(
|
|
265
|
+
`gtm variables create --name "DLV - user_id" --type v --config '${userIdVarConfig}' --quiet`,
|
|
266
|
+
{ stdio: 'inherit' },
|
|
267
|
+
);
|
|
268
|
+
expect(created).toEqual(['$schema', 'user_id']);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe('deleteGtmVariables', () => {
|
|
273
|
+
it('should call execSync to delete variables and return their names', () => {
|
|
274
|
+
const toDelete = gtmScript.getVariablesToDelete(
|
|
275
|
+
schemaVariables,
|
|
276
|
+
gtmVariables,
|
|
277
|
+
);
|
|
278
|
+
const deleted = gtmScript.deleteGtmVariables(toDelete);
|
|
279
|
+
expect(execSync).toHaveBeenCalledTimes(1);
|
|
280
|
+
expect(execSync).toHaveBeenCalledWith(
|
|
281
|
+
'gtm variables delete --variable-id 123 --force --quiet',
|
|
282
|
+
{ stdio: 'inherit' },
|
|
283
|
+
);
|
|
284
|
+
expect(deleted).toEqual(['old_variable']);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe('main function', () => {
|
|
290
|
+
let mockDeps;
|
|
291
|
+
let logSpy;
|
|
292
|
+
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
295
|
+
fs.existsSync.mockReturnValue(true);
|
|
296
|
+
mockDeps = {
|
|
297
|
+
setupGtmWorkspace: jest.fn().mockResolvedValue({
|
|
298
|
+
workspaceName: 'test-workspace',
|
|
299
|
+
workspaceId: '123',
|
|
300
|
+
}),
|
|
301
|
+
syncGtmVariables: jest.fn().mockResolvedValue({
|
|
302
|
+
created: ['var1'],
|
|
303
|
+
deleted: ['var2'],
|
|
304
|
+
inSync: ['var3'],
|
|
305
|
+
}),
|
|
306
|
+
getVariablesFromSchemas: jest
|
|
307
|
+
.fn()
|
|
308
|
+
.mockResolvedValue([{ name: 'event', description: 'Event name' }]),
|
|
309
|
+
getLatestSchemaPath: jest.fn().mockReturnValue('/fake/schemas'),
|
|
310
|
+
assertGtmCliAvailable: jest.fn(),
|
|
311
|
+
logger: gtmScript.logger,
|
|
312
|
+
parseArgs: gtmScript.parseArgs,
|
|
313
|
+
process: {
|
|
314
|
+
exit: jest.fn(),
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
afterEach(() => {
|
|
320
|
+
jest.restoreAllMocks();
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('should run quietly when --quiet is passed', async () => {
|
|
324
|
+
const argv = ['node', 'script.js', '--quiet'];
|
|
325
|
+
await gtmScript.main(argv, mockDeps);
|
|
326
|
+
expect(logSpy).not.toHaveBeenCalled();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it('should output JSON when --json is passed', async () => {
|
|
330
|
+
const argv = ['node', 'script.js', '--json'];
|
|
331
|
+
await gtmScript.main(argv, mockDeps);
|
|
332
|
+
expect(logSpy).toHaveBeenCalledTimes(1);
|
|
333
|
+
expect(JSON.parse(logSpy.mock.calls[0][0])).toEqual({
|
|
334
|
+
workspace: { workspaceName: 'test-workspace', workspaceId: '123' },
|
|
335
|
+
created: ['var1'],
|
|
336
|
+
deleted: ['var2'],
|
|
337
|
+
inSync: ['var3'],
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('should use the path from --path argument', async () => {
|
|
342
|
+
const argv = ['node', 'script.js', '--path=./my-demo'];
|
|
343
|
+
await gtmScript.main(argv, mockDeps);
|
|
344
|
+
expect(mockDeps.getLatestSchemaPath).toHaveBeenCalledWith('./my-demo');
|
|
345
|
+
});
|
|
346
|
+
});
|
package/index.js
CHANGED
|
@@ -4,6 +4,11 @@ import fs from 'fs';
|
|
|
4
4
|
import validateSchemas from './validateSchemas.js';
|
|
5
5
|
import generateEventDocs from './generateEventDocs.js';
|
|
6
6
|
import path from 'path';
|
|
7
|
+
import { execSync } from 'child_process';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
7
12
|
|
|
8
13
|
export default async function (context, options) {
|
|
9
14
|
const { siteDir } = context;
|
|
@@ -64,6 +69,35 @@ export default async function (context, options) {
|
|
|
64
69
|
updateSchemaIds(siteDir, url, version);
|
|
65
70
|
});
|
|
66
71
|
|
|
72
|
+
cli
|
|
73
|
+
.command('sync-gtm')
|
|
74
|
+
.description('Synchronize GTM Data Layer Variables from JSON schemas')
|
|
75
|
+
.option(
|
|
76
|
+
'--path <siteDir>',
|
|
77
|
+
'Docusaurus site directory that contains static/schemas',
|
|
78
|
+
siteDir,
|
|
79
|
+
)
|
|
80
|
+
.option('--json', 'Output JSON summary')
|
|
81
|
+
.option('--quiet', 'Suppress non-error logs')
|
|
82
|
+
.option(
|
|
83
|
+
'--skip-array-sub-properties',
|
|
84
|
+
'Skip array item sub-properties (e.g., list.0.item)',
|
|
85
|
+
)
|
|
86
|
+
.action((commandOptions) => {
|
|
87
|
+
const scriptPath = path.join(__dirname, 'scripts', 'sync-gtm.js');
|
|
88
|
+
const args = [`--path=${commandOptions.path}`];
|
|
89
|
+
|
|
90
|
+
if (commandOptions.json) args.push('--json');
|
|
91
|
+
if (commandOptions.quiet) args.push('--quiet');
|
|
92
|
+
if (commandOptions.skipArraySubProperties)
|
|
93
|
+
args.push('--skip-array-sub-properties');
|
|
94
|
+
|
|
95
|
+
execSync(`node "${scriptPath}" ${args.join(' ')}`, {
|
|
96
|
+
cwd: siteDir,
|
|
97
|
+
stdio: 'inherit',
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
67
101
|
cli
|
|
68
102
|
.command('version-with-schemas <version>')
|
|
69
103
|
.description(
|
package/package.json
CHANGED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const RefParser = require('@apidevtools/json-schema-ref-parser');
|
|
5
|
+
const mergeAllOf = require('json-schema-merge-allof');
|
|
6
|
+
|
|
7
|
+
const logger = {
|
|
8
|
+
_isQuiet: false,
|
|
9
|
+
_isJson: false,
|
|
10
|
+
setup: function (isJson, isQuiet) {
|
|
11
|
+
this._isQuiet = isQuiet;
|
|
12
|
+
this._isJson = isJson;
|
|
13
|
+
},
|
|
14
|
+
log: function (...args) {
|
|
15
|
+
if (!this._isQuiet && !this._isJson) {
|
|
16
|
+
console.log(...args);
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
error: function (...args) {
|
|
20
|
+
if (!this._isQuiet) {
|
|
21
|
+
console.error(...args);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function assertGtmCliAvailable() {
|
|
27
|
+
try {
|
|
28
|
+
execSync('gtm --version', { stdio: 'pipe' });
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'GTM CLI is not installed or not available in PATH. Install it with "npm install --save-optional @owntag/gtm-cli" (or "npm install -g @owntag/gtm-cli").',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function safeJsonParse(cliOutput) {
|
|
37
|
+
if (!cliOutput) return null;
|
|
38
|
+
const arrayStartIndex = cliOutput.indexOf('[');
|
|
39
|
+
const arrayEndIndex = cliOutput.lastIndexOf(']');
|
|
40
|
+
if (arrayStartIndex !== -1 && arrayEndIndex !== -1) {
|
|
41
|
+
const jsonString = cliOutput.substring(arrayStartIndex, arrayEndIndex + 1);
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(jsonString);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const objStartIndex = cliOutput.indexOf('{');
|
|
49
|
+
const objEndIndex = cliOutput.lastIndexOf('}');
|
|
50
|
+
if (objStartIndex !== -1 && objEndIndex !== -1) {
|
|
51
|
+
const jsonString = cliOutput.substring(objStartIndex, objEndIndex + 1);
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(jsonString);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getLatestSchemaPath(siteDir = '.') {
|
|
62
|
+
const versionsJsonPath = path.join(siteDir, 'versions.json');
|
|
63
|
+
const schemasBasePath = path.join(siteDir, 'static', 'schemas');
|
|
64
|
+
if (fs.existsSync(versionsJsonPath)) {
|
|
65
|
+
const versions = JSON.parse(fs.readFileSync(versionsJsonPath, 'utf-8'));
|
|
66
|
+
if (versions.length > 0) return path.join(schemasBasePath, versions[0]);
|
|
67
|
+
}
|
|
68
|
+
const nextVersionPath = path.join(schemasBasePath, 'next');
|
|
69
|
+
if (fs.existsSync(nextVersionPath)) return nextVersionPath;
|
|
70
|
+
return schemasBasePath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function findJsonFiles(dir) {
|
|
74
|
+
let results = [];
|
|
75
|
+
if (!fs.existsSync(dir)) return results;
|
|
76
|
+
const list = fs.readdirSync(dir);
|
|
77
|
+
list.forEach((file) => {
|
|
78
|
+
const filePath = path.join(dir, file);
|
|
79
|
+
const stat = fs.statSync(filePath);
|
|
80
|
+
if (stat && stat.isDirectory()) {
|
|
81
|
+
results = results.concat(findJsonFiles(filePath));
|
|
82
|
+
} else if (path.extname(filePath) === '.json') {
|
|
83
|
+
results.push(filePath);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseSchema(schema, options, prefix = '') {
|
|
90
|
+
if (!schema || !schema.properties) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let variables = [];
|
|
95
|
+
for (const key in schema.properties) {
|
|
96
|
+
const property = schema.properties[key];
|
|
97
|
+
const currentPath = prefix ? `${prefix}.${key}` : key;
|
|
98
|
+
variables.push({
|
|
99
|
+
name: currentPath,
|
|
100
|
+
description: property.description,
|
|
101
|
+
type: property.type,
|
|
102
|
+
});
|
|
103
|
+
if (
|
|
104
|
+
property.type === 'array' &&
|
|
105
|
+
property.items &&
|
|
106
|
+
!options.skipArraySubProperties
|
|
107
|
+
) {
|
|
108
|
+
if (property.items.properties) {
|
|
109
|
+
variables.push(
|
|
110
|
+
...parseSchema(property.items, options, `${currentPath}.0`),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
} else if (property.type === 'object' && property.properties) {
|
|
114
|
+
variables.push(...parseSchema(property, options, currentPath));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return variables;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function getVariablesFromSchemas(
|
|
121
|
+
schemaPath,
|
|
122
|
+
{ skipArraySubProperties = false },
|
|
123
|
+
) {
|
|
124
|
+
const allVariables = new Map();
|
|
125
|
+
const jsonFiles = findJsonFiles(schemaPath);
|
|
126
|
+
const eventFiles = jsonFiles.filter((f) => !f.includes('components'));
|
|
127
|
+
|
|
128
|
+
for (const file of eventFiles) {
|
|
129
|
+
try {
|
|
130
|
+
let schema = await RefParser.bundle(file);
|
|
131
|
+
schema = mergeAllOf(schema);
|
|
132
|
+
const fileVariables = parseSchema(schema, { skipArraySubProperties });
|
|
133
|
+
for (const variable of fileVariables) {
|
|
134
|
+
if (!allVariables.has(variable.name)) {
|
|
135
|
+
allVariables.set(variable.name, variable);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
logger.error(`Error processing schema ${file}:`, e);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return Array.from(allVariables.values());
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getGtmVariables() {
|
|
146
|
+
logger.log('Fetching existing GTM variables...');
|
|
147
|
+
const gtmVariablesOutput = execSync(
|
|
148
|
+
'gtm variables list -o json --quiet',
|
|
149
|
+
).toString();
|
|
150
|
+
return safeJsonParse(gtmVariablesOutput) || [];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function getVariablesToCreate(schemaVariables, gtmVariables) {
|
|
154
|
+
const gtmVarMap = new Map();
|
|
155
|
+
for (const gtmVar of gtmVariables) {
|
|
156
|
+
const nameParam = gtmVar.parameter?.find((p) => p.key === 'name');
|
|
157
|
+
if (gtmVar.type === 'v' && nameParam?.value) {
|
|
158
|
+
gtmVarMap.set(nameParam.value, gtmVar);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return schemaVariables.filter((sv) => !gtmVarMap.has(sv.name));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getVariablesToDelete(schemaVariables, gtmVariables) {
|
|
165
|
+
const schemaVarMap = new Map(schemaVariables.map((v) => [v.name, v]));
|
|
166
|
+
return gtmVariables.filter((gv) => {
|
|
167
|
+
const nameParam = gv.parameter?.find((p) => p.key === 'name');
|
|
168
|
+
return (
|
|
169
|
+
gv.type === 'v' &&
|
|
170
|
+
nameParam?.value &&
|
|
171
|
+
!schemaVarMap.has(nameParam.value) &&
|
|
172
|
+
gv.name.startsWith('DLV -')
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function createGtmVariables(variablesToCreate) {
|
|
178
|
+
logger.log(`Found ${variablesToCreate.length} variables to create.`);
|
|
179
|
+
for (const v of variablesToCreate) {
|
|
180
|
+
const isSchemaVar = v.name === '$schema';
|
|
181
|
+
const name = `DLV - ${v.name.replace(/\$/g, '\\$')}`;
|
|
182
|
+
const notes = isSchemaVar
|
|
183
|
+
? `References the '$schema' property. ${(v.description || '').replace(
|
|
184
|
+
/'/g,
|
|
185
|
+
'`',
|
|
186
|
+
)}`
|
|
187
|
+
: (v.description || '').replace(/'/g, '`');
|
|
188
|
+
|
|
189
|
+
const config = {
|
|
190
|
+
notes,
|
|
191
|
+
parameter: [
|
|
192
|
+
{ type: 'INTEGER', key: 'dataLayerVersion', value: '2' },
|
|
193
|
+
{ type: 'BOOLEAN', key: 'setDefaultValue', value: 'false' },
|
|
194
|
+
{ type: 'TEMPLATE', key: 'name', value: v.name },
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
const command = `gtm variables create --name "${name}" --type v --config '${JSON.stringify(
|
|
198
|
+
config,
|
|
199
|
+
)}' --quiet`;
|
|
200
|
+
logger.log(`Executing: ${command}`);
|
|
201
|
+
execSync(command, { stdio: 'inherit' });
|
|
202
|
+
}
|
|
203
|
+
return variablesToCreate.map((v) => v.name);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function deleteGtmVariables(variablesToDelete) {
|
|
207
|
+
logger.log(`Found ${variablesToDelete.length} variables to delete.`);
|
|
208
|
+
for (const v of variablesToDelete) {
|
|
209
|
+
const command = `gtm variables delete --variable-id ${v.variableId} --force --quiet`;
|
|
210
|
+
logger.log(`Executing: ${command}`);
|
|
211
|
+
execSync(command, { stdio: 'inherit' });
|
|
212
|
+
}
|
|
213
|
+
return variablesToDelete.map(
|
|
214
|
+
(v) => v.parameter.find((p) => p.key === 'name').value,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function syncGtmVariables(
|
|
219
|
+
schemaVariables,
|
|
220
|
+
{ skipArraySubProperties = false },
|
|
221
|
+
) {
|
|
222
|
+
const gtmVariables = getGtmVariables();
|
|
223
|
+
|
|
224
|
+
let finalSchemaVariables = schemaVariables;
|
|
225
|
+
if (skipArraySubProperties) {
|
|
226
|
+
finalSchemaVariables = schemaVariables.filter(
|
|
227
|
+
(v) => !v.name.includes('.0.'),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const toCreate = getVariablesToCreate(finalSchemaVariables, gtmVariables);
|
|
232
|
+
const toDelete = getVariablesToDelete(finalSchemaVariables, gtmVariables);
|
|
233
|
+
const inSync = schemaVariables.filter(
|
|
234
|
+
(s) => !toCreate.find((c) => c.name === s.name),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const created = createGtmVariables(toCreate);
|
|
238
|
+
const deleted = deleteGtmVariables(toDelete);
|
|
239
|
+
|
|
240
|
+
logger.log('GTM variable synchronization complete.');
|
|
241
|
+
return {
|
|
242
|
+
created,
|
|
243
|
+
deleted,
|
|
244
|
+
inSync: inSync.map((v) => v.name),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function setupGtmWorkspace() {
|
|
249
|
+
const branchName =
|
|
250
|
+
process.env.GTM_WORKSPACE_BRANCH ||
|
|
251
|
+
execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
|
|
252
|
+
const workspaceName = `feat/${branchName.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
|
253
|
+
|
|
254
|
+
logger.log('Listing GTM workspaces to find and delete existing...');
|
|
255
|
+
const workspacesOutput = execSync(
|
|
256
|
+
'gtm workspaces list -o json --quiet',
|
|
257
|
+
).toString();
|
|
258
|
+
const workspaces = safeJsonParse(workspacesOutput) || [];
|
|
259
|
+
const existingWorkspace = workspaces.find((w) => w.name === workspaceName);
|
|
260
|
+
|
|
261
|
+
if (existingWorkspace) {
|
|
262
|
+
logger.log(
|
|
263
|
+
`Found and deleting existing GTM workspace named "${workspaceName}" with ID: ${existingWorkspace.workspaceId}`,
|
|
264
|
+
);
|
|
265
|
+
execSync(
|
|
266
|
+
`gtm workspaces delete --workspace-id ${existingWorkspace.workspaceId} --force --quiet`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
logger.log(`Creating GTM workspace named "${workspaceName}"...`);
|
|
271
|
+
const createWorkspaceOutput = execSync(
|
|
272
|
+
`gtm workspaces create --name "${workspaceName}" -o json --quiet`,
|
|
273
|
+
).toString();
|
|
274
|
+
const newWorkspace = safeJsonParse(createWorkspaceOutput);
|
|
275
|
+
const workspaceId =
|
|
276
|
+
newWorkspace &&
|
|
277
|
+
(Array.isArray(newWorkspace)
|
|
278
|
+
? newWorkspace[0]?.workspaceId
|
|
279
|
+
: newWorkspace.workspaceId);
|
|
280
|
+
|
|
281
|
+
if (!workspaceId) {
|
|
282
|
+
throw new Error('Failed to create GTM workspace or parse its ID.');
|
|
283
|
+
}
|
|
284
|
+
logger.log(`Successfully created workspace with ID: ${workspaceId}`);
|
|
285
|
+
|
|
286
|
+
execSync(`gtm config set defaultWorkspaceId ${workspaceId}`);
|
|
287
|
+
logger.log(`Set default workspace to ${workspaceId}`);
|
|
288
|
+
return { workspaceName, workspaceId };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function parseArgs(argv) {
|
|
292
|
+
const args = argv.slice(2);
|
|
293
|
+
const pathArg = args.find((arg) => arg.startsWith('--path=')) || '';
|
|
294
|
+
const siteDir = pathArg.split('=')[1] || '.';
|
|
295
|
+
return {
|
|
296
|
+
isJson: args.includes('--json'),
|
|
297
|
+
isQuiet: args.includes('--quiet'),
|
|
298
|
+
skipArraySubProperties: args.includes('--skip-array-sub-properties'),
|
|
299
|
+
siteDir,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function main(argv, deps) {
|
|
304
|
+
try {
|
|
305
|
+
const {
|
|
306
|
+
setupGtmWorkspace: setup,
|
|
307
|
+
getLatestSchemaPath: getPath,
|
|
308
|
+
getVariablesFromSchemas: getVars,
|
|
309
|
+
syncGtmVariables: sync,
|
|
310
|
+
assertGtmCliAvailable: assertCli = assertGtmCliAvailable,
|
|
311
|
+
logger: log,
|
|
312
|
+
parseArgs: parse,
|
|
313
|
+
process: proc,
|
|
314
|
+
} = deps;
|
|
315
|
+
const { isJson, isQuiet, skipArraySubProperties, siteDir } = parse(argv);
|
|
316
|
+
log.setup(isJson, isQuiet);
|
|
317
|
+
|
|
318
|
+
log.log('Starting GTM variable synchronization script...');
|
|
319
|
+
assertCli();
|
|
320
|
+
|
|
321
|
+
const { workspaceName, workspaceId } = await setup();
|
|
322
|
+
|
|
323
|
+
const schemaPath = getPath(siteDir);
|
|
324
|
+
if (!fs.existsSync(schemaPath)) {
|
|
325
|
+
throw new Error(
|
|
326
|
+
`Schema directory not found: ${schemaPath}. Use --path=<siteDir> to point to your Docusaurus project root.`,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
log.log(`Scanning for schemas in: ${schemaPath}`);
|
|
330
|
+
const schemaVariables = await getVars(schemaPath, {
|
|
331
|
+
skipArraySubProperties,
|
|
332
|
+
});
|
|
333
|
+
if (schemaVariables.length === 0) {
|
|
334
|
+
throw new Error(
|
|
335
|
+
`No schema variables found in ${schemaPath}. Aborting to avoid deleting existing GTM variables.`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
log.log(`Found ${schemaVariables.length} variables defined in schemas.`);
|
|
339
|
+
|
|
340
|
+
const summary = await sync(schemaVariables, { skipArraySubProperties });
|
|
341
|
+
|
|
342
|
+
if (isJson) {
|
|
343
|
+
console.log(
|
|
344
|
+
JSON.stringify(
|
|
345
|
+
{ workspace: { workspaceName, workspaceId }, ...summary },
|
|
346
|
+
null,
|
|
347
|
+
2,
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
} else {
|
|
351
|
+
log.log('Synchronization successful!');
|
|
352
|
+
log.log(
|
|
353
|
+
`All changes applied in GTM workspace: "${workspaceName}" (ID: ${workspaceId})`,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
} catch (error) {
|
|
357
|
+
logger.error('An error occurred during GTM synchronization:');
|
|
358
|
+
logger.error(error.message);
|
|
359
|
+
if (deps.process && typeof deps.process.exit === 'function') {
|
|
360
|
+
deps.process.exit(1);
|
|
361
|
+
} else {
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (require.main === module) {
|
|
368
|
+
const dependencies = {
|
|
369
|
+
setupGtmWorkspace,
|
|
370
|
+
getLatestSchemaPath,
|
|
371
|
+
getVariablesFromSchemas,
|
|
372
|
+
syncGtmVariables,
|
|
373
|
+
assertGtmCliAvailable,
|
|
374
|
+
logger,
|
|
375
|
+
parseArgs,
|
|
376
|
+
process,
|
|
377
|
+
};
|
|
378
|
+
main(process.argv, dependencies);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
module.exports = {
|
|
382
|
+
getLatestSchemaPath,
|
|
383
|
+
getVariablesFromSchemas,
|
|
384
|
+
syncGtmVariables,
|
|
385
|
+
main,
|
|
386
|
+
getVariablesToCreate,
|
|
387
|
+
getVariablesToDelete,
|
|
388
|
+
createGtmVariables,
|
|
389
|
+
deleteGtmVariables,
|
|
390
|
+
parseSchema,
|
|
391
|
+
findJsonFiles,
|
|
392
|
+
safeJsonParse,
|
|
393
|
+
logger,
|
|
394
|
+
parseArgs,
|
|
395
|
+
assertGtmCliAvailable,
|
|
396
|
+
setupGtmWorkspace,
|
|
397
|
+
};
|