also looking at this
fix(image): apply builtin image fitting props
#2094
5 files
+49
-3
| @@ -635,6 +635,7 @@ External links (`http://`, `https://`, `mailto:`, `tel:`) stay as `<a href>` — | ||
| 635 | 635 | - `placeholder="blur"` (default) — inline SVG blur placeholder while loading; `placeholder="color"` + `placeholderColor="#…"` for a solid fill |
| 636 | 636 | - `lazy={false}` — opt out of lazy loading for above-the-fold heroes |
| 637 | 637 | - `preload` — emits a `<link rel="preload">` hint |
| 638 | - `objectFit="contain"` / `objectPosition="left center"` — fit and align the image inside its box; `fit` / `position` are aliases shared with `<Image>` | |
| 638 | 639 | - `provider="cloudinary"` / `"imgix"` / `"bunny"` — CDN URL rewriting; `quality` and `densities="1x 2x 3x"` are also accepted |
| 639 | 640 | |
| 640 | 641 | For genuinely dynamic URLs with no transforms, `x-src` / `x-alt` on a plain `<img>` is acceptable. Otherwise prefer `<StxImage>`. |
bun.lockmodified+2-2
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | }, |
| 11 | 11 | "devDependencies": { |
| 12 | 12 | "@iconify/json": "^2.2.535", |
| 13 | "@stacksjs/bumpx": "^0.2.21", | |
| 13 | "@stacksjs/bumpx": "0.2.25", | |
| 14 | 14 | "better-dx": "^0.2.38", |
| 15 | 15 | }, |
| 16 | 16 | }, |
| @@ -1755,7 +1755,7 @@ | ||
| 1755 | 1755 | |
| 1756 | 1756 | "@stacksjs/benchmarks": ["@stacksjs/benchmarks@workspace:packages/benchmarks"], |
| 1757 | 1757 | |
| 1758 | "@stacksjs/bumpx": ["@stacksjs/bumpx@0.2.21", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/logsmith": "^0.2.6", "bunfig": "^0.15.6" }, "bin": { "bumpx": "./dist/bin/cli.js" } }, "sha512-OFbqVX35ksAMlRQ7cEUltEqmA0zoBQdK4Zv0Me5FBlFQDt1JIo7K+uE9zCLIry4dyNa7MoKxCeGdGhH4iJbtQA=="], | |
| 1758 | "@stacksjs/bumpx": ["@stacksjs/bumpx@0.2.25", "", { "dependencies": { "@stacksjs/clapp": "^0.2.0", "@stacksjs/logsmith": "^0.2.6", "bunfig": "^0.15.6" }, "bin": { "bumpx": "./dist/bin/cli.js" } }, "sha512-gReU/g2BZ7XCTwSVf7Cf8ImnZFWEyXlh9eZDZNN6SOUf0VvNOCxKt+bfg9JG8QPro/M8FVy3XayTvVVOfUmFVw=="], | |
| 1759 | 1759 | |
| 1760 | 1760 | "@stacksjs/bunpress": ["@stacksjs/bunpress@0.2.14", "", { "dependencies": { "@cwcss/crosswind": "^0.2.16", "@stacksjs/clapp": "^0.2.0", "@stacksjs/stx": "^0.2.171", "@stacksjs/ts-i18n": "^0.1.11", "bunfig": "^0.15.17", "ts-syntax-highlighter": "^0.2.14" }, "peerDependencies": { "@stacksjs/ts-cloud": "^0.7.51" }, "optionalPeers": ["@stacksjs/ts-cloud"], "bin": { "bunpress": "./dist/bin/cli.js" } }, "sha512-o0Bf8b7kXKSyH5xXrVrdA46gPzMVssLBnbP82t8WzF17gIveFLNW+CzEZe3n9o+Ui2wiy2+6IF5SwGfrdTtWcQ=="], |
| 1761 | 1761 | |
| @@ -240,6 +240,8 @@ export const StxImageBuiltin: BuiltinComponentDef = { | ||
| 240 | 240 | const provider = resolveProp(props, 'provider') |
| 241 | 241 | const className = resolveProp(props, 'class') || resolveProp(props, 'className') || '' |
| 242 | 242 | const style = resolveProp(props, 'style') || '' |
| 243 | const objectFit = resolveProp(props, 'objectFit') || resolveProp(props, 'fit') | |
| 244 | const objectPosition = resolveProp(props, 'objectPosition') || resolveProp(props, 'position') | |
| 243 | 245 | const quality = resolveProp(props, 'quality') |
| 244 | 246 | |
| 245 | 247 | const lazy = resolveBoolProp(props, 'lazy', true) |
| @@ -277,6 +279,8 @@ export const StxImageBuiltin: BuiltinComponentDef = { | ||
| 277 | 279 | // ── Aspect ratio for CLS prevention ────────────────────────── |
| 278 | 280 | const styles: string[] = [] |
| 279 | 281 | if (style) styles.push(style) |
| 282 | if (objectFit) styles.push(`object-fit:${objectFit}`) | |
| 283 | if (objectPosition) styles.push(`object-position:${objectPosition}`) | |
| 280 | 284 | |
| 281 | 285 | if (numWidth && numHeight) { |
| 282 | 286 | styles.push(`aspect-ratio:${numWidth}/${numHeight}`) |
| @@ -365,6 +369,7 @@ export const StxImageBuiltin: BuiltinComponentDef = { | ||
| 365 | 369 | const consumedStatic = new Set([ |
| 366 | 370 | 'src', 'alt', 'width', 'height', 'lazy', 'sizes', 'srcset', |
| 367 | 371 | 'placeholder', 'placeholderColor', 'class', 'className', 'style', |
| 372 | 'objectFit', 'objectPosition', 'fit', 'position', | |
| 368 | 373 | 'format', 'provider', 'quality', 'densities', 'preload', 'priority', 'picture', 'loading', 'decoding', |
| 369 | 374 | ]) |
| 370 | 375 | |
| @@ -0,0 +1,40 @@ | ||
| 1 | import path from 'node:path' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { processDirectives } from '../../src/process' | |
| 4 | ||
| 5 | const pagePath = path.resolve(__dirname, 'image-fit-page.stx') | |
| 6 | ||
| 7 | function render(template: string, context: Record<string, unknown> = {}): Promise<string> { | |
| 8 | return processDirectives(template, context, pagePath, { debug: false }, new Set<string>()) | |
| 9 | } | |
| 10 | ||
| 11 | describe('builtin image fitting', () => { | |
| 12 | it.each(['Image', 'StxImage'])('applies %s layout props to the image style', async (component) => { | |
| 13 | const output = await render(`<${component} src="/wordmark.svg" alt="HQ" width="683" height="275" objectFit="contain" objectPosition="left center" priority />`) | |
| 14 | expect(output).toContain('object-fit:contain') | |
| 15 | expect(output).toContain('object-position:left center') | |
| 16 | expect(output).toContain('aspect-ratio:683/275') | |
| 17 | expect(output).toContain('loading="eager"') | |
| 18 | expect(output).not.toMatch(/\sobject(?:Fit|Position)=/i) | |
| 19 | }) | |
| 20 | ||
| 21 | it('resolves server values and keeps author styles and attributes', async () => { | |
| 22 | const output = await render('<Image src="/wordmark.svg" alt="HQ" :objectFit="fitMode" :objectPosition="anchor" style="width:96px;height:36px" data-role="brand" />', { fitMode: 'contain', anchor: 'center' }) | |
| 23 | expect(output).toContain('width:96px;height:36px;object-fit:contain;object-position:center') | |
| 24 | expect(output).toContain('data-role="brand"') | |
| 25 | expect(output).not.toMatch(/\sobject(?:Fit|Position)=/i) | |
| 26 | }) | |
| 27 | ||
| 28 | it('accepts the Image component aliases without leaking them to HTML', async () => { | |
| 29 | const output = await render('<Image src="/wordmark.svg" alt="HQ" fit="contain" position="top" />') | |
| 30 | expect(output).toContain('object-fit:contain') | |
| 31 | expect(output).toContain('object-position:top') | |
| 32 | expect(output).not.toMatch(/\s(?:fit|position)=/) | |
| 33 | }) | |
| 34 | ||
| 35 | it('leaves the browser default unchanged when no fit is requested', async () => { | |
| 36 | const output = await render('<StxImage src="/photo.jpg" alt="Training" />') | |
| 37 | expect(output).not.toContain('object-fit:') | |
| 38 | expect(output).not.toContain('object-position:') | |
| 39 | }) | |
| 40 | }) | |