feat: add serve-theme-css command to Paragon CLI

Improves the developer experience (DX) around local development for brand packages to be able to serve their built CSS files as if they were hosted on a CDN for use in the recently updated Paragon documentation website theme selector that allows users to create custom themes with external CSS URLs.

Brand packages may now run `paragon serve-theme-css --theme-name "Custom Theme Name"` to generate a Paragon documentation website URL with the local CSS files applied.

---------

Co-authored-by: Adam Stankiewicz <agstanki@gmail.com>
This commit is contained in:
Maxwell Frank 2025-07-03 11:31:15 -04:00 committed by GitHub
parent fd6ec1e00f
commit 62e65ca9af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1012 additions and 104 deletions

View File

@ -84,6 +84,7 @@ The Paragon CLI (Command Line Interface) is a tool that provides various utility
- `paragon build-tokens`: Build Paragon's design tokens.
- `paragon replace-variables`: Replace SCSS variables usages or definitions to CSS variables and vice versa in `.scss` files.
- `paragon build-scss`: Compile Paragon's core and themes SCSS into CSS.
- `paragon serve-theme-css`: Serve built theme CSS files on a local server as if they were on a CDN.
Use `paragon help` to see more information.

View File

@ -5,6 +5,7 @@ const { helpCommand } = require('../lib/help');
const buildTokensCommand = require('../lib/build-tokens');
const replaceVariablesCommand = require('../lib/replace-variables');
const buildScssCommand = require('../lib/build-scss');
const serveThemeCssCommand = require('../lib/serve-theme-css');
const { sendTrackInfo } = require('../lib/utils');
const versionCommand = require('../lib/version');
const migrateToOpenEdxScopeCommand = require('../lib/migrate-to-openedx-scope');
@ -188,6 +189,42 @@ const COMMANDS = {
},
],
},
'serve-theme-css': {
executor: serveThemeCssCommand,
description: 'Serves theme CSS files on a local server as if they were on a CDN.',
options: [
{
name: '-b, --build-dir',
description: 'The directory containing built CSS files to serve.',
defaultValue: './dist',
},
{
name: '-p, --port',
description: 'The port to serve files on.',
defaultValue: 3000,
},
{
name: '-h, --host',
description: 'The host to serve files on.',
defaultValue: 'localhost',
},
{
name: '--cors',
description: 'Whether to enable CORS headers.',
defaultValue: true,
},
{
name: '-t, --theme-name',
description: 'The name for the theme in the docs URL.',
defaultValue: 'Local Theme',
},
{
name: '-d, --docs-url',
description: 'The base URL for the Paragon docs site.',
defaultValue: 'https://paragon-openedx.netlify.app/',
},
],
},
help: {
executor: (args) => helpCommand(COMMANDS, args),
parameters: [
@ -195,7 +232,7 @@ const COMMANDS = {
name: 'command',
description: 'Specifies command name.',
defaultValue: '\'\'',
choices: '[install-theme|build-tokens|replace-variables|build-scss]',
choices: '[install-theme|build-tokens|replace-variables|build-scss|serve-theme-css]',
required: false,
},
],

View File

@ -60,7 +60,7 @@ describe('helpCommand', () => {
helpCommand(COMMANDS, ['help']);
expect(console.log).toHaveBeenCalledWith(/* eslint-disable-line no-console */
expect.stringContaining(
`${chalk.yellow.bold('command')} ${chalk.grey('[install-theme|build-tokens|replace-variables|build-scss], Default: \'\'')}`,
`${chalk.yellow.bold('command')} ${chalk.grey('[install-theme|build-tokens|replace-variables|build-scss|serve-theme-css], Default: \'\'')}`,
),
);
});
@ -280,7 +280,7 @@ describe('helpCommand', () => {
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining(
`${chalk.yellow.bold('command')} ${chalk.grey('[install-theme|build-tokens|replace-variables|build-scss], Default: \'\'')}`,
`${chalk.yellow.bold('command')} ${chalk.grey('[install-theme|build-tokens|replace-variables|build-scss|serve-theme-css], Default: \'\'')}`,
),
);
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Specifies command name.'));

View File

@ -0,0 +1,284 @@
const http = require('http');
const fs = require('fs');
const serveThemeCssCommand = require('../serve-theme-css');
jest.mock('fs');
jest.mock('http');
jest.mock('ora', () => jest.fn(() => ({
start: jest.fn().mockReturnThis(),
succeed: jest.fn((message) => {
// Make ora.succeed call console.log so we can capture it
// eslint-disable-next-line no-console
console.log(message);
return this;
}),
fail: jest.fn().mockReturnThis(),
})));
describe('serveThemeCssCommand', () => {
let mockServer;
let mockListen;
let mockClose;
beforeEach(() => {
jest.clearAllMocks();
mockListen = jest.fn((port, host, callback) => {
if (callback) {
callback();
}
});
mockClose = jest.fn((callback) => {
if (callback) {
callback();
}
});
mockServer = {
listen: mockListen,
on: jest.fn(),
close: mockClose,
};
http.createServer.mockReturnValue(mockServer);
fs.existsSync.mockReturnValue(true);
fs.statSync.mockReturnValue({
isDirectory: () => false,
size: 1024,
mtime: { getTime: () => 1234567890 },
});
fs.readFileSync.mockReturnValue(JSON.stringify({
themeUrls: {
core: {
paths: {
default: './core.css',
minified: './core.min.css',
},
},
defaults: {
light: 'light',
},
variants: {
light: {
paths: {
default: './light.css',
minified: './light.min.css',
},
},
},
},
}));
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should start server with default arguments', async () => {
const args = [];
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
await serveThemeCssCommand(args);
expect(http.createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
// Wait for the setTimeout to complete and output to be generated
await new Promise((resolve) => { setTimeout(resolve, 1100); });
// Verify that the default docs URL is included in the output
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('https://paragon-openedx.netlify.app/'),
);
});
it('should exit if build directory does not exist', async () => {
const args = ['--build-dir=./nonexistent'];
fs.existsSync.mockReturnValue(false);
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
await serveThemeCssCommand(args);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Error: Build directory'),
);
expect(mockExit).toHaveBeenCalledWith(1);
});
it('should exit if theme-urls.json does not exist', async () => {
const args = ['--build-dir=./dist'];
// Mock that dist exists but theme-urls.json doesn't
fs.existsSync.mockImplementation((path) => !path.endsWith('theme-urls.json'));
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
await serveThemeCssCommand(args);
// The actual implementation calls console.error twice with separate messages
expect(mockConsoleError).toHaveBeenNthCalledWith(
1,
expect.stringContaining('Error:'),
);
expect(mockConsoleError).toHaveBeenNthCalledWith(
1,
expect.stringContaining('does not appear to be a valid Paragon dist directory'),
);
expect(mockConsoleError).toHaveBeenNthCalledWith(
2,
expect.stringContaining('Missing theme-urls.json file'),
);
expect(mockExit).toHaveBeenCalledWith(1);
});
it('should exit if theme-urls.json is invalid JSON', async () => {
const args = ['--build-dir=./dist'];
fs.readFileSync.mockReturnValue('invalid json');
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
await serveThemeCssCommand(args);
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Error: Could not read theme-urls.json file.'),
);
expect(mockExit).toHaveBeenCalledWith(1);
});
it('should handle server errors', async () => {
const args = ['--port=3000'];
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
await serveThemeCssCommand(args);
// Simulate server error
const errorCallback = mockServer.on.mock.calls.find(call => call[0] === 'error')[1];
errorCallback({ code: 'EADDRINUSE' });
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining('Error: Port 3000 is already in use.'),
);
expect(mockExit).toHaveBeenCalledWith(1);
});
it('should handle graceful shutdown', async () => {
const args = [];
const mockExit = jest.spyOn(process, 'exit').mockImplementation(() => {});
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
await serveThemeCssCommand(args);
// Simulate SIGINT
const sigintCallback = process.listeners('SIGINT').pop();
sigintCallback();
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('Shutting down server...'),
);
expect(mockClose).toHaveBeenCalled();
expect(mockExit).toHaveBeenCalledWith(0);
});
it('should parse command line arguments correctly', async () => {
const args = [
'-b', './custom-dist',
'-p', '8080',
'-h', '0.0.0.0',
'--cors=false',
];
await serveThemeCssCommand(args);
expect(mockListen).toHaveBeenCalledWith(8080, '0.0.0.0', expect.any(Function));
});
it('should include custom docs URL in output when specified', async () => {
const args = [
'--docs-url=https://custom-docs.example.com',
'--theme-name=Custom Theme',
];
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
await serveThemeCssCommand(args);
expect(http.createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
// Wait for the setTimeout to complete and output to be generated
await new Promise((resolve) => { setTimeout(resolve, 1100); });
// Verify that the custom docs URL is included in the output
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('https://custom-docs.example.com'),
);
});
it('should display server URL and available theme files', async () => {
const args = [];
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
await serveThemeCssCommand(args);
expect(http.createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
// Wait for the setTimeout to complete and output to be generated
await new Promise((resolve) => { setTimeout(resolve, 1100); });
// Verify server URL is displayed
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('Theme CSS server running at http://localhost:3000'),
);
// Verify available theme files section is displayed
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('Available theme files:'),
);
// Verify core CSS files are listed
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('Core CSS:'),
);
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('http://localhost:3000/core.css'),
);
// Verify theme variants are listed
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('Theme Variants:'),
);
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('light:'),
);
// Verify theme configuration is listed
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('Theme Configuration:'),
);
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('http://localhost:3000/theme-urls.json'),
);
});
it('should display shutdown instructions', async () => {
const args = [];
const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
await serveThemeCssCommand(args);
expect(http.createServer).toHaveBeenCalled();
expect(mockListen).toHaveBeenCalledWith(3000, 'localhost', expect.any(Function));
// Wait for the setTimeout to complete and output to be generated
await new Promise((resolve) => { setTimeout(resolve, 1100); });
// Verify shutdown instructions are displayed
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining('Press Ctrl+C to stop the server'),
);
});
});

66
lib/queryParamEncoding.js Normal file
View File

@ -0,0 +1,66 @@
const { compressToEncodedURIComponent, decompressFromEncodedURIComponent } = require('lz-string');
/**
* @typedef {Object} ThemeState
* @property {Array<{name: string, urls: string[]}>} themes - Array of theme configurations
* @property {number} activeIndex - Index of the currently active theme
*/
/**
* Encodes theme state (themes array + active index) as a highly compressed string for use in a query param.
* Uses shorthand keys and LZ-String compression.
*
* @param {Array<{name: string, urls: string[]}>} themes
* @param {number} activeIndex
* @returns {string}
*/
function encodeThemesToQueryParam(themes, activeIndex) {
const shortThemes = themes.map(theme => ({
n: theme.name,
u: theme.urls,
}));
const shortState = {
t: shortThemes,
i: activeIndex,
};
const json = JSON.stringify(shortState);
return compressToEncodedURIComponent(json);
}
/**
* Decodes a compressed query param value into theme state (themes array + active index).
* Handles LZ-String decompression and shorthand key expansion.
*
* @param {string} encoded
* @returns {ThemeState}
*/
function decodeThemesFromQueryParam(encoded) {
try {
const decompressed = decompressFromEncodedURIComponent(encoded);
if (!decompressed) {
return { themes: [], activeIndex: 0 };
}
const shortState = JSON.parse(decompressed);
const fullThemes = (shortState.t || []).map(shortTheme => ({
name: shortTheme.n,
urls: shortTheme.u,
}));
return {
themes: fullThemes,
activeIndex: shortState.i || 0,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error decoding theme query param:', error);
return { themes: [], activeIndex: 0 };
}
}
module.exports = {
encodeThemesToQueryParam,
decodeThemesFromQueryParam,
};

317
lib/serve-theme-css.js Normal file
View File

@ -0,0 +1,317 @@
/* eslint-disable no-console */
const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const minimist = require('minimist');
const chalk = require('chalk');
const ora = require('ora');
const { encodeThemesToQueryParam } = require('./queryParamEncoding');
// Constants
const DEFAULT_THEME_NAME = 'Local Theme';
const DEFAULT_DOCS_URL = 'https://paragon-openedx.netlify.app/';
/**
* Generates a docs URL with encoded themes parameter
*/
function generateDocsUrl(themeUrls, host, port, themeName, docsBaseUrl) {
if (!themeUrls) {
return null;
}
const themes = [];
const activeIndex = 1; // Set active index to 1 since we'll add default theme first
// Always include the default Open edX theme first (index 0)
themes.push({
name: 'Open edX (Default)',
urls: [],
});
// Create a single theme variant that includes all available CSS files
const themeUrlList = [];
// Add core.css if it exists
if (themeUrls.core) {
const corePath = themeUrls.core.paths.default.replace(/^\.\//, '');
themeUrlList.push(`http://${host}:${port}/${corePath}`);
}
// Add all theme variants if they exist
if (themeUrls.variants) {
Object.entries(themeUrls.variants).forEach(([, themeData]) => {
const themePath = themeData.paths.default.replace(/^\.\//, '');
themeUrlList.push(`http://${host}:${port}/${themePath}`);
});
}
if (themeUrlList.length === 0) {
// If no local themes, just return URL with default theme
const encodedThemes = encodeThemesToQueryParam(themes, 0);
return `${docsBaseUrl}?themes=${encodedThemes}`;
}
// Create a single theme with all the combined URLs (index 1)
themes.push({
name: themeName,
urls: themeUrlList,
});
const encodedThemes = encodeThemesToQueryParam(themes, activeIndex);
return `${docsBaseUrl}?themes=${encodedThemes}`;
}
/**
* Serves theme CSS files on a local server as if they were on a CDN.
*
* @param {string[]} commandArgs - Command line arguments for serving theme CSS files.
* @param {string} [commandArgs.build-dir='./dist'] - The directory containing built CSS files to serve.
* @param {number} [commandArgs.port=3000] - The port to serve files on.
* @param {string} [commandArgs.host='localhost'] - The host to serve files on.
* @param {boolean} [commandArgs.cors=true] - Whether to enable CORS headers.
* @param {string} [commandArgs.theme-name='Local Theme'] - The name for the theme in the docs URL.
* @param {string} [commandArgs.docs-url] - The base URL for the Paragon docs site.
*/
async function serveThemeCssCommand(commandArgs) {
const defaultArgs = {
'build-dir': './dist',
port: 3000,
host: 'localhost',
cors: true,
'theme-name': DEFAULT_THEME_NAME,
'docs-url': DEFAULT_DOCS_URL,
};
const alias = {
'build-dir': 'b',
port: 'p',
host: 'h',
'theme-name': 't',
'docs-url': 'd',
};
const {
'build-dir': buildDir,
port,
host,
cors,
'theme-name': themeName,
'docs-url': docsUrl,
} = minimist(commandArgs, {
alias,
default: defaultArgs,
boolean: ['cors'],
});
const resolvedBuildDir = path.resolve(process.cwd(), buildDir);
// Check if build directory exists
if (!fs.existsSync(resolvedBuildDir)) {
console.error(chalk.red.bold(`Error: Build directory '${resolvedBuildDir}' does not exist.`));
console.error(chalk.yellow('Please run `paragon build-scss` first to generate CSS files.'));
process.exit(1);
}
// Check for theme-urls.json to validate this is a proper dist directory
const themeUrlsPath = path.join(resolvedBuildDir, 'theme-urls.json');
if (!fs.existsSync(themeUrlsPath)) {
console.error(chalk.red.bold(`Error: '${resolvedBuildDir}' does not appear to be a valid Paragon dist directory.`));
console.error(chalk.yellow('Missing theme-urls.json file. Please run `paragon build-scss` first.'));
process.exit(1);
}
// Read theme-urls.json to understand the available files
let themeUrls;
try {
const themeUrlsContent = fs.readFileSync(themeUrlsPath, 'utf8');
const themeUrlsJson = JSON.parse(themeUrlsContent);
themeUrls = themeUrlsJson.themeUrls;
} catch (error) {
console.error(chalk.red.bold('Error: Could not read theme-urls.json file.'));
process.exit(1);
}
// Create server
const server = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url, true);
let filePath = parsedUrl.pathname;
// Remove leading slash
if (filePath.startsWith('/')) {
filePath = filePath.substring(1);
}
// Set content type and no-cache headers for development
const setContentTypeAndNoCache = (contentType) => {
res.setHeader('Content-Type', contentType);
// Add no-cache headers for development
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
};
// Set CORS headers if enabled
const setCorsHeaders = () => {
if (cors) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
};
// Handle theme-urls.json or default to theme-urls.json if no file specified
if (filePath === 'theme-urls.json' || filePath === '') {
// Set CORS headers if enabled
setCorsHeaders();
// Set content type and no-cache headers using helper function
setContentTypeAndNoCache('application/json');
res.end(JSON.stringify(themeUrls, null, 2));
return;
}
// Resolve file path relative to build directory
const fullPath = path.join(resolvedBuildDir, filePath);
// Security check: ensure file is within build directory
if (!fullPath.startsWith(resolvedBuildDir)) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Forbidden');
return;
}
// Set CORS headers if enabled
setCorsHeaders();
// Handle OPTIONS requests for CORS preflight
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// Check if file exists
if (!fs.existsSync(fullPath)) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('File not found');
return;
}
// Get file stats
const stats = fs.statSync(fullPath);
// Handle directories
if (stats.isDirectory()) {
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Directory access not allowed');
return;
}
// Set appropriate content type based on file extension
const ext = path.extname(fullPath).toLowerCase();
// Only serve CSS files and theme configuration JSON
if (ext === '.css') {
setContentTypeAndNoCache('text/css');
} else if (ext === '.json' && path.basename(fullPath) === 'theme-urls.json') {
setContentTypeAndNoCache('application/json');
} else {
// Reject all other file types
res.writeHead(403, { 'Content-Type': 'text/plain' });
res.end('Only CSS files and theme-urls.json are allowed');
return;
}
res.setHeader('Content-Length', stats.size);
// Stream the file
const fileStream = fs.createReadStream(fullPath);
fileStream.pipe(res);
fileStream.on('error', (error) => {
console.error(chalk.red(`Error serving file ${filePath}:`, error.message));
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal server error');
}
});
});
// Start server
server.listen(port, host, () => {
const spinner = ora('Starting theme CSS server...').start();
setTimeout(() => {
spinner.succeed(chalk.green.bold(`Theme CSS server running at http://${host}:${port}`));
console.log(chalk.cyan('\nAvailable theme files:'));
// List available files based on theme-urls.json
if (themeUrls) {
// Core files
if (themeUrls.core) {
console.log(chalk.yellow('\nCore CSS:'));
const coreDefault = themeUrls.core.paths.default.replace(/^\.\//, '');
const coreMinified = themeUrls.core.paths.minified.replace(/^\.\//, '');
console.log(chalk.gray(` http://${host}:${port}/${coreDefault}`));
console.log(chalk.gray(` http://${host}:${port}/${coreMinified}`));
}
// Theme variants
if (themeUrls.variants) {
console.log(chalk.yellow('\nTheme Variants:'));
Object.entries(themeUrls.variants).forEach(([variantName, themeData]) => {
const isDefault = themeUrls.defaults && themeUrls.defaults[variantName];
const prefix = isDefault ? chalk.green('★ ') : ' ';
const themeDefault = themeData.paths.default.replace(/^\.\//, '');
const themeMinified = themeData.paths.minified.replace(/^\.\//, '');
console.log(chalk.gray(`${prefix}${variantName}:`));
console.log(chalk.gray(` http://${host}:${port}/${themeDefault}`));
console.log(chalk.gray(` http://${host}:${port}/${themeMinified}`));
});
}
// Theme URLs JSON
console.log(chalk.yellow('\nTheme Configuration:'));
console.log(chalk.gray(` http://${host}:${port}/theme-urls.json`));
}
// Show the Paragon docs URL with encoded themes
const docsUrlWithThemes = generateDocsUrl(themeUrls, host, port, themeName, docsUrl);
if (docsUrlWithThemes) {
console.log(chalk.green.bold('\n📖 Paragon Docs URL with encoded themes:'));
console.log(chalk.cyan(docsUrlWithThemes));
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.'));
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.'));
} else {
console.log(chalk.yellow('\n⚠ No themes found to encode for docs URL.'));
}
console.log(chalk.gray('\nPress Ctrl+C to stop the server'));
}, 1000);
});
// Handle server errors
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
console.error(chalk.red.bold(`Error: Port ${port} is already in use.`));
console.error(chalk.yellow('Try using a different port with --port option.'));
} else {
console.error(chalk.red.bold('Server error:', error.message));
}
process.exit(1);
});
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log(chalk.gray('\nShutting down server...'));
server.close(() => {
console.log(chalk.green('Server stopped'));
process.exit(0);
});
});
}
module.exports = serveThemeCssCommand;

333
package-lock.json generated
View File

@ -33,6 +33,7 @@
"js-toml": "^1.0.0",
"lodash.uniqby": "^4.7.0",
"log-update": "^4.0.0",
"lz-string": "^1.5.0",
"mailto-link": "^2.0.0",
"minimist": "^1.2.8",
"ora": "^5.4.1",
@ -2515,6 +2516,40 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"example/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"example/node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"example/node_modules/ci-info": {
"version": "3.9.0",
"funding": [
@ -4536,6 +4571,31 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/cli/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"optional": true,
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/@babel/cli/node_modules/commander": {
"version": "6.2.1",
"dev": true,
@ -4568,6 +4628,19 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@babel/cli/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"optional": true,
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"license": "MIT",
@ -14359,7 +14432,8 @@
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"engines": {
"node": ">=8"
},
@ -15163,35 +15237,29 @@
"license": "ISC"
},
"node_modules/chokidar": {
"version": "3.6.0",
"license": "MIT",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 8.10.0"
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"node_modules/chokidar/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"engines": {
"node": ">= 6"
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/chownr": {
@ -20986,6 +21054,29 @@
}
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/cosmiconfig": {
"version": "6.0.0",
"license": "MIT",
@ -21031,6 +21122,17 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": {
"version": "2.7.0",
"license": "MIT",
@ -21568,6 +21670,29 @@
"node": ">=18.0.0"
}
},
"node_modules/gatsby-page-utils/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/gatsby-page-utils/node_modules/glob": {
"version": "7.2.3",
"license": "ISC",
@ -21586,6 +21711,17 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/gatsby-page-utils/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/gatsby-parcel-config": {
"version": "1.14.0",
"license": "MIT",
@ -21696,6 +21832,40 @@
"gatsby": "^5.0.0-next"
}
},
"node_modules/gatsby-plugin-page-creator/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/gatsby-plugin-page-creator/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/gatsby-plugin-react-axe": {
"version": "0.5.0",
"license": "MIT",
@ -22057,6 +22227,40 @@
"gatsby": "^5.0.0-next"
}
},
"node_modules/gatsby-source-filesystem/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/gatsby-source-filesystem/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/gatsby-transformer-react-docgen": {
"version": "8.14.0",
"license": "MIT",
@ -22199,6 +22403,29 @@
"version": "2.0.0",
"license": "MIT"
},
"node_modules/gatsby/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/gatsby/node_modules/cosmiconfig": {
"version": "7.1.0",
"license": "MIT",
@ -25724,7 +25951,8 @@
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dependencies": {
"binary-extensions": "^2.0.0"
},
@ -40129,7 +40357,8 @@
},
"node_modules/readdirp": {
"version": "3.6.0",
"license": "MIT",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dependencies": {
"picomatch": "^2.2.1"
},
@ -42240,34 +42469,10 @@
}
}
},
"node_modules/sass/node_modules/chokidar": {
"version": "4.0.1",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/sass/node_modules/immutable": {
"version": "5.0.3",
"license": "MIT"
},
"node_modules/sass/node_modules/readdirp": {
"version": "4.0.2",
"license": "MIT",
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"license": "ISC",
@ -47238,6 +47443,40 @@
}
}
},
"node_modules/webpack-dev-server/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/webpack-dev-server/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/webpack-dev-server/node_modules/ipaddr.js": {
"version": "2.2.0",
"license": "MIT",

View File

@ -39,6 +39,7 @@
"start": "npm start --workspace=www",
"test": "jest --coverage",
"test:watch": "npm run test -- --watch",
"test:watch-no-coverage": "npm run test:watch -- --coverage=false",
"generate-component": "npm start --workspace=component-generator",
"example:start": "npm start --workspace=example",
"example:start:with-theme": "npm run start:with-theme --workspace=example",
@ -53,6 +54,7 @@
"prepare": "husky || true",
"build-tokens": "./bin/paragon-scripts.js build-tokens --build-dir ./styles/css",
"build-tokens:watch": "npx nodemon --ignore styles/css -x \"npm run build-tokens\"",
"serve-theme-css": "./bin/paragon-scripts.js serve-theme-css --build-dir ./dist --theme-name='Custom Theme Name'",
"replace-variables-usage-with-css": "./bin/paragon-scripts.js replace-variables -p src -t usage",
"replace-variables-definition-with-css": "./bin/paragon-scripts.js replace-variables -p src -t definition",
"cli:help": "./bin/paragon-scripts.js help"
@ -75,6 +77,7 @@
"js-toml": "^1.0.0",
"lodash.uniqby": "^4.7.0",
"log-update": "^4.0.0",
"lz-string": "^1.5.0",
"mailto-link": "^2.0.0",
"minimist": "^1.2.8",
"ora": "^5.4.1",

View File

@ -1,7 +1,7 @@
const React = require('react');
const { SettingsContextProvider } = require('./src/context/SettingsContext');
const { InsightsContextProvider } = require('./src/context/InsightsContext');
const { encodeThemesToQueryParam } = require('./src/utils/queryParamEncoding');
const { encodeThemesToQueryParam } = require('../lib/queryParamEncoding');
const { hasUrls } = require('./src/utils/themeUtils');
// wrap whole app in settings context

View File

@ -1,8 +1,9 @@
import { useEffect } from 'react';
import { encodeThemesToQueryParam } from '../utils/queryParamEncoding';
import { type ThemeConfig } from '../types/types';
import { UpdateSettingsFunction } from './useSettings';
import { encodeThemesToQueryParam } from '../utils/queryParamEncoding';
/**
* Hook to manage theme state including default and custom themes
* Handles theme switching, CSS injection, and URL parameter management
@ -20,6 +21,7 @@ export const useCustomThemes = (
themes[activeIndex].urls.forEach((url: string) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = url;
link.setAttribute('data-custom-theme', 'true');
document.head.appendChild(link);

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react';
import { type ContainerSize } from '~paragon-react';
import { SETTINGS_EVENTS, sendUserAnalyticsEvent } from '../../segment-events';
import { decodeThemesFromQueryParam } from '../utils/queryParamEncoding';
import { type ThemeConfig } from '../types/types';
import { decodeThemesFromQueryParam } from '../utils/queryParamEncoding';
export interface Settings {
direction?: string;

View File

@ -1,60 +1,19 @@
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
import { type ThemeConfig } from '../types/types';
import { ThemeConfig } from '../types/types';
export interface ThemeState {
// TypeScript wrapper for the CommonJS queryParamEncoding module
const queryParamEncoding = require('../../../lib/queryParamEncoding');
// Re-export the types for better TypeScript support
export type ThemeState = {
themes: ThemeConfig[];
activeIndex: number;
}
};
interface ShortThemeConfig {
n: string; // name
u: string[]; // urls
}
interface ShortThemeState {
t: ShortThemeConfig[]; // themes
i: number; // activeIndex
}
/**
* Encodes theme state (themes array + active index) as a highly compressed string for use in a query param.
* Uses shorthand keys and LZ-String compression.
*/
// Type-safe function signatures that replace the original implementations
export function encodeThemesToQueryParam(themes: ThemeConfig[], activeIndex: number): string {
const fullState: ThemeState = { themes, activeIndex };
const shortThemes: ShortThemeConfig[] = fullState.themes.map(theme => ({
n: theme.name,
u: theme.urls,
}));
const shortState: ShortThemeState = {
t: shortThemes,
i: fullState.activeIndex,
};
return compressToEncodedURIComponent(JSON.stringify(shortState));
return queryParamEncoding.encodeThemesToQueryParam(themes, activeIndex);
}
/**
* Decodes a compressed query param value into theme state (themes array + active index).
* Handles LZ-String decompression and shorthand key expansion.
*/
export function decodeThemesFromQueryParam(encoded: string): ThemeState {
try {
const decompressed = decompressFromEncodedURIComponent(encoded);
if (!decompressed) { return { themes: [], activeIndex: 0 }; }
const shortState: ShortThemeState = JSON.parse(decompressed);
const fullThemes: ThemeConfig[] = (shortState.t || []).map(shortTheme => ({
name: shortTheme.n,
urls: shortTheme.u,
}));
return {
themes: fullThemes,
activeIndex: shortState.i || 0,
};
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error decoding theme query param:', error);
return { themes: [], activeIndex: 0 };
}
return queryParamEncoding.decodeThemesFromQueryParam(encoded);
}