@openedx/paragon 23.13.0 → 23.14.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.
@@ -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.13.0",
3
+ "version": "23.14.1",
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",