Skip to content
Merged
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
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
"git.ignoreLimitWarning": true,
"html.format.enable": true,
"css.validate": false,
"scss.validate": false,
"stylelint.validate": ["css", "scss"],
"stylelint.validate": ["css"],
"files.associations": {
"*.mdc": "markdown",
".clinerules": "markdown",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"lint": "run-s lint:eslint lint:prettier lint:textlint lint:cspell",
"lint:cspell": "cspell --no-progress --show-suggestions \"**\"",
"lint:eslint": "eslint --fix \"./{*,**/*}.{js,cjs,mjs}\"",
"lint:prettier": "prettier --write \"{*,./**/*}.{md,mdc,mdx,js,jsx,ts,tsx,scss,pug,html}\" \"**/.clinerules\" \"!**/{dist,storybook-static}/**/*\"",
"lint:prettier": "prettier --write \"{*,./**/*}.{md,mdc,mdx,js,jsx,ts,tsx,css,pug,html}\" \"**/.clinerules\" \"!**/{dist,storybook-static}/**/*\"",
"lint:textlint": "textlint --fix \"{*,./**/*}.{md,mdc}\" && textlint \"{*,./**/*}.{md,mdc}\"",
"storybook": "lerna run storybook --scope=@d-zero/custom-components",
"release": "lerna publish --exact --conventional-commits --conventional-graduate",
Expand Down
10 changes: 7 additions & 3 deletions packages/@d-zero/builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@
"dependencies": {
"@11ty/eleventy": "3.1.1",
"@d-zero/shared": "0.9.0",
"@types/postcss-import": "14.0.3",
"@types/postcss-load-config": "3.0.1",
"character-entities": "2.0.2",
"cli-color": "2.0.4",
"cssnano": "7.0.7",
"dayjs": "1.11.13",
"esbuild": "0.25.5",
"filesize": "10.1.6",
Expand All @@ -41,10 +44,11 @@
"js-yaml": "4.1.0",
"jsdom": "26.1.0",
"minimatch": "10.0.3",
"postcss": "8.5.6",
"postcss-import": "16.1.1",
"postcss-load-config": "6.0.1",
"prettier": "3.5.3",
"pug": "3.0.3",
"strip-css-comments": "5.0.0",
"vite": "6.3.5"
"pug": "3.0.3"
},
"devDependencies": {
"@types/cli-color": "2.0.6",
Expand Down
220 changes: 220 additions & 0 deletions packages/@d-zero/builder/src/compiler/css.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
import path from 'node:path';

import { describe, it, expect, beforeEach, afterEach } from 'vitest';

import { compileCss } from './css.js';

describe('compileCss', () => {
describe('Basic CSS transformation (string input → string output)', () => {
it('should minify simple CSS', async () => {
const css = `
.button {
color: red;
background-color: blue;
margin: 10px 20px;
}
`;

const result = await compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {},
});

// Verify cssnano minification
expect(result).not.toContain('\n');
expect(result).not.toContain(' ');
expect(result).toContain('.button');
expect(result).toContain('color:red');
expect(result).toContain('background-color:blue');
expect(result).toContain('margin:10px 20px');
});

it('should handle CSS comments properly', async () => {
const css = `
/* Regular comment */
.test { color: red; }
/*! Important comment */
.important { color: blue; }
`;

const result = await compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {},
});

expect(result).not.toContain('Regular comment');
expect(result).toContain('Important comment');
});

it('should process complex CSS selectors and values', async () => {
const css = `
.card > .header::before {
content: "";
display: block;
width: 100%;
height: 2px;
background: linear-gradient(to right, #ff0000, #0000ff);
}

@media (min-width: 768px) {
.card {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
}
`;

const result = await compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {},
});

expect(result).toContain('.card>.header:before'); // ::before gets minified to :before
expect(result).toContain('content:""');
expect(result).toContain('linear-gradient(90deg,red,#00f)'); // Actual minification result
expect(result).toContain('@media (min-width:768px)');
});

it('should handle empty CSS', async () => {
const css = '';

const result = await compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {},
});

expect(result).toBe('');
});

it('should handle CSS with only comments', async () => {
const css = `
/* This is comment only */
/* Another comment */
`;

const result = await compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {},
});

expect(result).toBe('');
});

it('should preserve only important comments', async () => {
const css = `
/*! License information */
/* Regular comment */
/*! Copyright information */
`;

const result = await compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {},
});

expect(result).toContain('License information');
expect(result).toContain('Copyright information');
expect(result).not.toContain('Regular comment');
});
});

describe('Invalid CSS syntax handling', () => {
it('should return empty string for invalid syntax', async () => {
const css = '.test { color: ; }'; // Missing value

const result = await compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {},
});

expect(result).toBe('');
});

it('should throw error for unclosed block syntax', async () => {
const css = '.test { color: red'; // Missing closing brace

await expect(
compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {},
}),
).rejects.toThrow('Unclosed block');
});
});

describe('Options testing', () => {
it('should not perform file operations when alias is set but no @import exists', async () => {
const css = `
.component {
color: green;
}
`;

const result = await compileCss(css, 'test.css', {
banner: '',
minify: true,
alias: {
'@components': '/some/path',
'@utils': '/another/path',
},
});

expect(result).toContain('.component');
expect(result).toContain('color:green');
});
});
});

// Tests for file import functionality (separated)
describe('compileCss - File import functionality', () => {
const testDir = path.join(process.cwd(), 'test-temp-import');
const cssDir = path.join(testDir, 'css');

beforeEach(() => {
mkdirSync(cssDir, { recursive: true });
});

afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});

it('should resolve alias-based @import', async () => {
// Create test file
const buttonCssPath = path.join(cssDir, 'components', 'button.css');
mkdirSync(path.dirname(buttonCssPath), { recursive: true });
writeFileSync(buttonCssPath, '.button { padding: 1rem; }');

const css = '@import "@components/button.css";';

const result = await compileCss(css, path.join(cssDir, 'main.css'), {
banner: '',
minify: true,
alias: {
'@components': path.join(cssDir, 'components'),
},
});

expect(result).toContain('.button');
expect(result).toContain('padding:1rem');
});

it('should throw error when importing non-existent file', async () => {
const css = '@import "./non-existent.css";';

await expect(
compileCss(css, path.join(cssDir, 'main.css'), {
banner: '',
minify: true,
alias: {},
}),
).rejects.toThrow();
});
});
94 changes: 94 additions & 0 deletions packages/@d-zero/builder/src/compiler/css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import path from 'node:path';

import cssnano from 'cssnano';
import postcss from 'postcss';
import postcssImport from 'postcss-import';
// eslint-disable-next-line import/default
import postcssLoadConfig from 'postcss-load-config';

type CompileCssOptions = {
banner: string;
minify: boolean;
alias: Record<string, string>;
};

/**
*
* @param css
* @param inputPath
* @param options
*/
export async function compileCss(
css: string,
inputPath: string,
options: CompileCssOptions,
) {
// Configure plugins with alias resolver for postcss-import
const plugins: postcss.AcceptedPlugin[] = [];

// Add postcss-import plugin with alias resolver
plugins.push(
postcssImport({
resolve:
// Create alias resolver for postcss-import
(id: string, basedir: string) => {
// Check if the import starts with an alias
for (const [alias, aliasPath] of Object.entries(options.alias)) {
if (id.startsWith(alias)) {
const resolvedPath = id.replace(alias, aliasPath);
return [path.resolve(basedir, resolvedPath)];
}
}
// For non-alias imports, fallback to default postcss-import resolution
return [id];
},
}),
cssnano({
preset: [
'default',
{
// Preserve !important comments (license, copyright, etc.)
discardComments: {
removeAll: false,
removeAllButFirst: false,
},
// Custom comment removal that preserves ! comments
cssDeclarationSorter: false,
},
],
}),
);

// Try to load PostCSS config from project root
let config;
try {
config = await postcssLoadConfig();
} catch {
// Fallback to default config if no config found
config = { plugins: [] };
}

// Add other plugins from config (excluding postcss-import if it exists)
if (config.plugins) {
for (const plugin of config.plugins) {
// Skip postcss-import plugin to avoid duplicates
if (
typeof plugin === 'object' &&
plugin &&
'pluginName' in plugin &&
plugin.pluginName === 'postcss-import'
) {
continue;
}
plugins.push(plugin);
}
}

// Process CSS with PostCSS
const result = await postcss(plugins).process(css, {
from: inputPath,
to: undefined,
});

return result.css;
}
Loading