From e9ace55ed511a002db365a7ff9e257743f4dd81e Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 19:10:38 -0500 Subject: [PATCH 1/5] =?UTF-8?q?=F0=9F=93=9D=20Update=20README,=20add=20som?= =?UTF-8?q?e=20type=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 499 ++++++++++++++++++++++++++++++++---- types/Colors.types.ts | 12 + types/ParseOptions.types.ts | 5 + types/WCAG.types.ts | 6 + utils/textContrast.ts | 13 + 5 files changed, 478 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 2798894..307ccdc 100644 --- a/README.md +++ b/README.md @@ -3,115 +3,500 @@ [![JSR](https://jsr.io/badges/@amuench/contrastrast)](https://jsr.io/@amuench/contrastrast) [![npm version](https://badge.fury.io/js/contrastrast.svg)](https://badge.fury.io/js/contrastrast) -# constrastrast +# contrastrast -A lightweight tool that parses color strings and recommends text contrast based -on [WCAG Standards](http://www.w3.org/TR/AERT#color-contrast) +A comprehensive TypeScript/Deno library for color manipulation, parsing, +conversion, and accessibility analysis. Built with WCAG standards in mind, +contrastrast helps you create accessible color combinations and analyze contrast +ratios. + +**Features:** + +- 🎨 **Multi-format parsing**: Supports HEX, RGB, and HSL color formats +- ♿ **WCAG compliance**: Built-in WCAG 2.1 contrast ratio calculations and + compliance checking +- 📊 **Color analysis**: Luminance, brightness, and accessibility calculations +- 🔄 **Format conversion**: Convert between HEX, RGB, and HSL formats +- ⚡ **TypeScript**: Full TypeScript support with comprehensive type definitions ## Installation -Install `constrastrast` by running one of the following commands: +Install `contrastrast` by running one of the following commands: ```bash -npm install --save constrastrast +npm install --save contrastrast -yarn add constrastrast +yarn add contrastrast -pnpm install --save constrastrast +pnpm install --save contrastrast -deno add contrastrast +deno add jsr:@amuench/contrastrast ``` -## How it works +## Quick Start -`constrastrast` takes a given background color as a string in either HEX, HSL, -or RGB format, and (by default) returns `"dark"` or `"light"` as a recommended -text variant for that given background color +```typescript +import { Contrastrast } from "contrastrast"; -For example, you may use it like this: +// Parse any color format, by default will throw an error if the color string is invalid +const color = new Contrastrast("#1a73e8"); -```tsx -import { textContrastForBGColor } from "contrastrast"; +// Check if the color is light or dark +console.log(color.isLight()); // false +console.log(color.isDark()); // true -const MyColorChangingComponent = (backgroundColor: string) => { - return
- This text is readable no matter what the background color is! -
-} +// Get contrast ratio with another color +const ratio = color.contrastRatio("#ffffff"); // 4.5 + +// Check WCAG compliance +const meetsAA = color.meetsWCAG("#ffffff", "background", "AA"); // true + +// Convert between formats +console.log(color.toHex()); // "#1a73e8" +console.log(color.toRgbString()); // "rgb(26, 115, 232)" +console.log(color.toHslString()); // "hsl(218, 80%, 51%)" ``` -## Supported Color Formats +## API Reference -`constrastrast` supports the following color string formats: +### Types -### HEX +The library exports the following TypeScript types: -HEX Notation in either 3 or 6 length format +#### Color Value Types -**examples** +```typescript +type RGBValues = { + r: number; // Red (0-255) + g: number; // Green (0-255) + b: number; // Blue (0-255) +}; +type HSLValues = { + h: number; // Hue (0-360) + s: number; // Saturation (0-100) + l: number; // Lightness (0-100) +}; ``` -#ad1232 -ad1232 +#### Configuration Types -#ada +```typescript +type ParseOptions = { + throwOnError: boolean; // Whether to throw on invalid colors + fallbackColor?: string; // Fallback color when throwOnError is false +}; -ada +type ContrastOptions = { + returnDetails?: boolean; // Return detailed WCAG analysis instead of just ratio +}; ``` -### RGB +#### Result Types + +```typescript +type ContrastResult = { + ratio: number; + passes: { + AA_NORMAL: boolean; // WCAG AA normal text (4.5:1) + AA_LARGE: boolean; // WCAG AA large text (3:1) + AAA_NORMAL: boolean; // WCAG AAA normal text (7:1) + AAA_LARGE: boolean; // WCAG AAA large text (4.5:1) + }; +}; +``` -Standard RGB notation +#### WCAG Types -**examples** +```typescript +type WCAGContrastLevel = "AA" | "AAA"; +type WCAGTextSize = "normal" | "large"; +``` + +### Constructor and Factory Methods + +#### `new Contrastrast(colorString: string, parseOpts?: Partial)` + +Create a new Contrastrast instance from any supported color string. + +```typescript +const color1 = new Contrastrast("#ff0000"); +const color2 = new Contrastrast("rgb(255, 0, 0)"); +const color3 = new Contrastrast("hsl(0, 100%, 50%)"); +// With error handling +const safeColor = new Contrastrast("invalid-color", { + throwOnError: false, + fallbackColor: "#000000", +}); ``` -rgb(100,200, 230) -rgb(5, 30, 40) +#### `Contrastrast.parse(colorString: string, parseOpts?: Partial): Contrastrast` + +Static method alias for the constructor. Also accepts the same `parseOpts` +configuration object. + +```typescript +const color = Contrastrast.parse("#1a73e8"); + +// With error handling +const safeColor = Contrastrast.parse("invalid-color", { + throwOnError: false, + fallbackColor: "#ffffff", +}); ``` -### HSL +#### `Contrastrast.fromHex(hex: string): Contrastrast` + +Create from hex color (with or without #). Supports 3 and 6 digit codes. + +```typescript +const red1 = Contrastrast.fromHex("#ff0000"); +const red2 = Contrastrast.fromHex("ff0000"); +const shortRed = Contrastrast.fromHex("#f00"); +``` + +#### `Contrastrast.fromRgb(r: number, g: number, b: number): Contrastrast` / `Contrastrast.fromRgb(rgb: RGBValues): Contrastrast` + +Create from RGB values. + +```typescript +const red1 = Contrastrast.fromRgb(255, 0, 0); +const red2 = Contrastrast.fromRgb({ r: 255, g: 0, b: 0 }); +``` + +#### `Contrastrast.fromHsl(h: number, s: number, l: number): Contrastrast` / `Contrastrast.fromHsl(hsl: HSLValues): Contrastrast` + +Create from HSL values. + +```typescript +const red1 = Contrastrast.fromHsl(0, 100, 50); +const red2 = Contrastrast.fromHsl({ h: 0, s: 100, l: 50 }); +``` + +### Color Format Conversion + +#### `toHex(includeHash?: boolean): string` + +Convert to hex format. + +```typescript +const color = new Contrastrast("rgb(255, 0, 0)"); +console.log(color.toHex()); // "#ff0000" +console.log(color.toHex(false)); // "ff0000" +``` + +#### `toRgb(): RGBValues` + +Get RGB values as an object. + +```typescript +const rgb = color.toRgb(); // { r: 255, g: 0, b: 0 } +``` + +#### `toRgbString(): string` + +Convert to RGB string format. + +```typescript +const rgbString = color.toRgbString(); // "rgb(255, 0, 0)" +``` + +#### `toHsl(): HSLValues` + +Get HSL values as an object. + +```typescript +const hsl = color.toHsl(); // { h: 0, s: 100, l: 50 } +``` + +#### `toHslString(): string` + +Convert to HSL string format. + +```typescript +const hslString = color.toHslString(); // "hsl(0, 100%, 50%)" +``` + +### Color Analysis + +#### `luminance(): number` + +Calculate +[WCAG 2.1 relative luminance](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-relative-luminance) +(0-1). + +```typescript +const black = new Contrastrast("#000000"); +const white = new Contrastrast("#ffffff"); +console.log(black.luminance()); // 0 +console.log(white.luminance()); // 1 +``` + +#### `brightness(): number` + +Calculate perceived brightness using +[AERT formula](https://www.w3.org/TR/AERT#color-contrast) (0-255). + +```typescript +const color = new Contrastrast("#1a73e8"); +const brightness = color.brightness(); // ~102.4 +``` + +#### `isLight(): boolean` / `isDark(): boolean` -HSL Notation with or without the symbol markers +Determine if color is light or dark based on +[AERT brightness](https://www.w3.org/TR/AERT#color-contrast) threshold (124). -**examples** +```typescript +const lightColor = new Contrastrast("#ffffff"); +const darkColor = new Contrastrast("#000000"); +console.log(lightColor.isLight()); // true +console.log(darkColor.isDark()); // true +``` + +### Accessibility & Contrast + +#### `contrastRatio(color: Contrastrast | string): number` +Calculate +[WCAG 2.1 contrast ratio](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio) +between colors. + +```typescript +const bgColor = new Contrastrast("#1a73e8"); +const ratio = bgColor.contrastRatio("#ffffff"); // 4.5 ``` -hsl(217°, 90%, 61%) -hsl(72°, 90%, 61%) +#### `textContrast(comparisonColor: Contrastrast | string, role?: "foreground" | "background", options?: ContrastOptions): number | ContrastResult` + +Calculate contrast with detailed +[WCAG compliance analysis](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) +options. + +```typescript +// Simple ratio +const ratio = bgColor.textContrast("#ffffff"); // 4.5 + +// Detailed analysis +const result = bgColor.textContrast("#ffffff", "background", { + returnDetails: true, +}); +// { +// ratio: 4.5, +// passes: { +// AA_NORMAL: true, +// AA_LARGE: true, +// AAA_NORMAL: false, +// AAA_LARGE: true +// } +// } +``` + +#### `meetsWCAG(comparisonColor: Contrastrast | string, role: "foreground" | "background", level: "AA" | "AAA", textSize?: "normal" | "large"): boolean` + +Check +[WCAG compliance](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) +for specific requirements. + +```typescript +const bgColor = new Contrastrast("#1a73e8"); + +// Check different WCAG levels +const meetsAA = bgColor.meetsWCAG("#ffffff", "background", "AA"); // true +const meetsAAA = bgColor.meetsWCAG("#ffffff", "background", "AAA"); // false +const meetsAALarge = bgColor.meetsWCAG("#ffffff", "background", "AA", "large"); // true +``` + +### Utility Methods + +#### `equals(color: Contrastrast | string): boolean` + +Compare colors for equality. + +```typescript +const color1 = new Contrastrast("#ff0000"); +const color2 = new Contrastrast("rgb(255, 0, 0)"); +console.log(color1.equals(color2)); // true +``` + +## ParseOptions Configuration + +Both the constructor and `parse` method accept optional configuration for error +handling: -hsl(121deg, 90%, 61%) +```typescript +interface ParseOptions { + throwOnError: boolean; // Whether to throw on invalid colors (default: true) + fallbackColor?: string; // Fallback color when throwOnError is false (default: "#000000") +} + +// Safe parsing with fallback +const safeColor = Contrastrast.parse("invalid-color", { + throwOnError: false, + fallbackColor: "#333333", +}); -hsl(298, 90, 61) +// Will throw on invalid color (default behavior) +const strictColor = new Contrastrast("invalid-color"); // throws Error ``` -### Alpha Formats +## Supported Color Formats + +### HEX -Currently `contrastrast` doesn't support alpha formats and will log an error and -return the default value +- `#ff0000` or `ff0000` +- `#f00` or `f00` (short format) -### Unhandled Formats +### RGB + +- `rgb(255, 0, 0)` +- `rgb(100, 200, 230)` + +### HSL -If an unhandled string is passed, by default `contrastrast` will log an error -and return the default value (`"dark"`) +- `hsl(0, 100%, 50%)` +- `hsl(217, 90%, 61%)` -## Options +## Real-World Examples -`textContrastForBGColor` takes an `ContrastrastOptions` object as an optional -second parameter, it currently has the following configuration options: +### React Component with Dynamic Text Color -```ts -type ContrastrastOptions = { - fallbackOption?: "dark" | "light"; // Defaults to "dark" if not specified - throwErrorOnUnhandled?: boolean; // Throws an error instead of returning the `fallbackOption`. Defaults to `false` if not specific +```tsx +import { Contrastrast } from "contrastrast"; + +interface ColorCardProps { + backgroundColor: string; + children: React.ReactNode; +} + +const ColorCard: React.FC = ({ backgroundColor, children }) => { + const bgColor = new Contrastrast(backgroundColor); + const textColor = bgColor.isLight() ? "#000000" : "#ffffff"; + + return ( +
+ {children} +
+ ); }; ``` +### WCAG Compliant Color Picker + +```typescript +import { Contrastrast } from "contrastrast"; + +function validateColorCombination(background: string, foreground: string): { + isValid: boolean; + level: string; + ratio: number; +} { + const bgColor = new Contrastrast(background); + const ratio = bgColor.contrastRatio(foreground); + + const meetsAAA = bgColor.meetsWCAG(foreground, "background", "AAA"); + const meetsAA = bgColor.meetsWCAG(foreground, "background", "AA"); + + return { + isValid: meetsAA, + level: meetsAAA ? "AAA" : meetsAA ? "AA" : "FAIL", + ratio, + }; +} + +const result = validateColorCombination("#1a73e8", "#ffffff"); +console.log(result); // { isValid: true, level: "AA", ratio: 4.5 } +``` + +## Standalone Utility Functions + +In addition to the Contrastrast class methods, contrastrast exports standalone +utility functions for when you need to work with colors without creating class +instances. + +### `textContrast(foreground: Contrastrast | string, background: Contrastrast | string, options?: ContrastOptions): number | ContrastResult` + +Calculate contrast ratio between two colors with optional detailed +[WCAG analysis](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html). + +```typescript +import { textContrast } from "contrastrast"; + +// Simple ratio calculation +const ratio = textContrast("#000000", "#ffffff"); // 21 + +// Detailed WCAG analysis +const analysis = textContrast("#1a73e8", "#ffffff", { returnDetails: true }); +// { +// ratio: 4.5, +// passes: { +// AA_NORMAL: true, +// AA_LARGE: true, +// AAA_NORMAL: false, +// AAA_LARGE: true +// } +// } +``` + +### `contrastRatio(color1: Contrastrast | string, color2: Contrastrast | string): number` + +Calculate the +[WCAG 2.1 contrast ratio](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-contrast-ratio) +between any two colors. + +```typescript +import { contrastRatio } from "contrastrast"; + +const ratio1 = contrastRatio("#1a73e8", "#ffffff"); // 4.5 +const ratio2 = contrastRatio("rgb(255, 0, 0)", "hsl(0, 0%, 100%)"); // 3.998 + +// Works with mixed formats +const ratio3 = contrastRatio("#000", "rgb(255, 255, 255)"); // 21 +``` + +Both functions accept color strings in any supported format (HEX, RGB, HSL) or +Contrastrast instances. + +## Legacy API Support (v0.3.x) + +For backward compatibility, contrastrast still exports the legacy v0.3.x API, +but these methods are deprecated and will be removed in v2.0. + +### `textContrastForBGColor(bgColorString: string, options?: Partial): "dark" | "light"` + +**⚠️ Deprecated** - Use `new Contrastrast(bgColor).isLight() ? "dark" : "light"` +or the new class methods instead. + +```typescript +import { textContrastForBGColor } from "contrastrast"; + +// Legacy usage (deprecated) +const textColor = textContrastForBGColor("#1a73e8"); // "light" + +// Recommended v1.0+ approach +const bgColor = new Contrastrast("#1a73e8"); +const textColor = bgColor.isLight() ? "dark" : "light"; // "light" +``` + +**Migration Guide:** + +- Replace `textContrastForBGColor(color)` with + `new Contrastrast(color).isLight() ? "dark" : "light"` +- For more sophisticated analysis, use the new WCAG-compliant methods like + `meetsWCAG()` or `textContrast()` +- The legacy `ContrastrastOptions` are replaced by `ParseOptions` for error + handling + ## Contributing -Happy for any and all contributions. Please note the project uses `pnpm` and I -prefer to have git commits formatted with -[`gitmoji-cli`](https://github.com/carloscuesta/gitmoji-cli) +Happy for any and all contributions. This project uses Deno for development with +the following commands: + +- `deno test` - Run tests +- `deno lint` - Lint code +- `deno fmt` - Format code +- `deno task build:npm` - Test building the NPM distribution + +Please note I prefer git commits formatted with +[`gitmoji-cli`](https://github.com/carloscuesta/gitmoji-cli). diff --git a/types/Colors.types.ts b/types/Colors.types.ts index f1edb50..f527d00 100644 --- a/types/Colors.types.ts +++ b/types/Colors.types.ts @@ -1,11 +1,23 @@ +/** + * RGB color values + */ export type RGBValues = { + /** Red component (0-255) */ r: number; + /** Green component (0-255) */ g: number; + /** Blue component (0-255) */ b: number; }; +/** + * HSL color values + */ export type HSLValues = { + /** Hue in degrees (0-360) */ h: number; + /** Saturation percentage (0-100) */ s: number; + /** Lightness percentage (0-100) */ l: number; }; diff --git a/types/ParseOptions.types.ts b/types/ParseOptions.types.ts index ec72e55..2fb6154 100644 --- a/types/ParseOptions.types.ts +++ b/types/ParseOptions.types.ts @@ -1,4 +1,9 @@ +/** + * Configuration options for parsing color strings + */ export type ParseOptions = { + /** Whether to throw an error on invalid color strings (default: true) */ throwOnError: boolean; + /** Fallback color to use when throwOnError is false (default: "#000000") */ fallbackColor?: string; }; diff --git a/types/WCAG.types.ts b/types/WCAG.types.ts index ff934e6..8321f80 100644 --- a/types/WCAG.types.ts +++ b/types/WCAG.types.ts @@ -1,3 +1,9 @@ +/** + * WCAG contrast compliance levels + */ export type WCAGContrastLevel = "AA" | "AAA"; +/** + * WCAG text size categories for contrast requirements + */ export type WCAGTextSize = "normal" | "large"; diff --git a/utils/textContrast.ts b/utils/textContrast.ts index 5bfc860..eece02e 100644 --- a/utils/textContrast.ts +++ b/utils/textContrast.ts @@ -2,17 +2,30 @@ import type { Contrastrast } from "../contrastrast.ts"; import { WCAG_LEVELS } from "../constants.ts"; import { contrastRatio } from "./contrastRatio.ts"; +/** + * Detailed contrast analysis result with WCAG compliance information + */ export type ContrastResult = { + /** Contrast ratio (1:1 to 21:1) */ ratio: number; + /** WCAG compliance test results */ passes: { + /** WCAG AA compliance for normal text (4.5:1 threshold) */ AA_NORMAL: boolean; + /** WCAG AA compliance for large text (3:1 threshold) */ AA_LARGE: boolean; + /** WCAG AAA compliance for normal text (7:1 threshold) */ AAA_NORMAL: boolean; + /** WCAG AAA compliance for large text (4.5:1 threshold) */ AAA_LARGE: boolean; }; }; +/** + * Options for contrast analysis functions + */ export type ContrastOptions = { + /** Return detailed WCAG analysis instead of just the ratio (default: false) */ returnDetails?: boolean; }; From 298a64c0f2af2a196152a4bd72d0e38f254b1caa Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 20:11:25 -0500 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=92=9A=20Add=20ci=20quality,=20build?= =?UTF-8?q?=20and=20publish=20stuff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-validation.yml | 45 ++++++++++ .github/workflows/publish-jsr.yml | 84 ++++++++++++++++++ .github/workflows/publish-npm.yml | 122 +++++++++++++++++++++++++++ .github/workflows/quality-checks.yml | 65 ++++++++++++++ 4 files changed, 316 insertions(+) create mode 100644 .github/workflows/pr-validation.yml create mode 100644 .github/workflows/publish-jsr.yml create mode 100644 .github/workflows/publish-npm.yml create mode 100644 .github/workflows/quality-checks.yml diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml new file mode 100644 index 0000000..0be6d09 --- /dev/null +++ b/.github/workflows/pr-validation.yml @@ -0,0 +1,45 @@ +name: PR Validation + +on: + pull_request: + branches: [main] + workflow_dispatch: + +jobs: + quality-checks: + uses: ./.github/workflows/quality-checks.yml + + build-validation: + name: Build Validation + runs-on: ubuntu-latest + needs: quality-checks + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Build NPM package + run: deno run -A scripts/build_npm.ts + + - name: Verify NPM package structure + run: | + test -f npm/package.json + test -d npm/esm + test -d npm/script + + - name: Test NPM package can be imported + working-directory: npm + run: | + npm install + node -e "const contrastrast = require('./script/mod.js'); console.log('NPM package import successful')" + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: npm-package + path: npm/ + retention-days: 7 diff --git a/.github/workflows/publish-jsr.yml b/.github/workflows/publish-jsr.yml new file mode 100644 index 0000000..e81c44b --- /dev/null +++ b/.github/workflows/publish-jsr.yml @@ -0,0 +1,84 @@ +name: Publish to JSR + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to publish (e.g., v1.0.0)" + required: true + type: string + +jobs: + validate: + name: Pre-publish Validation + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Extract version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Publishing version: ${VERSION}" + + - name: Verify version in deno.json matches tag + run: | + DENO_VERSION=$(jq -r '.version' deno.json) + TAG_VERSION="${{ steps.version.outputs.version }}" + TAG_VERSION_CLEAN="${TAG_VERSION#v}" + + if [ "$DENO_VERSION" != "$TAG_VERSION_CLEAN" ]; then + echo "Error: Version mismatch!" + echo "deno.json version: $DENO_VERSION" + echo "Tag version: $TAG_VERSION_CLEAN" + exit 1 + fi + + - name: Run quality checks + run: | + deno fmt --check + deno lint + deno test + + publish: + name: Publish to JSR + runs-on: ubuntu-latest + needs: validate + environment: publishing + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Publish to JSR + env: + JSR_TOKEN: ${{ secrets.JSR_TOKEN }} + run: deno publish + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.validate.outputs.version }} + name: Release ${{ needs.validate.outputs.version }} + generate_release_notes: true + draft: false + prerelease: ${{ contains(needs.validate.outputs.version, '-') }} diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 0000000..334d549 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,122 @@ +name: Publish to NPM + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to publish (e.g., v1.0.0)" + required: true + type: string + +jobs: + validate: + name: Pre-publish Validation + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Extract version from tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION="${GITHUB_REF#refs/tags/}" + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "Publishing version: ${VERSION}" + + - name: Verify version in deno.json matches tag + run: | + DENO_VERSION=$(jq -r '.version' deno.json) + TAG_VERSION="${{ steps.version.outputs.version }}" + TAG_VERSION_CLEAN="${TAG_VERSION#v}" + + if [ "$DENO_VERSION" != "$TAG_VERSION_CLEAN" ]; then + echo "Error: Version mismatch!" + echo "deno.json version: $DENO_VERSION" + echo "Tag version: $TAG_VERSION_CLEAN" + exit 1 + fi + + - name: Run quality checks + run: | + deno fmt --check + deno lint + deno test + + build-and-publish: + name: Build and Publish to NPM + runs-on: ubuntu-latest + needs: validate + environment: publishing + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "18" + registry-url: "https://registry.npmjs.org" + + - name: Build NPM package with dnt + run: deno run -A scripts/build_npm.ts + + - name: Verify NPM package version matches tag + run: | + NPM_VERSION=$(jq -r '.version' npm/package.json) + TAG_VERSION="${{ needs.validate.outputs.version }}" + TAG_VERSION_CLEAN="${TAG_VERSION#v}" + + if [ "$NPM_VERSION" != "$TAG_VERSION_CLEAN" ]; then + echo "Error: NPM package version mismatch!" + echo "npm/package.json version: $NPM_VERSION" + echo "Tag version: $TAG_VERSION_CLEAN" + exit 1 + fi + + - name: Verify package structure + run: | + test -f npm/package.json + test -d npm/esm + test -d npm/script + test -f npm/LICENSE + test -f npm/README.md + + - name: Test NPM package imports + working-directory: npm + run: | + # Test CommonJS import + node -e "const contrastrast = require('./script/mod.js'); console.log('CommonJS import successful:', typeof contrastrast);" + + # Test ESM import + node -e "import('./esm/mod.js').then(m => console.log('ESM import successful:', typeof m.default || typeof m));" + + - name: Publish to NPM + working-directory: npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [[ "${{ needs.validate.outputs.version }}" == *"-"* ]]; then + echo "Publishing non-stable release with beta tag" + npm publish --tag beta + else + echo "Publishing stable release" + npm publish + fi diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml new file mode 100644 index 0000000..4536acf --- /dev/null +++ b/.github/workflows/quality-checks.yml @@ -0,0 +1,65 @@ +name: Quality Checks + +on: + push: + branches: ["*"] + workflow_dispatch: + +jobs: + format: + name: Format Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Check formatting + run: deno fmt --check + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: v2.x + + - name: Run linter + run: deno lint + + test: + name: Test on Deno ${{ matrix.deno-version }} + runs-on: ubuntu-latest + strategy: + matrix: + deno-version: ["v1.x", "v2.x"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Deno + uses: denoland/setup-deno@v1 + with: + deno-version: ${{ matrix.deno-version }} + + - name: Run tests + run: deno test --coverage=coverage + + - name: Generate coverage report + run: deno coverage coverage --lcov --output=coverage.lcov + + - name: Upload coverage reports + uses: codecov/codecov-action@v3 + with: + file: ./coverage.lcov + fail_ci_if_error: false From 21e6abbdd6f4954caa8b7370bb0a3e8697844365 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 21:00:57 -0500 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=92=9A=20update=20CI=20to=20run=20on?= =?UTF-8?q?=20every=20PR,=20fix=20dupe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-validation.yml | 4 ---- .github/workflows/quality-checks.yml | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 0be6d09..0568a5b 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -6,13 +6,9 @@ on: workflow_dispatch: jobs: - quality-checks: - uses: ./.github/workflows/quality-checks.yml - build-validation: name: Build Validation runs-on: ubuntu-latest - needs: quality-checks steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 4536acf..9b65ca1 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -1,8 +1,7 @@ name: Quality Checks on: - push: - branches: ["*"] + pull_request: workflow_dispatch: jobs: From 4965ee028b76ac1746d7e10ee00840c41616e161 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 21:03:47 -0500 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=92=9A=20Drop=20deno=201.x=20test=20r?= =?UTF-8?q?unning=20(it=20wasn't=20worth=20it)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality-checks.yml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 9b65ca1..74435cc 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -36,11 +36,8 @@ jobs: run: deno lint test: - name: Test on Deno ${{ matrix.deno-version }} + name: Test w/ Deno runs-on: ubuntu-latest - strategy: - matrix: - deno-version: ["v1.x", "v2.x"] steps: - name: Checkout code @@ -49,7 +46,7 @@ jobs: - name: Setup Deno uses: denoland/setup-deno@v1 with: - deno-version: ${{ matrix.deno-version }} + deno-version: v2.x - name: Run tests run: deno test --coverage=coverage From 45909c35ce3584d2bfd761fc9631b551f3586e06 Mon Sep 17 00:00:00 2001 From: Alex Muench Date: Sat, 16 Aug 2025 21:07:37 -0500 Subject: [PATCH 5/5] =?UTF-8?q?=F0=9F=92=9A=20Drop=20code=20coverage=20upl?= =?UTF-8?q?oading=20for=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/quality-checks.yml | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 74435cc..449bb5d 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -49,13 +49,4 @@ jobs: deno-version: v2.x - name: Run tests - run: deno test --coverage=coverage - - - name: Generate coverage report - run: deno coverage coverage --lcov --output=coverage.lcov - - - name: Upload coverage reports - uses: codecov/codecov-action@v3 - with: - file: ./coverage.lcov - fail_ci_if_error: false + run: deno test