diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..ceb48da --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,55 @@ +name: Publish Package + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-*" + workflow_dispatch: + +# id-token: write is required for npm provenance (OIDC) +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + name: Publish to npm + steps: + - name: Checkout 🛎️ + uses: actions/checkout@v5 + - name: Setup pnpm 🔧 + uses: pnpm/action-setup@v4 + - name: Setup Node.js 🔧 + uses: actions/setup-node@v6 + with: + node-version: "lts/*" + check-latest: true + cache: "pnpm" + registry-url: "https://registry.npmjs.org" + - name: Verify tag matches package.json version 🔍 + run: | + TAG="${GITHUB_REF_NAME#v}" + PKG="$(node -p "require('./package.json').version")" + if [ "$TAG" != "$PKG" ]; then + echo "::error::Tag '$TAG' does not match package.json version '$PKG'" + exit 1 + fi + echo "Publishing version $PKG" + - name: Install dependencies 🪄 + run: pnpm install --frozen-lockfile + - name: Lint 🔍 + run: pnpm run lint + - name: TypeScript 🔍 + run: pnpm run check + - name: Vitest 🔍 + run: pnpm run spec + - name: Build 💎 + run: pnpm run build + # Published with npm, not pnpm: pnpm publish accepts --provenance but + # silently ignores it, yielding a publish with no attestation. + - name: Publish to npm 🚀 + run: npm publish --access public --provenance --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/src/content/docs/reference/spacing.md b/docs/src/content/docs/reference/spacing.md index 5366480..86cbb81 100644 --- a/docs/src/content/docs/reference/spacing.md +++ b/docs/src/content/docs/reference/spacing.md @@ -72,6 +72,18 @@ Control spacing between flex items: ``` +Use directional gaps to control the horizontal and vertical axes independently: + +```tsx + + {/* columnGap: 8, rowGap: 6 */} + + + + {/* columnGap: 12, rowGap: 4.5 */} + +``` + ## Available Sizes Spacing values follow a scale where `1` = 4px: @@ -122,6 +134,8 @@ Use arbitrary values for custom spacing not in the preset scale: // padding: 20 // marginHorizontal: 24 // gap: 12 + // columnGap: 12 + // rowGap: 6 ``` :::note diff --git a/docs/src/content/docs/reference/transforms.md b/docs/src/content/docs/reference/transforms.md index e7ca254..4309c9c 100644 --- a/docs/src/content/docs/reference/transforms.md +++ b/docs/src/content/docs/reference/transforms.md @@ -12,6 +12,7 @@ Apply 2D and 3D transformations to views with React Native's transform API. All ```tsx // scale: 0 // scale: 0.5 + // scale: 0.8 // scale: 1 // scale: 1.1 // scale: 1.5 @@ -22,12 +23,14 @@ Apply 2D and 3D transformations to views with React Native's transform API. All ```tsx // scaleX: 1.1 // scaleY: 0.9 + // scaleX: -0.8 ``` ### Arbitrary Values ```tsx // scale: 1.23 + // scale: 0.8 // scaleX: 0.5 // scaleY: 2.5 ``` diff --git a/src/parser/spacing.test.ts b/src/parser/spacing.test.ts index 2813a37..17efd74 100644 --- a/src/parser/spacing.test.ts +++ b/src/parser/spacing.test.ts @@ -197,6 +197,18 @@ describe("parseSpacing - gap", () => { expect(parseSpacing("gap-[100px]")).toEqual({ gap: 100 }); expect(parseSpacing("gap-[100]")).toEqual({ gap: 100 }); }); + + it("should parse horizontal and vertical gaps", () => { + expect(parseSpacing("gap-x-2")).toEqual({ columnGap: 8 }); + expect(parseSpacing("gap-y-1.5")).toEqual({ rowGap: 6 }); + expect(parseSpacing("gap-x-px")).toEqual({ columnGap: 1 }); + expect(parseSpacing("gap-y-0")).toEqual({ rowGap: 0 }); + }); + + it("should parse directional gaps with arbitrary values", () => { + expect(parseSpacing("gap-x-[12px]")).toEqual({ columnGap: 12 }); + expect(parseSpacing("gap-y-[4.5]")).toEqual({ rowGap: 4.5 }); + }); }); describe("parseSpacing - edge cases", () => { @@ -507,6 +519,8 @@ describe("parseSpacing - custom spacing", () => { expect(parseSpacing("gap-xs", customSpacing)).toEqual({ gap: 4 }); expect(parseSpacing("gap-md", customSpacing)).toEqual({ gap: 16 }); expect(parseSpacing("gap-xl", customSpacing)).toEqual({ gap: 64 }); + expect(parseSpacing("gap-x-sm", customSpacing)).toEqual({ columnGap: 8 }); + expect(parseSpacing("gap-y-lg", customSpacing)).toEqual({ rowGap: 32 }); }); it("should allow custom spacing to override preset values", () => { diff --git a/src/parser/spacing.ts b/src/parser/spacing.ts index 8635db5..c9a3f4e 100644 --- a/src/parser/spacing.ts +++ b/src/parser/spacing.ts @@ -70,7 +70,7 @@ function parseArbitrarySpacing(value: string): number | null { /** * Parse spacing classes (margin, padding, gap) - * Examples: m-4, mx-2, mt-8, p-4, px-2, pt-8, gap-4, m-[16px], pl-[4.5px], -m-4, -mt-[10px], ms-4, pe-2, mx-auto + * Examples: m-4, mx-2, mt-8, p-4, px-2, pt-8, gap-4, gap-x-2, gap-y-1.5, m-[16px], pl-[4.5px], -m-4, -mt-[10px], ms-4, pe-2, mx-auto * @param cls - The class name to parse * @param customSpacing - Optional custom spacing values from tailwind.config */ @@ -128,27 +128,37 @@ export function parseSpacing(cls: string, customSpacing?: Record } } - // Gap: gap-4, gap-[16px] - const gapMatch = cls.match(/^gap-(.+)$/); + // Gap: gap-4, gap-x-2, gap-y-1.5, gap-[16px] + const gapMatch = cls.match(/^gap(?:-([xy]))?-(.+)$/); if (gapMatch) { - const valueStr = gapMatch[1]; + const [, direction = "", valueStr] = gapMatch; // Try arbitrary value first (highest priority) const arbitraryValue = parseArbitrarySpacing(valueStr); if (arbitraryValue !== null) { - return { gap: arbitraryValue }; + return getGapStyle(direction, arbitraryValue); } // Try spacing scale (includes custom spacing) const scaleValue = spacingMap[valueStr]; if (scaleValue !== undefined) { - return { gap: scaleValue }; + return getGapStyle(direction, scaleValue); } } return null; } +/** + * Get gap style object based on Tailwind's gap direction. + * gap-x controls spacing between columns; gap-y controls spacing between rows. + */ +function getGapStyle(direction: string, value: number): StyleObject { + if (direction === "x") return { columnGap: value }; + if (direction === "y") return { rowGap: value }; + return { gap: value }; +} + /** * Get margin style object based on direction */ diff --git a/src/parser/transforms.test.ts b/src/parser/transforms.test.ts index b87ae2b..ef3f420 100644 --- a/src/parser/transforms.test.ts +++ b/src/parser/transforms.test.ts @@ -32,6 +32,7 @@ describe("parseTransform - scale utilities", () => { expect(parseTransform("scale-0")).toEqual({ transform: [{ scale: 0 }] }); expect(parseTransform("scale-50")).toEqual({ transform: [{ scale: 0.5 }] }); expect(parseTransform("scale-75")).toEqual({ transform: [{ scale: 0.75 }] }); + expect(parseTransform("scale-80")).toEqual({ transform: [{ scale: 0.8 }] }); expect(parseTransform("scale-90")).toEqual({ transform: [{ scale: 0.9 }] }); expect(parseTransform("scale-95")).toEqual({ transform: [{ scale: 0.95 }] }); expect(parseTransform("scale-100")).toEqual({ transform: [{ scale: 1 }] }); @@ -40,11 +41,14 @@ describe("parseTransform - scale utilities", () => { expect(parseTransform("scale-125")).toEqual({ transform: [{ scale: 1.25 }] }); expect(parseTransform("scale-150")).toEqual({ transform: [{ scale: 1.5 }] }); expect(parseTransform("scale-200")).toEqual({ transform: [{ scale: 2 }] }); + expect(parseTransform("scale-102.5")).toEqual({ transform: [{ scale: 1.025 }] }); + expect(parseTransform("scale-999")).toEqual({ transform: [{ scale: 9.99 }] }); }); it("should parse scaleX values", () => { expect(parseTransform("scale-x-0")).toEqual({ transform: [{ scaleX: 0 }] }); expect(parseTransform("scale-x-50")).toEqual({ transform: [{ scaleX: 0.5 }] }); + expect(parseTransform("scale-x-80")).toEqual({ transform: [{ scaleX: 0.8 }] }); expect(parseTransform("scale-x-100")).toEqual({ transform: [{ scaleX: 1 }] }); expect(parseTransform("scale-x-150")).toEqual({ transform: [{ scaleX: 1.5 }] }); }); @@ -52,16 +56,21 @@ describe("parseTransform - scale utilities", () => { it("should parse scaleY values", () => { expect(parseTransform("scale-y-0")).toEqual({ transform: [{ scaleY: 0 }] }); expect(parseTransform("scale-y-75")).toEqual({ transform: [{ scaleY: 0.75 }] }); + expect(parseTransform("scale-y-80")).toEqual({ transform: [{ scaleY: 0.8 }] }); expect(parseTransform("scale-y-100")).toEqual({ transform: [{ scaleY: 1 }] }); expect(parseTransform("scale-y-125")).toEqual({ transform: [{ scaleY: 1.25 }] }); }); it("should parse arbitrary scale values", () => { expect(parseTransform("scale-[1.23]")).toEqual({ transform: [{ scale: 1.23 }] }); + expect(parseTransform("scale-[80]")).toEqual({ transform: [{ scale: 80 }] }); expect(parseTransform("scale-[0.5]")).toEqual({ transform: [{ scale: 0.5 }] }); expect(parseTransform("scale-[2.5]")).toEqual({ transform: [{ scale: 2.5 }] }); expect(parseTransform("scale-x-[1.5]")).toEqual({ transform: [{ scaleX: 1.5 }] }); expect(parseTransform("scale-y-[0.8]")).toEqual({ transform: [{ scaleY: 0.8 }] }); + expect(parseTransform("scale-[80%]")).toEqual({ transform: [{ scale: 0.8 }] }); + expect(parseTransform("scale-x-[125%]")).toEqual({ transform: [{ scaleX: 1.25 }] }); + expect(parseTransform("scale-y-[80%]")).toEqual({ transform: [{ scaleY: 0.8 }] }); }); it("should parse negative arbitrary scale values", () => { @@ -69,9 +78,15 @@ describe("parseTransform - scale utilities", () => { expect(parseTransform("scale-x-[-0.5]")).toEqual({ transform: [{ scaleX: -0.5 }] }); }); + it("should parse negative numeric scale values", () => { + expect(parseTransform("-scale-80")).toEqual({ transform: [{ scale: -0.8 }] }); + expect(parseTransform("-scale-x-125")).toEqual({ transform: [{ scaleX: -1.25 }] }); + expect(parseTransform("-scale-y-50")).toEqual({ transform: [{ scaleY: -0.5 }] }); + }); + it("should return null for invalid scale values", () => { - expect(parseTransform("scale-999")).toBeNull(); expect(parseTransform("scale-invalid")).toBeNull(); + expect(parseTransform("scale-10x")).toBeNull(); }); }); diff --git a/src/parser/transforms.ts b/src/parser/transforms.ts index c75d813..b315e50 100644 --- a/src/parser/transforms.ts +++ b/src/parser/transforms.ts @@ -59,13 +59,14 @@ export const PERSPECTIVE_SCALE: Record = { }; /** - * Parse arbitrary scale value: [1.23], [0.5] + * Parse arbitrary scale value: [1.23], [0.5], [80%] * Returns number for valid scale, null otherwise */ function parseArbitraryScale(value: string): number | null { - const scaleMatch = value.match(/^\[(-?\d+(?:\.\d+)?)\]$/); + const scaleMatch = value.match(/^\[(-?\d+(?:\.\d+)?)(%)?\]$/); if (scaleMatch) { - return parseFloat(scaleMatch[1]); + const parsedValue = Number.parseFloat(scaleMatch[1]); + return scaleMatch[2] === "%" ? parsedValue / 100 : parsedValue; } // Unsupported format @@ -73,7 +74,7 @@ function parseArbitraryScale(value: string): number | null { /* v8 ignore next 5 */ if (process.env.NODE_ENV !== "production") { console.warn( - `[react-native-tailwind] Invalid arbitrary scale value: ${value}. Only numbers are supported (e.g., [1.5], [0.75]).`, + `[react-native-tailwind] Invalid arbitrary scale value: ${value}. Only numbers and percentages are supported (e.g., [1.5], [0.75], [80%]).`, ); } return null; @@ -82,6 +83,29 @@ function parseArbitraryScale(value: string): number | null { return null; } +/** + * Parse a Tailwind scale value. + * Named numeric utilities are percentages (scale-80 -> 0.8), while bracketed + * arbitrary numbers are raw React Native multipliers (scale-[1.25] -> 1.25). + */ +function parseScaleValue(value: string): number | null { + const arbitraryScale = parseArbitraryScale(value); + if (arbitraryScale !== null) { + return arbitraryScale; + } + + const presetScale = SCALE_MAP[value]; + if (presetScale !== undefined) { + return presetScale; + } + + if (/^\d+(?:\.\d+)?$/.test(value)) { + return Number.parseFloat(value) / 100; + } + + return null; +} + /** * Parse arbitrary rotation value: [37deg], [-15deg] * Returns string for valid rotation, null otherwise @@ -181,51 +205,33 @@ export function parseTransform(cls: string, customSpacing?: Record