@openedx/paragon 23.12.2 ā 23.14.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.
- package/README.md +1 -0
- package/bin/paragon-scripts.js +38 -1
- package/lib/__tests__/help.test.js +2 -2
- package/lib/__tests__/serve-theme-css.test.js +284 -0
- package/lib/queryParamEncoding.js +66 -0
- package/lib/serve-theme-css.js +317 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -84,6 +84,7 @@ The Paragon CLI (Command Line Interface) is a tool that provides various utility
|
|
|
84
84
|
- `paragon build-tokens`: Build Paragon's design tokens.
|
|
85
85
|
- `paragon replace-variables`: Replace SCSS variables usages or definitions to CSS variables and vice versa in `.scss` files.
|
|
86
86
|
- `paragon build-scss`: Compile Paragon's core and themes SCSS into CSS.
|
|
87
|
+
- `paragon serve-theme-css`: Serve built theme CSS files on a local server as if they were on a CDN.
|
|
87
88
|
|
|
88
89
|
Use `paragon help` to see more information.
|
|
89
90
|
|
package/bin/paragon-scripts.js
CHANGED
|
@@ -5,6 +5,7 @@ const { helpCommand } = require('../lib/help');
|
|
|
5
5
|
const buildTokensCommand = require('../lib/build-tokens');
|
|
6
6
|
const replaceVariablesCommand = require('../lib/replace-variables');
|
|
7
7
|
const buildScssCommand = require('../lib/build-scss');
|
|
8
|
+
const serveThemeCssCommand = require('../lib/serve-theme-css');
|
|
8
9
|
const { sendTrackInfo } = require('../lib/utils');
|
|
9
10
|
const versionCommand = require('../lib/version');
|
|
10
11
|
const migrateToOpenEdxScopeCommand = require('../lib/migrate-to-openedx-scope');
|
|
@@ -188,6 +189,42 @@ const COMMANDS = {
|
|
|
188
189
|
},
|
|
189
190
|
],
|
|
190
191
|
},
|
|
192
|
+
'serve-theme-css': {
|
|
193
|
+
executor: serveThemeCssCommand,
|
|
194
|
+
description: 'Serves theme CSS files on a local server as if they were on a CDN.',
|
|
195
|
+
options: [
|
|
196
|
+
{
|
|
197
|
+
name: '-b, --build-dir',
|
|
198
|
+
description: 'The directory containing built CSS files to serve.',
|
|
199
|
+
defaultValue: './dist',
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: '-p, --port',
|
|
203
|
+
description: 'The port to serve files on.',
|
|
204
|
+
defaultValue: 3000,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: '-h, --host',
|
|
208
|
+
description: 'The host to serve files on.',
|
|
209
|
+
defaultValue: 'localhost',
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: '--cors',
|
|
213
|
+
description: 'Whether to enable CORS headers.',
|
|
214
|
+
defaultValue: true,
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: '-t, --theme-name',
|
|
218
|
+
description: 'The name for the theme in the docs URL.',
|
|
219
|
+
defaultValue: 'Local Theme',
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: '-d, --docs-url',
|
|
223
|
+
description: 'The base URL for the Paragon docs site.',
|
|
224
|
+
defaultValue: 'https://paragon-openedx.netlify.app/',
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
},
|
|
191
228
|
help: {
|
|
192
229
|
executor: (args) => helpCommand(COMMANDS, args),
|
|
193
230
|
parameters: [
|
|
@@ -195,7 +232,7 @@ const COMMANDS = {
|
|
|
195
232
|
name: 'command',
|
|
196
233
|
description: 'Specifies command name.',
|
|
197
234
|
defaultValue: '\'\'',
|
|
198
|
-
choices: '[install-theme|build-tokens|replace-variables|build-scss]',
|
|
235
|
+
choices: '[install-theme|build-tokens|replace-variables|build-scss|serve-theme-css]',
|
|
199
236
|
required: false,
|
|
200
237
|
},
|
|
201
238
|
],
|
|
@@ -60,7 +60,7 @@ describe('helpCommand', () => {
|
|
|
60
60
|
helpCommand(COMMANDS, ['help']);
|
|
61
61
|
expect(console.log).toHaveBeenCalledWith(/* eslint-disable-line no-console */
|
|
62
62
|
expect.stringContaining(
|
|
63
|
-
`${chalk.yellow.bold('command')} ${chalk.grey('[install-theme|build-tokens|replace-variables|build-scss], Default: \'\'')}`,
|
|
63
|
+
`${chalk.yellow.bold('command')} ${chalk.grey('[install-theme|build-tokens|replace-variables|build-scss|serve-theme-css], Default: \'\'')}`,
|
|
64
64
|
),
|
|
65
65
|
);
|
|
66
66
|
});
|
|
@@ -280,7 +280,7 @@ describe('helpCommand', () => {
|
|
|
280
280
|
|
|
281
281
|
expect(console.log).toHaveBeenCalledWith(
|
|
282
282
|
expect.stringContaining(
|
|
283
|
-
`${chalk.yellow.bold('command')} ${chalk.grey('[install-theme|build-tokens|replace-variables|build-scss], Default: \'\'')}`,
|
|
283
|
+
`${chalk.yellow.bold('command')} ${chalk.grey('[install-theme|build-tokens|replace-variables|build-scss|serve-theme-css], Default: \'\'')}`,
|
|
284
284
|
),
|
|
285
285
|
);
|
|
286
286
|
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Specifies command name.'));
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
const serveThemeCssCommand = require('../serve-theme-css');
|
|
5
|
+
|
|
6
|
+
jest.mock('fs');
|
|
7
|
+
jest.mock('http');
|
|
8
|
+
jest.mock('ora', () => jest.fn(() => ({
|
|
9
|
+
start: jest.fn().mockReturnThis(),
|
|
10
|
+
succeed: jest.fn((message) => {
|
|
11
|
+
// Make ora.succeed call console.log so we can capture it
|
|
12
|
+
// eslint-disable-next-line no-console
|
|
13
|
+
console.log(message);
|
|
14
|
+
return this;
|
|
15
|
+
}),
|
|
16
|
+
fail: jest.fn().mockReturnThis(),
|
|
17
|
+
})));
|
|
18
|
+
|
|
19
|
+
describe('serveThemeCssCommand', () => {
|
|
20
|
+
let mockServer;
|
|
21
|
+
let mockListen;
|
|
22
|
+
let mockClose;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks();
|
|
26
|
+
|
|
27
|
+
mockListen = jest.fn((port, host, callback) => {
|
|
28
|
+
if (callback) {
|
|
29
|
+
callback();
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
mockClose = jest.fn((callback) => {
|
|
33
|
+
if (callback) {
|
|
34
|
+
callback();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
mockServer = {
|
|
38
|
+
listen: mockListen,
|
|
39
|
+
on: jest.fn(),
|
|
40
|
+
close: mockClose,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
http.createServer.mockReturnValue(mockServer);
|
|
44
|
+
fs.existsSync.mockReturnValue(true);
|
|
45
|
+
fs.statSync.mockReturnValue({
|
|
46
|
+
isDirectory: () => false,
|
|
47
|
+
size: 1024,
|
|
48
|
+
mtime: { getTime: () => 1234567890 },
|
|
49
|
+
});
|
|
50
|
+
fs.readFileSync.mockReturnValue(JSON.stringify({
|
|
51
|
+
themeUrls: {
|
|
52
|
+
core: {
|
|
53
|
+
paths: {
|
|
54
|
+
default: './core.css',
|
|
55
|
+
minified: './core.min.css',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
defaults: {
|
|
59
|
+
light: 'light',
|
|
60
|
+
},
|
|
61
|
+
variants: {
|
|
62
|
+
light: {
|
|
63
|
+
paths: {
|
|
64
|
+
default: './light.css',
|
|
65
|
+
minified: './light.min.css',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
jest.restoreAllMocks();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should start server with default arguments', async () => {
|
|
78
|
+
const args = [];
|
|
79
|
+
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
80
|
+
|
|
81
|
+
await serveThemeCssCommand(args);
|
|
82
|
+
|
|
83
|
+
expect(http.createServer).toHaveBeenCalled();
|
|
84
|
+
expect(mockListen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
|
|
85
|
+
|
|
86
|
+
// Wait for the setTimeout to complete and output to be generated
|
|
87
|
+
await new Promise((resolve) => { setTimeout(resolve, 1100); });
|
|
88
|
+
|
|
89
|
+
// Verify that the default docs URL is included in the output
|
|
90
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
91
|
+
expect.stringContaining('https://paragon-openedx.netlify.app/'),
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should exit if build directory does not exist', async () => {
|
|
96
|
+
const args = ['--build-dir=./nonexistent'];
|
|
97
|
+
fs.existsSync.mockReturnValue(false);
|
|
98
|
+
|
|
99
|
+
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
|
100
|
+
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
101
|
+
|
|
102
|
+
await serveThemeCssCommand(args);
|
|
103
|
+
|
|
104
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
105
|
+
expect.stringContaining('Error: Build directory'),
|
|
106
|
+
);
|
|
107
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should exit if theme-urls.json does not exist', async () => {
|
|
111
|
+
const args = ['--build-dir=./dist'];
|
|
112
|
+
// Mock that dist exists but theme-urls.json doesn't
|
|
113
|
+
fs.existsSync.mockImplementation((path) => !path.endsWith('theme-urls.json'));
|
|
114
|
+
|
|
115
|
+
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
|
116
|
+
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
117
|
+
|
|
118
|
+
await serveThemeCssCommand(args);
|
|
119
|
+
|
|
120
|
+
// The actual implementation calls console.error twice with separate messages
|
|
121
|
+
expect(mockConsoleError).toHaveBeenNthCalledWith(
|
|
122
|
+
1,
|
|
123
|
+
expect.stringContaining('Error:'),
|
|
124
|
+
);
|
|
125
|
+
expect(mockConsoleError).toHaveBeenNthCalledWith(
|
|
126
|
+
1,
|
|
127
|
+
expect.stringContaining('does not appear to be a valid Paragon dist directory'),
|
|
128
|
+
);
|
|
129
|
+
expect(mockConsoleError).toHaveBeenNthCalledWith(
|
|
130
|
+
2,
|
|
131
|
+
expect.stringContaining('Missing theme-urls.json file'),
|
|
132
|
+
);
|
|
133
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should exit if theme-urls.json is invalid JSON', async () => {
|
|
137
|
+
const args = ['--build-dir=./dist'];
|
|
138
|
+
fs.readFileSync.mockReturnValue('invalid json');
|
|
139
|
+
|
|
140
|
+
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
|
141
|
+
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
142
|
+
|
|
143
|
+
await serveThemeCssCommand(args);
|
|
144
|
+
|
|
145
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
146
|
+
expect.stringContaining('Error: Could not read theme-urls.json file.'),
|
|
147
|
+
);
|
|
148
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should handle server errors', async () => {
|
|
152
|
+
const args = ['--port=3000'];
|
|
153
|
+
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
|
154
|
+
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
155
|
+
|
|
156
|
+
await serveThemeCssCommand(args);
|
|
157
|
+
|
|
158
|
+
// Simulate server error
|
|
159
|
+
const errorCallback = mockServer.on.mock.calls.find(call => call[0] === 'error')[1];
|
|
160
|
+
errorCallback({ code: 'EADDRINUSE' });
|
|
161
|
+
|
|
162
|
+
expect(mockConsoleError).toHaveBeenCalledWith(
|
|
163
|
+
expect.stringContaining('Error: Port 3000 is already in use.'),
|
|
164
|
+
);
|
|
165
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should handle graceful shutdown', async () => {
|
|
169
|
+
const args = [];
|
|
170
|
+
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
|
|
171
|
+
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
172
|
+
|
|
173
|
+
await serveThemeCssCommand(args);
|
|
174
|
+
|
|
175
|
+
// Simulate SIGINT
|
|
176
|
+
const sigintCallback = process.listeners('SIGINT').pop();
|
|
177
|
+
sigintCallback();
|
|
178
|
+
|
|
179
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
180
|
+
expect.stringContaining('Shutting down server...'),
|
|
181
|
+
);
|
|
182
|
+
expect(mockClose).toHaveBeenCalled();
|
|
183
|
+
expect(mockExit).toHaveBeenCalledWith(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should parse command line arguments correctly', async () => {
|
|
187
|
+
const args = [
|
|
188
|
+
'-b', './custom-dist',
|
|
189
|
+
'-p', '8080',
|
|
190
|
+
'-h', '0.0.0.0',
|
|
191
|
+
'--cors=false',
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
await serveThemeCssCommand(args);
|
|
195
|
+
|
|
196
|
+
expect(mockListen).toHaveBeenCalledWith(8080, '0.0.0.0', expect.any(Function));
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should include custom docs URL in output when specified', async () => {
|
|
200
|
+
const args = [
|
|
201
|
+
'--docs-url=https://custom-docs.example.com',
|
|
202
|
+
'--theme-name=Custom Theme',
|
|
203
|
+
];
|
|
204
|
+
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
205
|
+
|
|
206
|
+
await serveThemeCssCommand(args);
|
|
207
|
+
|
|
208
|
+
expect(http.createServer).toHaveBeenCalled();
|
|
209
|
+
expect(mockListen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
|
|
210
|
+
|
|
211
|
+
// Wait for the setTimeout to complete and output to be generated
|
|
212
|
+
await new Promise((resolve) => { setTimeout(resolve, 1100); });
|
|
213
|
+
|
|
214
|
+
// Verify that the custom docs URL is included in the output
|
|
215
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
216
|
+
expect.stringContaining('https://custom-docs.example.com'),
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should display server URL and available theme files', async () => {
|
|
221
|
+
const args = [];
|
|
222
|
+
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
223
|
+
|
|
224
|
+
await serveThemeCssCommand(args);
|
|
225
|
+
|
|
226
|
+
expect(http.createServer).toHaveBeenCalled();
|
|
227
|
+
expect(mockListen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
|
|
228
|
+
|
|
229
|
+
// Wait for the setTimeout to complete and output to be generated
|
|
230
|
+
await new Promise((resolve) => { setTimeout(resolve, 1100); });
|
|
231
|
+
|
|
232
|
+
// Verify server URL is displayed
|
|
233
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
234
|
+
expect.stringContaining('Theme CSS server running at http://localhost:3000'),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
// Verify available theme files section is displayed
|
|
238
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
239
|
+
expect.stringContaining('Available theme files:'),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
// Verify core CSS files are listed
|
|
243
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
244
|
+
expect.stringContaining('Core CSS:'),
|
|
245
|
+
);
|
|
246
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
247
|
+
expect.stringContaining('http://localhost:3000/core.css'),
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Verify theme variants are listed
|
|
251
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
252
|
+
expect.stringContaining('Theme Variants:'),
|
|
253
|
+
);
|
|
254
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
255
|
+
expect.stringContaining('light:'),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Verify theme configuration is listed
|
|
259
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
260
|
+
expect.stringContaining('Theme Configuration:'),
|
|
261
|
+
);
|
|
262
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
263
|
+
expect.stringContaining('http://localhost:3000/theme-urls.json'),
|
|
264
|
+
);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('should display shutdown instructions', async () => {
|
|
268
|
+
const args = [];
|
|
269
|
+
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
270
|
+
|
|
271
|
+
await serveThemeCssCommand(args);
|
|
272
|
+
|
|
273
|
+
expect(http.createServer).toHaveBeenCalled();
|
|
274
|
+
expect(mockListen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
|
|
275
|
+
|
|
276
|
+
// Wait for the setTimeout to complete and output to be generated
|
|
277
|
+
await new Promise((resolve) => { setTimeout(resolve, 1100); });
|
|
278
|
+
|
|
279
|
+
// Verify shutdown instructions are displayed
|
|
280
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(
|
|
281
|
+
expect.stringContaining('Press Ctrl+C to stop the server'),
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const { compressToEncodedURIComponent, decompressFromEncodedURIComponent } = require('lz-string');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} ThemeState
|
|
5
|
+
* @property {Array<{name: string, urls: string[]}>} themes - Array of theme configurations
|
|
6
|
+
* @property {number} activeIndex - Index of the currently active theme
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Encodes theme state (themes array + active index) as a highly compressed string for use in a query param.
|
|
11
|
+
* Uses shorthand keys and LZ-String compression.
|
|
12
|
+
*
|
|
13
|
+
* @param {Array<{name: string, urls: string[]}>} themes
|
|
14
|
+
* @param {number} activeIndex
|
|
15
|
+
* @returns {string}
|
|
16
|
+
*/
|
|
17
|
+
function encodeThemesToQueryParam(themes, activeIndex) {
|
|
18
|
+
const shortThemes = themes.map(theme => ({
|
|
19
|
+
n: theme.name,
|
|
20
|
+
u: theme.urls,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const shortState = {
|
|
24
|
+
t: shortThemes,
|
|
25
|
+
i: activeIndex,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const json = JSON.stringify(shortState);
|
|
29
|
+
return compressToEncodedURIComponent(json);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decodes a compressed query param value into theme state (themes array + active index).
|
|
34
|
+
* Handles LZ-String decompression and shorthand key expansion.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} encoded
|
|
37
|
+
* @returns {ThemeState}
|
|
38
|
+
*/
|
|
39
|
+
function decodeThemesFromQueryParam(encoded) {
|
|
40
|
+
try {
|
|
41
|
+
const decompressed = decompressFromEncodedURIComponent(encoded);
|
|
42
|
+
if (!decompressed) {
|
|
43
|
+
return { themes: [], activeIndex: 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const shortState = JSON.parse(decompressed);
|
|
47
|
+
const fullThemes = (shortState.t || []).map(shortTheme => ({
|
|
48
|
+
name: shortTheme.n,
|
|
49
|
+
urls: shortTheme.u,
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
themes: fullThemes,
|
|
54
|
+
activeIndex: shortState.i || 0,
|
|
55
|
+
};
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// eslint-disable-next-line no-console
|
|
58
|
+
console.error('Error decoding theme query param:', error);
|
|
59
|
+
return { themes: [], activeIndex: 0 };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
encodeThemesToQueryParam,
|
|
65
|
+
decodeThemesFromQueryParam,
|
|
66
|
+
};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const url = require('url');
|
|
6
|
+
const minimist = require('minimist');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
const ora = require('ora');
|
|
9
|
+
|
|
10
|
+
const { encodeThemesToQueryParam } = require('./queryParamEncoding');
|
|
11
|
+
|
|
12
|
+
// Constants
|
|
13
|
+
const DEFAULT_THEME_NAME = 'Local Theme';
|
|
14
|
+
const DEFAULT_DOCS_URL = 'https://paragon-openedx.netlify.app/';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Generates a docs URL with encoded themes parameter
|
|
18
|
+
*/
|
|
19
|
+
function generateDocsUrl(themeUrls, host, port, themeName, docsBaseUrl) {
|
|
20
|
+
if (!themeUrls) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const themes = [];
|
|
25
|
+
const activeIndex = 1; // Set active index to 1 since we'll add default theme first
|
|
26
|
+
|
|
27
|
+
// Always include the default Open edX theme first (index 0)
|
|
28
|
+
themes.push({
|
|
29
|
+
name: 'Open edX (Default)',
|
|
30
|
+
urls: [],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Create a single theme variant that includes all available CSS files
|
|
34
|
+
const themeUrlList = [];
|
|
35
|
+
|
|
36
|
+
// Add core.css if it exists
|
|
37
|
+
if (themeUrls.core) {
|
|
38
|
+
const corePath = themeUrls.core.paths.default.replace(/^\.\//, '');
|
|
39
|
+
themeUrlList.push(`http://${host}:${port}/${corePath}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Add all theme variants if they exist
|
|
43
|
+
if (themeUrls.variants) {
|
|
44
|
+
Object.entries(themeUrls.variants).forEach(([, themeData]) => {
|
|
45
|
+
const themePath = themeData.paths.default.replace(/^\.\//, '');
|
|
46
|
+
themeUrlList.push(`http://${host}:${port}/${themePath}`);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (themeUrlList.length === 0) {
|
|
51
|
+
// If no local themes, just return URL with default theme
|
|
52
|
+
const encodedThemes = encodeThemesToQueryParam(themes, 0);
|
|
53
|
+
return `${docsBaseUrl}?themes=${encodedThemes}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Create a single theme with all the combined URLs (index 1)
|
|
57
|
+
themes.push({
|
|
58
|
+
name: themeName,
|
|
59
|
+
urls: themeUrlList,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const encodedThemes = encodeThemesToQueryParam(themes, activeIndex);
|
|
63
|
+
return `${docsBaseUrl}?themes=${encodedThemes}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Serves theme CSS files on a local server as if they were on a CDN.
|
|
68
|
+
*
|
|
69
|
+
* @param {string[]} commandArgs - Command line arguments for serving theme CSS files.
|
|
70
|
+
* @param {string} [commandArgs.build-dir='./dist'] - The directory containing built CSS files to serve.
|
|
71
|
+
* @param {number} [commandArgs.port=3000] - The port to serve files on.
|
|
72
|
+
* @param {string} [commandArgs.host='localhost'] - The host to serve files on.
|
|
73
|
+
* @param {boolean} [commandArgs.cors=true] - Whether to enable CORS headers.
|
|
74
|
+
* @param {string} [commandArgs.theme-name='Local Theme'] - The name for the theme in the docs URL.
|
|
75
|
+
* @param {string} [commandArgs.docs-url] - The base URL for the Paragon docs site.
|
|
76
|
+
*/
|
|
77
|
+
async function serveThemeCssCommand(commandArgs) {
|
|
78
|
+
const defaultArgs = {
|
|
79
|
+
'build-dir': './dist',
|
|
80
|
+
port: 3000,
|
|
81
|
+
host: 'localhost',
|
|
82
|
+
cors: true,
|
|
83
|
+
'theme-name': DEFAULT_THEME_NAME,
|
|
84
|
+
'docs-url': DEFAULT_DOCS_URL,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const alias = {
|
|
88
|
+
'build-dir': 'b',
|
|
89
|
+
port: 'p',
|
|
90
|
+
host: 'h',
|
|
91
|
+
'theme-name': 't',
|
|
92
|
+
'docs-url': 'd',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const {
|
|
96
|
+
'build-dir': buildDir,
|
|
97
|
+
port,
|
|
98
|
+
host,
|
|
99
|
+
cors,
|
|
100
|
+
'theme-name': themeName,
|
|
101
|
+
'docs-url': docsUrl,
|
|
102
|
+
} = minimist(commandArgs, {
|
|
103
|
+
alias,
|
|
104
|
+
default: defaultArgs,
|
|
105
|
+
boolean: ['cors'],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const resolvedBuildDir = path.resolve(process.cwd(), buildDir);
|
|
109
|
+
|
|
110
|
+
// Check if build directory exists
|
|
111
|
+
if (!fs.existsSync(resolvedBuildDir)) {
|
|
112
|
+
console.error(chalk.red.bold(`Error: Build directory '${resolvedBuildDir}' does not exist.`));
|
|
113
|
+
console.error(chalk.yellow('Please run `paragon build-scss` first to generate CSS files.'));
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check for theme-urls.json to validate this is a proper dist directory
|
|
118
|
+
const themeUrlsPath = path.join(resolvedBuildDir, 'theme-urls.json');
|
|
119
|
+
if (!fs.existsSync(themeUrlsPath)) {
|
|
120
|
+
console.error(chalk.red.bold(`Error: '${resolvedBuildDir}' does not appear to be a valid Paragon dist directory.`));
|
|
121
|
+
console.error(chalk.yellow('Missing theme-urls.json file. Please run `paragon build-scss` first.'));
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Read theme-urls.json to understand the available files
|
|
126
|
+
let themeUrls;
|
|
127
|
+
try {
|
|
128
|
+
const themeUrlsContent = fs.readFileSync(themeUrlsPath, 'utf8');
|
|
129
|
+
const themeUrlsJson = JSON.parse(themeUrlsContent);
|
|
130
|
+
themeUrls = themeUrlsJson.themeUrls;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error(chalk.red.bold('Error: Could not read theme-urls.json file.'));
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Create server
|
|
137
|
+
const server = http.createServer((req, res) => {
|
|
138
|
+
const parsedUrl = url.parse(req.url, true);
|
|
139
|
+
let filePath = parsedUrl.pathname;
|
|
140
|
+
|
|
141
|
+
// Remove leading slash
|
|
142
|
+
if (filePath.startsWith('/')) {
|
|
143
|
+
filePath = filePath.substring(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Set content type and no-cache headers for development
|
|
147
|
+
const setContentTypeAndNoCache = (contentType) => {
|
|
148
|
+
res.setHeader('Content-Type', contentType);
|
|
149
|
+
// Add no-cache headers for development
|
|
150
|
+
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
151
|
+
res.setHeader('Pragma', 'no-cache');
|
|
152
|
+
res.setHeader('Expires', '0');
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Set CORS headers if enabled
|
|
156
|
+
const setCorsHeaders = () => {
|
|
157
|
+
if (cors) {
|
|
158
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
159
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
160
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Handle theme-urls.json or default to theme-urls.json if no file specified
|
|
165
|
+
if (filePath === 'theme-urls.json' || filePath === '') {
|
|
166
|
+
// Set CORS headers if enabled
|
|
167
|
+
setCorsHeaders();
|
|
168
|
+
|
|
169
|
+
// Set content type and no-cache headers using helper function
|
|
170
|
+
setContentTypeAndNoCache('application/json');
|
|
171
|
+
|
|
172
|
+
res.end(JSON.stringify(themeUrls, null, 2));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Resolve file path relative to build directory
|
|
177
|
+
const fullPath = path.join(resolvedBuildDir, filePath);
|
|
178
|
+
|
|
179
|
+
// Security check: ensure file is within build directory
|
|
180
|
+
if (!fullPath.startsWith(resolvedBuildDir)) {
|
|
181
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
182
|
+
res.end('Forbidden');
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Set CORS headers if enabled
|
|
187
|
+
setCorsHeaders();
|
|
188
|
+
|
|
189
|
+
// Handle OPTIONS requests for CORS preflight
|
|
190
|
+
if (req.method === 'OPTIONS') {
|
|
191
|
+
res.writeHead(200);
|
|
192
|
+
res.end();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Check if file exists
|
|
197
|
+
if (!fs.existsSync(fullPath)) {
|
|
198
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
199
|
+
res.end('File not found');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Get file stats
|
|
204
|
+
const stats = fs.statSync(fullPath);
|
|
205
|
+
|
|
206
|
+
// Handle directories
|
|
207
|
+
if (stats.isDirectory()) {
|
|
208
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
209
|
+
res.end('Directory access not allowed');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Set appropriate content type based on file extension
|
|
214
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
215
|
+
|
|
216
|
+
// Only serve CSS files and theme configuration JSON
|
|
217
|
+
if (ext === '.css') {
|
|
218
|
+
setContentTypeAndNoCache('text/css');
|
|
219
|
+
} else if (ext === '.json' && path.basename(fullPath) === 'theme-urls.json') {
|
|
220
|
+
setContentTypeAndNoCache('application/json');
|
|
221
|
+
} else {
|
|
222
|
+
// Reject all other file types
|
|
223
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' });
|
|
224
|
+
res.end('Only CSS files and theme-urls.json are allowed');
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
res.setHeader('Content-Length', stats.size);
|
|
229
|
+
|
|
230
|
+
// Stream the file
|
|
231
|
+
const fileStream = fs.createReadStream(fullPath);
|
|
232
|
+
fileStream.pipe(res);
|
|
233
|
+
|
|
234
|
+
fileStream.on('error', (error) => {
|
|
235
|
+
console.error(chalk.red(`Error serving file ${filePath}:`, error.message));
|
|
236
|
+
if (!res.headersSent) {
|
|
237
|
+
res.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
238
|
+
res.end('Internal server error');
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Start server
|
|
244
|
+
server.listen(port, host, () => {
|
|
245
|
+
const spinner = ora('Starting theme CSS server...').start();
|
|
246
|
+
|
|
247
|
+
setTimeout(() => {
|
|
248
|
+
spinner.succeed(chalk.green.bold(`Theme CSS server running at http://${host}:${port}`));
|
|
249
|
+
console.log(chalk.cyan('\nAvailable theme files:'));
|
|
250
|
+
|
|
251
|
+
// List available files based on theme-urls.json
|
|
252
|
+
if (themeUrls) {
|
|
253
|
+
// Core files
|
|
254
|
+
if (themeUrls.core) {
|
|
255
|
+
console.log(chalk.yellow('\nCore CSS:'));
|
|
256
|
+
const coreDefault = themeUrls.core.paths.default.replace(/^\.\//, '');
|
|
257
|
+
const coreMinified = themeUrls.core.paths.minified.replace(/^\.\//, '');
|
|
258
|
+
console.log(chalk.gray(` http://${host}:${port}/${coreDefault}`));
|
|
259
|
+
console.log(chalk.gray(` http://${host}:${port}/${coreMinified}`));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Theme variants
|
|
263
|
+
if (themeUrls.variants) {
|
|
264
|
+
console.log(chalk.yellow('\nTheme Variants:'));
|
|
265
|
+
Object.entries(themeUrls.variants).forEach(([variantName, themeData]) => {
|
|
266
|
+
const isDefault = themeUrls.defaults && themeUrls.defaults[variantName];
|
|
267
|
+
const prefix = isDefault ? chalk.green('ā
') : ' ';
|
|
268
|
+
const themeDefault = themeData.paths.default.replace(/^\.\//, '');
|
|
269
|
+
const themeMinified = themeData.paths.minified.replace(/^\.\//, '');
|
|
270
|
+
console.log(chalk.gray(`${prefix}${variantName}:`));
|
|
271
|
+
console.log(chalk.gray(` http://${host}:${port}/${themeDefault}`));
|
|
272
|
+
console.log(chalk.gray(` http://${host}:${port}/${themeMinified}`));
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Theme URLs JSON
|
|
277
|
+
console.log(chalk.yellow('\nTheme Configuration:'));
|
|
278
|
+
console.log(chalk.gray(` http://${host}:${port}/theme-urls.json`));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Show the Paragon docs URL with encoded themes
|
|
282
|
+
const docsUrlWithThemes = generateDocsUrl(themeUrls, host, port, themeName, docsUrl);
|
|
283
|
+
if (docsUrlWithThemes) {
|
|
284
|
+
console.log(chalk.green.bold('\nš Paragon Docs URL with encoded themes:'));
|
|
285
|
+
console.log(chalk.cyan(docsUrlWithThemes));
|
|
286
|
+
console.log(chalk.gray('\nThis URL will load the Paragon docs site with the default Open edX theme and your local theme CSS files pre-configured.'));
|
|
287
|
+
console.log(chalk.gray('The local theme will be active by default, but you can switch to the default Open edX theme using the theme selector.'));
|
|
288
|
+
} else {
|
|
289
|
+
console.log(chalk.yellow('\nā ļø No themes found to encode for docs URL.'));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
console.log(chalk.gray('\nPress Ctrl+C to stop the server'));
|
|
293
|
+
}, 1000);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Handle server errors
|
|
297
|
+
server.on('error', (error) => {
|
|
298
|
+
if (error.code === 'EADDRINUSE') {
|
|
299
|
+
console.error(chalk.red.bold(`Error: Port ${port} is already in use.`));
|
|
300
|
+
console.error(chalk.yellow('Try using a different port with --port option.'));
|
|
301
|
+
} else {
|
|
302
|
+
console.error(chalk.red.bold('Server error:', error.message));
|
|
303
|
+
}
|
|
304
|
+
process.exit(1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Handle graceful shutdown
|
|
308
|
+
process.on('SIGINT', () => {
|
|
309
|
+
console.log(chalk.gray('\nShutting down server...'));
|
|
310
|
+
server.close(() => {
|
|
311
|
+
console.log(chalk.green('Server stopped'));
|
|
312
|
+
process.exit(0);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = serveThemeCssCommand;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openedx/paragon",
|
|
3
|
-
"version": "23.
|
|
3
|
+
"version": "23.14.0",
|
|
4
4
|
"description": "Accessible, responsive UI component library based on Bootstrap.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"start": "npm start --workspace=www",
|
|
40
40
|
"test": "jest --coverage",
|
|
41
41
|
"test:watch": "npm run test -- --watch",
|
|
42
|
+
"test:watch-no-coverage": "npm run test:watch -- --coverage=false",
|
|
42
43
|
"generate-component": "npm start --workspace=component-generator",
|
|
43
44
|
"example:start": "npm start --workspace=example",
|
|
44
45
|
"example:start:with-theme": "npm run start:with-theme --workspace=example",
|
|
@@ -53,6 +54,7 @@
|
|
|
53
54
|
"prepare": "husky || true",
|
|
54
55
|
"build-tokens": "./bin/paragon-scripts.js build-tokens --build-dir ./styles/css",
|
|
55
56
|
"build-tokens:watch": "npx nodemon --ignore styles/css -x \"npm run build-tokens\"",
|
|
57
|
+
"serve-theme-css": "./bin/paragon-scripts.js serve-theme-css --build-dir ./dist --theme-name='Custom Theme Name'",
|
|
56
58
|
"replace-variables-usage-with-css": "./bin/paragon-scripts.js replace-variables -p src -t usage",
|
|
57
59
|
"replace-variables-definition-with-css": "./bin/paragon-scripts.js replace-variables -p src -t definition",
|
|
58
60
|
"cli:help": "./bin/paragon-scripts.js help"
|
|
@@ -75,6 +77,7 @@
|
|
|
75
77
|
"js-toml": "^1.0.0",
|
|
76
78
|
"lodash.uniqby": "^4.7.0",
|
|
77
79
|
"log-update": "^4.0.0",
|
|
80
|
+
"lz-string": "^1.5.0",
|
|
78
81
|
"mailto-link": "^2.0.0",
|
|
79
82
|
"minimist": "^1.2.8",
|
|
80
83
|
"ora": "^5.4.1",
|