Skip to content

Commit

Permalink
[astro-purgecss] Fix files modified without updating hash (#611)
Browse files Browse the repository at this point in the history
* Fix purgecss not updating file names after purge

* Add tests to astro purge css

* Add pkg-pr-new

* Fix workflow

* docs(changeset): Fix files modified without updating hash
  • Loading branch information
mhdcodes authored Aug 27, 2024
1 parent da1b4a1 commit cc70f58
Show file tree
Hide file tree
Showing 11 changed files with 965 additions and 75 deletions.
5 changes: 5 additions & 0 deletions .changeset/cuddly-shrimps-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro-purgecss': minor
---

Fix files modified without updating hash
6 changes: 5 additions & 1 deletion .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ on:
pull_request:
branches: ['main']

concurrency: ${{ github.workflow }}-${{ github.ref }}

jobs:
build:
runs-on: ubuntu-latest
Expand All @@ -22,12 +24,14 @@ jobs:
- uses: pnpm/action-setup@v2
with:
version: 8
run_install: true

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'

- run: pnpm install
- run: pnpm run test
- run: pnpm run build
- run: pnpx pkg-pr-new publish './packages/astro-purgecss'
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2022 Codiume
Copyright (c) 2024 Codiume

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
7 changes: 5 additions & 2 deletions apps/example-purgecss/astro.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineConfig } from 'astro/config';
import purgecss from 'astro-purgecss';
import { defineConfig } from 'astro/config';

export default defineConfig({
// Add purgecss support to Astro
Expand All @@ -10,5 +10,8 @@ export default defineConfig({
safelist: ['random', 'yep', 'button', /^nav-/],
blocklist: ['usedClass', /^nav-/]
})
]
],
build: {
inlineStylesheets: 'never'
}
});
12 changes: 8 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@
"packages/*"
],
"scripts": {
"test": "vitest run",
"build": "turbo run build && turbo run typecheck",
"build:no-cache": "turbo run build --no-cache && turbo run typecheck",
"dev": "turbo run dev --no-cache --parallel --continue",
"clean": "turbo run clean && rm -rf node_modules",
"format": "prettier --write \"**/*.{ts,tsx,md,astro,mjs}\"",
"clean": "turbo run clean && rm -rf node_modules",
"changeset": "changeset",
"changeset:version": "changeset version",
"changeset:publish": "pnpm run build && changeset publish"
Expand All @@ -25,12 +26,15 @@
"commander": "^12.1.0",
"esbuild": "^0.23.1",
"esbuild-plugin-clean": "^1.0.1",
"ora": "^8.0.1",
"ora": "^8.1.0",
"pkg-pr-new": "^0.0.20",
"prettier": "^3.3.3",
"prettier-plugin-astro": "^0.14.1",
"tiny-glob": "^0.2.9",
"tsup": "^8.2.4",
"turbo": "^2.0.14",
"typescript": "^5.5.4"
"turbo": "^2.1.0",
"typescript": "^5.5.4",
"vite-tsconfig-paths": "^5.0.1",
"vitest": "^2.0.5"
}
}
20 changes: 6 additions & 14 deletions packages/astro-purgecss/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,17 @@
import type { AstroIntegration } from 'astro';
import { writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { PurgeCSS, type UserDefinedOptions } from 'purgecss';

export interface PurgeCSSOptions extends Partial<UserDefinedOptions> {}

function handleWindowsPath(outputPath: string): string {
if (process.platform !== 'win32') return outputPath;

if (outputPath.endsWith('\\')) {
outputPath = outputPath.substring(0, outputPath.length - 1);
}
outputPath = outputPath.replaceAll('\\', '/');
import { resolveOutputPath, writeCssFile } from './utils';

return outputPath;
}
export interface PurgeCSSOptions extends Partial<UserDefinedOptions> {}

function Plugin(options: PurgeCSSOptions = {}): AstroIntegration {
return {
name: 'astro-purgecss',
hooks: {
'astro:build:done': async ({ dir }) => {
const outDir = handleWindowsPath(fileURLToPath(dir));
const outDir = resolveOutputPath(fileURLToPath(dir));
const purged = await new PurgeCSS().purge({
css: [`${outDir}/**/*.css`],
defaultExtractor: (content) => content.match(/[\w-/:]+(?<!:)/g) || [],
Expand All @@ -35,7 +25,9 @@ function Plugin(options: PurgeCSSOptions = {}): AstroIntegration {
await Promise.all(
purged
.filter(({ file }) => file?.endsWith('.css'))
.map(async ({ css, file }) => await writeFile(file!, css))
.map(
async ({ css, file }) => await writeCssFile({ css, file, outDir })
)
);
}
}
Expand Down
105 changes: 105 additions & 0 deletions packages/astro-purgecss/src/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { unlink, writeFile } from 'node:fs/promises';
import { basename, dirname } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { resolveOutputPath, writeCssFile } from './utils';

describe('resolveOutputPath', () => {
it('should return the same path for non-windows platforms', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin' });

const inputPath = '/some/unix/path';
expect(resolveOutputPath(inputPath)).toBe(inputPath);

Object.defineProperty(process, 'platform', { value: originalPlatform });
});

it('should remove trailing backslash and replace backslashes with forward slashes on windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });

const inputPath = 'C:\\Users\\test\\path\\';
const expectedPath = 'C:/Users/test/path';
expect(resolveOutputPath(inputPath)).toBe(expectedPath);

Object.defineProperty(process, 'platform', { value: originalPlatform });
});

it('should replace backslashes with forward slashes on windows without trailing backslash', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32' });

const inputPath = 'C:\\Users\\test\\path';
const expectedPath = 'C:/Users/test/path';
expect(resolveOutputPath(inputPath)).toBe(expectedPath);

Object.defineProperty(process, 'platform', { value: originalPlatform });
});
});

const mockCss = 'body { color: red; }';
const mockFile = '/path/to/astro.csjqp06s.css';
const mockOutDir = '/path/to/outdir';
const mockHash = 'abcdefg1';

vi.mock('node:fs/promises', () => ({
writeFile: vi.fn(() => Promise.resolve()),
unlink: vi.fn(() => Promise.resolve()),
readdir: vi.fn(() => Promise.resolve([]))
}));

vi.mock('node:path', () => ({
dirname: vi.fn(() => '/path/to'),
basename: vi.fn((file) => file.split('/').pop() as string),
join: vi.fn((...args) => args.join('/'))
}));

vi.mock('node:crypto', () => ({
createHash: vi.fn(() => ({
update: vi.fn().mockReturnThis(),
digest: vi.fn().mockReturnValue(mockHash.repeat(5))
}))
}));

describe('writeCssFile', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('should return undefined if no file is provided', async () => {
const result = await writeCssFile({ css: mockCss, outDir: mockOutDir });
expect(result).toBeUndefined();
});

it('should write new file, delete old file, and update references', async () => {
const newFile = '/path/to/astro.abcdefg1.css';

await writeCssFile({ css: mockCss, file: mockFile, outDir: mockOutDir });

expect(writeFile).toHaveBeenCalledWith(newFile, mockCss);
expect(unlink).toHaveBeenCalledWith(mockFile);
expect(dirname).toHaveBeenCalledWith(mockOutDir);
expect(basename).toHaveBeenCalledWith(mockFile);
expect(basename).toHaveBeenCalledWith(newFile);
});

it('should return the new file name', async () => {
const result = await writeCssFile({
css: mockCss,
file: mockFile,
outDir: mockOutDir
});
expect(result).toBe('/path/to/astro.abcdefg1.css');
});

it('should handle errors gracefully', async () => {
vi.mocked(writeFile).mockImplementation(() =>
Promise.reject(new Error('Write error'))
);

await expect(
writeCssFile({ css: mockCss, file: mockFile, outDir: mockOutDir })
).rejects.toThrow('Write error');
});
});
84 changes: 84 additions & 0 deletions packages/astro-purgecss/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { createHash } from 'node:crypto';
import { readdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { basename, dirname, join } from 'node:path';

async function replaceStringInFile(
filePath: string,
searchValue: string,
replaceValue: string
) {
try {
const fileContent = await readFile(filePath, 'utf8');
if (fileContent.includes(searchValue)) {
const re = new RegExp(searchValue, 'g');
const newContent = fileContent.replace(re, replaceValue);
await writeFile(filePath, newContent, 'utf8');
}
} catch (err) {
console.error(`Error processing file ${filePath}: ${err}`);
}
}

async function replaceStringInDirectory(
directory: string,
searchValue: string,
replaceValue: string
) {
try {
const files = await readdir(directory, { withFileTypes: true });
for (const file of files) {
const fullPath = join(directory, file.name);
if (file.isDirectory()) {
await replaceStringInDirectory(fullPath, searchValue, replaceValue);
} else if (file.isFile()) {
await replaceStringInFile(fullPath, searchValue, replaceValue);
}
}
} catch (err) {
console.error(`Error processing directory ${directory}: ${err}`);
}
}

export function resolveOutputPath(outputPath: string): string {
if (process.platform !== 'win32') return outputPath;

// Remove trailing backslash if present
outputPath = outputPath.replace(/\\+$/, '');

// Replace all backslashes with forward slashes
return outputPath.replace(/\\/g, '/');
}

export async function writeCssFile({
css,
file,
outDir
}: {
css: string;
file?: string;
outDir: string;
}) {
if (!file) return;

// Get content hash before writing to file
const hash = createHash('sha256').update(css).digest('hex').substring(0, 8);

// Generate new file name with hash
// Astro orignal hash is 8 characters long
const newFile = `${file.slice(0, -13)}.${hash}.css`;

// Write purged CSS to new file
await writeFile(newFile, css);

// Remove old file
await unlink(file);

// Replace old name references with new file name
await replaceStringInDirectory(
dirname(outDir), // Search from parent directory
basename(file),
basename(newFile)
);

return newFile;
}
Loading

0 comments on commit cc70f58

Please sign in to comment.