Skip to content
Open
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: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ jobs:
cache: true
require-lockfile: true

- name: Test image attribute plugin
run: pnpm exec tsx --test tests/remark-image-attributes.test.ts

- name: Build site
run: pnpm build

Expand Down
39 changes: 35 additions & 4 deletions src/plugins/remark-image-attributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ export function remarkImageAttributes() {

const children = node.children;

// A shared wrapper cannot carry separate alignments for multiple images.
// In that case, give each image its own block-level alignment instead.
const multipleImages =
children.filter((child) => child.type === 'image').length > 1;
if (multipleImages) {
node.data = node.data || {};
node.data.hName = 'div';
node.data.hProperties = node.data.hProperties || {};
node.data.hProperties.class = 'img-wrapper img-multiple';
}

// Look for images with hash attributes in URL
for (const child of children) {
if (child.type === 'image') {
Expand Down Expand Up @@ -54,6 +65,18 @@ export function remarkImageAttributes() {
imgStyle += ` border: ${border};`;
}

if (multipleImages) {
// Keep image styles isolated instead of aligning the entire
// paragraph (which would also align sibling images).
const margins =
align === 'left'
? ' margin-left: 0; margin-right: auto;'
: align === 'right'
? ' margin-left: auto; margin-right: 0;'
: ' margin-left: auto; margin-right: auto;';
imgStyle += ` display: block;${margins}`;
}

// Set image properties
child.data = child.data || {};
child.data.hProperties = child.data.hProperties || {};
Expand All @@ -74,10 +97,18 @@ export function remarkImageAttributes() {
}

// Wrap the paragraph to act as a container
node.data = node.data || {};
node.data.hName = 'div';
node.data.hProperties = node.data.hProperties || {};
node.data.hProperties.class = `img-wrapper img-align-${align}`;
if (!multipleImages) {
node.data = node.data || {};
node.data.hName = 'div';
node.data.hProperties = node.data.hProperties || {};
node.data.hProperties.class = `img-wrapper img-align-${align}`;
}
} else if (multipleImages) {
// Unattributed images retain their own default left alignment.
child.data = child.data || {};
child.data.hProperties = child.data.hProperties || {};
child.data.hProperties.style =
'display: block; margin-left: 0; margin-right: auto;';
} else if (children.length === 1) {
// Standalone image without attributes - still wrap and left-align
node.data = node.data || {};
Expand Down
61 changes: 61 additions & 0 deletions tests/remark-image-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { Image, Paragraph, Root } from 'mdast';
import { remarkImageAttributes } from '../src/plugins/remark-image-attributes';

function image(url: string): Image {
return { type: 'image', url, alt: 'Example' };
}

function transform(...children: Image[]): Paragraph {
const paragraph: Paragraph = { type: 'paragraph', children };
const tree: Root = { type: 'root', children: [paragraph] };
remarkImageAttributes()(tree);
return paragraph;
}

test('multiple images keep independent width, border and alignment', () => {
const first = image('./plain.webp');
const second = image('./styled.webp#w=60&border&align=right');
const paragraph = transform(first, second);

assert.equal(paragraph.data?.hProperties?.class, 'img-wrapper img-multiple');
assert.equal(paragraph.data?.hName, 'div');
assert.equal(first.data?.hProperties?.style,
'display: block; margin-left: 0; margin-right: auto;');
assert.equal(first.data?.hProperties?.['data-slide-width'], undefined);
assert.equal(first.url, './plain.webp');
assert.equal(second.url, './styled.webp');
assert.match(String(second.data?.hProperties?.style), /width: 60%/);
assert.match(String(second.data?.hProperties?.style), /border: 5px solid #ADADAD/);
assert.match(String(second.data?.hProperties?.style), /margin-left: auto; margin-right: 0/);
assert.equal(second.data?.hProperties?.['data-slide-width'], '60');
assert.equal(second.data?.hProperties?.['data-slide-align'], 'right');
});

test('different alignments in one paragraph do not overwrite each other', () => {
const first = image('./one.webp#align=left');
const second = image('./two.webp#align=center');
transform(first, second);

assert.match(String(first.data?.hProperties?.style), /margin-left: 0; margin-right: auto/);
assert.match(String(second.data?.hProperties?.style), /margin-left: auto; margin-right: auto/);
});

test('single-image paragraphs preserve existing wrapper behavior', () => {
const img = image('./single.webp#w=50&align=right');
const paragraph = transform(img);

assert.equal(paragraph.data?.hName, 'div');
assert.equal(paragraph.data?.hProperties?.class, 'img-wrapper img-align-right');
assert.equal(img.data?.hProperties?.style, 'width: 50%;');
assert.equal(img.url, './single.webp');
});

test('unattributed single image remains left aligned', () => {
const img = image('./plain.webp');
const paragraph = transform(img);

assert.equal(paragraph.data?.hProperties?.class, 'img-wrapper img-align-left');
assert.equal(img.data, undefined);
});
Loading