mirror of https://github.com/openedx/paragon.git
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:
parent
fd6ec1e00f
commit
62e65ca9af
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue