Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,023 changes: 985 additions & 38 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"license": "MIT",
"dependencies": {
"@bigcommerce/big-design": "^0.30.2",
"@tailwindcss/cli": "^4.1.11",
"ajv": "^6.12.3",
"axios": "^0.28.0",
"babel-polyfill": "^6.26.0",
Expand All @@ -51,7 +52,8 @@
"redux-thunk": "^2.3.0",
"socket.io": "^4.5.3",
"socket.io-client": "^4.3.2",
"styled-components": "^5.3.11"
"styled-components": "^5.3.11",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@babel/core": "^7.18.0",
Expand Down
8 changes: 6 additions & 2 deletions src/cli/deployment/widgetTemplatePublish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@ import publishWidgetTemplate from '../../services/widgetTemplate/publish';
import { log, messages } from '../../messages';
import checkCredentials from '../../services/auth/checkAuth';
import AUTH_CONFIG from '../../services/auth/authConfig';
import { TailwindOptions } from 'src/services/widgetTemplate/twProcessor';

const widgetTemplatePublish = () => {
const program = new Command('publish');

return program
.arguments('<widget-template>')
.combineFlagAndOptionalValue(true)
.option('-t, --tw-style-path <tw-style_path>', 'Path for Tailwind 4 CSS styles')
.option('-p, --tw-prefix <tw-prefix>', 'Use a custom prefix for Tailwind 4 CSS classes')
.description('Releases the widget template to the store belonging to the env config')
.usage('<widget-template>')
.action((widgetTemplate) => {
.action((widgetTemplate, options: TailwindOptions) => {
const widgetTemplateDir = path.resolve(`./${widgetTemplate}`);
if (!checkCredentials(AUTH_CONFIG)) {
process.exit(1);
Expand All @@ -33,7 +37,7 @@ const widgetTemplatePublish = () => {
return;
}

publishWidgetTemplate(widgetTemplate, widgetTemplateDir);
publishWidgetTemplate(widgetTemplate, widgetTemplateDir, options);
});
};

Expand Down
4 changes: 3 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import start from './run/start';
import widgetTemplatePublish from './deployment/widgetTemplatePublish';
import validateCommands from './run/validate';
import init from './run/init';
import listWidgets from './manage/listWidgets';

const { version } = require('../../package.json');

Expand All @@ -18,6 +19,7 @@ cli
.addCommand(start())
.addCommand(validateCommands())
.addCommand(createStarterTemplate())
.addCommand(widgetTemplatePublish());
.addCommand(widgetTemplatePublish())
.addCommand(listWidgets());

cli.parse(process.argv);
30 changes: 30 additions & 0 deletions src/cli/manage/listWidgets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Command } from 'commander';

import getWidgets from '../../services/widgets/getList';

export default function widgets() {
const cmd = new Command('list')
.description('Manage BigCommerce widgets');

handleWidgets(cmd as Command);
handleWidgetsTemplates(cmd as Command);

return cmd;
}

function handleWidgets(cmd: Command) {
cmd.command('widgets')
.description('List all widgets with their IDs from BigCommerce')
.action(async () => {
await getWidgets('widgets');
});
}

function handleWidgetsTemplates(cmd: Command) {
cmd.command('widget-templates')
.description('List all widget templates with their IDs from BigCommerce')
.action(async () => {
await getWidgets('widget-templates');
});

}
17 changes: 14 additions & 3 deletions src/services/widgetTemplate/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { publishWidget } from '../api/widget';
import WidgetFileType, { FileLoaderResponse } from '../../types';
import schemaLoader from '../schema/schemaLoader/schemaLoader';

import twProcessor, { TailwindOptions } from './twProcessor';
import widgetTemplateLoader from './widgetTemplateLoader/widgetTemplateLoader';
import track from './track';


interface CreateWidgetTemplateReq {
name: string;
schema: object;
Expand All @@ -16,6 +18,7 @@ interface CreateWidgetTemplateReq {
channel_id: number;
}


const channelId = process.env.WIDGET_BUILDER_CHANNEL_ID ? parseInt(process.env.WIDGET_BUILDER_CHANNEL_ID, 10) : 1;

const widgetTemplatePayload = (widgetName: string): CreateWidgetTemplateReq => ({
Expand All @@ -26,10 +29,11 @@ const widgetTemplatePayload = (widgetName: string): CreateWidgetTemplateReq => (
channel_id: channelId,
});

const publishWidgetTemplate = async (widgetName: string, widgetTemplateDir: string) => {
const publishWidgetTemplate = async (widgetName: string, widgetTemplateDir: string, twOptions: TailwindOptions = {}) => {
const widgetTemplateUuid = track.isTracked(widgetTemplateDir);

try {
const styles = await twProcessor(widgetTemplateDir, twOptions);
const widgetConfiguration = await Promise.all([
widgetTemplateLoader(widgetTemplateDir),
schemaLoader(widgetTemplateDir),
Expand All @@ -39,7 +43,8 @@ const publishWidgetTemplate = async (widgetName: string, widgetTemplateDir: stri
const { data, type } = current;

if (type === WidgetFileType.TEMPLATE) {
return { ...acc, template: data };
const template = `<style>${styles}</style>${data}`;
return { ...acc, template };
}

if (type === WidgetFileType.SCHEMA) {
Expand All @@ -61,7 +66,13 @@ const publishWidgetTemplate = async (widgetName: string, widgetTemplateDir: stri
} else {
log.success(`Successfully updated ${widgetName}`);
}
} catch {
} catch (e: unknown) {
console.log(e);
if (e instanceof Error) {
log.error(e.message);
} else {
log.error(messages.widgetRelease.failure);
}
log.error(messages.widgetRelease.failure);
}
};
Expand Down
5 changes: 2 additions & 3 deletions src/services/widgetTemplate/track.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { readFileSync, writeFileSync } from 'fs';

import { log } from '../../messages';

const filePath = (dir: string): string => `${dir}/widget.yml`;

const isDev = process.env.NODE_ENV !== 'production';
const filePath = (dir: string): string => `${dir}/widget${isDev ? '-dev' : ''}.yml`;
const isTracked = (dir: string): string | null => {
try {
const data = readFileSync(filePath(dir), 'utf-8');
Expand Down
110 changes: 110 additions & 0 deletions src/services/widgetTemplate/twProcessor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import fs from 'fs/promises';
import { spawn } from 'child_process';
export type TailwindOptions = {
twStylePath?: string;
twPrefix?: string;
}
const tailwindBin = require.resolve('../../../../node_modules/@tailwindcss/cli/dist/index.mjs');

const TEMP_HTML = 'temp-content.html';
const TEMP_CSS = 'temp.css';
const OUTPUT_CSS = 'output.css';



// Step 1: Extract classes from HBS using regex
function extractClassesFromHbs(template: string): string[] {
const matches = template.match(/class=["'`]([^"'`]+)["'`]/g) || [];
return matches
.map(m => m.replace(/class=["'`]/, '').replace(/["'`]$/, ''))
.flatMap(m => m.split(/\s+/));
}

// Step 2: Create a fake HTML snippet using all extracted classes
function generateClassUsageHTML(classes: string[]): string {
const unique = [...new Set(classes)].filter(Boolean);
return `<div class="${unique.join(' ')}"></div>`;
}

// Step 3: Build Tailwind CSS using that raw HTML content
export default async function buildTailwindCSS(path = './', { twPrefix, twStylePath }: TailwindOptions = {}): Promise<string> {
const hbsContent = await fs.readFile(`${path}/widget.html`, 'utf8');
const themeContent = await fs.readFile(`${twStylePath}`, 'utf8')
.then(data => {
//Extract everything between @theme {}

const themeMatch = data.match(/@theme\s*\{([\s\S]*?)\n\}/);
if (themeMatch && themeMatch[1]) {
return `@theme {
${themeMatch[1].trim()}
}`;
}
return '';
})
.catch((e: Error) => {
console.warn(`Theme file not found. Skipping custom TW theme styles.`);
});


const classes = extractClassesFromHbs(hbsContent);
const html = generateClassUsageHTML(classes);

await fs.writeFile(`${path}/${TEMP_CSS}`, `
${twPrefix ? '@config "./tailwind.config.js";' : ''}
@layer theme, utilities;
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@source "${path}/${TEMP_HTML}";
${themeContent}
`);
await fs.writeFile(`${path}/${TEMP_HTML}`, html, 'utf8');

// Create a basic Tailwind config file if a custom prefix is needed.
if (twPrefix) {
await fs.writeFile(`${path}/tailwind.config.js`, `
module.exports = {
prefix: 'tw-',
};
`, 'utf8');
}

return new Promise((resolve, reject) => {

const proc = spawn('node', [
tailwindBin,
'--optimize',
'--input', `${path}/${TEMP_CSS}`,
]);

let css = '';
let err = '';

proc.stdout.on('data', chunk => (css += chunk));
proc.stderr.on('data', chunk => (err += chunk));
proc.on('error', error => {
console.error(`Error executing Tailwind CLI: ${error.message}`);
reject(error);
});
proc.on('close', async code => {
if (code !== 0) reject(new Error(err));
else {
await cleanUpTempFiles(path);
resolve(css.trim());
}
});
});


}

function cleanUpTempFiles(path = './') {

console.log(`🧹 Cleaning up temporary files in ${path}`);

return Promise.all([
fs.unlink(`${path}/${TEMP_HTML}`).catch(() => { }),
fs.unlink(`${path}/${TEMP_CSS}`).catch(() => { }),
fs.unlink(`${path}/${OUTPUT_CSS}`).catch(() => { }),
fs.unlink(`${path}/tailwind.config.js`).catch(() => { }),
]);
}
34 changes: 34 additions & 0 deletions src/services/widgets/getList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import axios from 'axios';

// Replace with your actual BigCommerce store hash and access token
import AUTH_CONFIG from '../auth/authConfig';

const BASE_URL = `${AUTH_CONFIG.apiPath}/content/`;

type ObjectType = 'widgets' | 'widget-templates'

async function getAll(wType: ObjectType) {
try {
const response = await axios.get(BASE_URL + wType, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Auth-Client': AUTH_CONFIG.authId,
'X-Auth-Token': AUTH_CONFIG.authToken,
},
});
const widgets = response.data.data;
if (!widgets || widgets.length === 0) {
console.log('No widgets found.');
return;
}
console.log('Widget List:');
console.table(widgets.map((widget: any) => ({ uuid: widget.uuid, name: widget.name })))
} catch (error: any) {
console.error('Error fetching widgets:', error.response?.data || error.message);
}
}



export default getAll;