Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/ready-monkeys-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Adds support for importing `.apng` files as image metadata for use with standard `<img>` elements. Astro's image components reject APNG files to avoid removing their animation
5 changes: 5 additions & 0 deletions .changeset/young-suns-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'astro': patch
---

Fixes the `glob()` content loader failing to load files with colons in their names (e.g., `Guide: Architecture.md`)
4 changes: 4 additions & 0 deletions packages/astro/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ declare module '*.png' {
const metadata: ImageMetadata;
export default metadata;
}
declare module '*.apng' {
const metadata: ImageMetadata;
export default metadata;
}
declare module '*.tiff' {
const metadata: ImageMetadata;
export default metadata;
Expand Down
1 change: 1 addition & 0 deletions packages/astro/src/assets/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const VALID_INPUT_FORMATS = [
'jpeg',
'jpg',
'png',
'apng',
'tiff',
'webp',
'gif',
Expand Down
3 changes: 3 additions & 0 deletions packages/astro/src/assets/utils/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ export async function emitImageMetadata(
}

const fileMetadata = await imageMetadata(fileData, id);
if (path.extname(id).toLowerCase() === '.apng') {
fileMetadata.format = 'apng';
}

const emittedImage: Omit<ImageMetadataWithContents, 'fsPath'> = {
src: '',
Expand Down
6 changes: 3 additions & 3 deletions packages/astro/src/content/loaders/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ function generateIdDefault({ entry, base, data }: GenerateIdOptions, isLegacy?:
if (data.slug) {
return String(data.slug);
}
const entryURL = new URL(encodeURI(entry), base);
const entryURL = new URL('./' + encodeURI(entry), base);
if (isLegacy) {
// Legacy behavior: use ID based on path, not slug
const { id } = getContentEntryIdAndSlug({
Expand Down Expand Up @@ -132,7 +132,7 @@ export function glob(globOptions: GlobOptions & { [secretLegacyFlag]?: boolean }
logger.warn(`No entry type found for ${entry}`);
return;
}
const fileUrl = new URL(encodeURI(entry), base);
const fileUrl = new URL('./' + encodeURI(entry), base);
const contents = await fs.readFile(fileUrl, 'utf-8').catch((err) => {
logger.error(`Error reading ${entry}: ${err.message}`);
return;
Expand Down Expand Up @@ -314,7 +314,7 @@ export function glob(globOptions: GlobOptions & { [secretLegacyFlag]?: boolean }
);

function isConfigFile(file: string) {
const fileUrl = new URL(file, baseDir);
const fileUrl = new URL('./' + encodeURI(file), baseDir);
return configFiles.has(fileUrl.href);
}

Expand Down
32 changes: 32 additions & 0 deletions packages/astro/test/core-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,38 @@ describe('astro:image', () => {
);
});

it('imports APNG metadata for a standard img element', async () => {
logs.length = 0;
const res = await fixture.fetch('/apng-img');
const html = await res.text();
const $ = cheerio.load(html);

assert.equal(res.status, 200);
assert.match($('#apng').attr('src')!, /animated\.apng/);
assert.equal($('#apng').attr('width'), '2');
assert.equal($('#apng').attr('height'), '3');
assert.equal($('#format').text(), 'apng');
assert.equal(logs.length, 0);
});

it('rejects APNG images in the Image component', async () => {
logs.length = 0;
const res = await fixture.fetch('/apng-image');
await res.text();

assert.equal(logs.length >= 1, true);
assert.match(logs[0].message, /Received unsupported format `apng`/);
});

it('rejects APNG images in the Picture component', async () => {
logs.length = 0;
const res = await fixture.fetch('/apng-picture');
await res.text();

assert.equal(logs.length >= 1, true);
assert.match(logs[0].message, /Received unsupported format `apng`/);
});

it('properly error image in Markdown frontmatter is not found', async () => {
logs.length = 0;
let res = await fixture.fetch('/blog/one');
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
import { Image } from 'astro:assets';
import image from '../images/animated.apng';
---

<Image src={image} alt="Animated image" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
import image from '../images/animated.apng';
---

<img id="apng" src={image.src} width={image.width} height={image.height} alt="Animated image" />
<span id="format">{image.format}</span>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
import { Picture } from 'astro:assets';
import image from '../images/animated.apng';
---

<Picture src={image} alt="Animated image" />
13 changes: 13 additions & 0 deletions packages/astro/test/units/assets/emit-image-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import { describe, it } from 'node:test';
import { writeFile, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { emitImageMetadata } from '../../../dist/assets/utils/node.js';

const APNG_FIXTURE = fileURLToPath(
new URL('../../fixtures/core-image-errors/src/images/animated.apng', import.meta.url),
);

// Minimal valid 1×1 JPEG
const TINY_JPEG = Buffer.from([
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01,
Expand Down Expand Up @@ -45,4 +50,12 @@ describe('emitImageMetadata', () => {
await rm(dir, { recursive: true, force: true });
}
});

it('returns APNG metadata with dimensions', async () => {
const result = await emitImageMetadata(APNG_FIXTURE);
assert.ok(result, 'expected metadata to be returned');
assert.equal(result.width, 2);
assert.equal(result.height, 3);
assert.equal(result.format, 'apng');
});
});
15 changes: 15 additions & 0 deletions packages/astro/test/units/assets/getImage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,21 @@ describe('getImage', () => {
});

describe('format', () => {
it('rejects imported APNG images', async () => {
await assert.rejects(
renderImage({
src: {
src: '/_astro/animated.apng',
width: 2,
height: 3,
format: 'apng',
},
alt: 'Animated image',
}),
/Received unsupported format `apng`/,
);
});

it('defaults to webp for remote images with a non-svg extension', async () => {
const result = await renderImage({
src: 'https://example.com/photo.jpg',
Expand Down
57 changes: 54 additions & 3 deletions packages/astro/test/units/content-layer/glob-loader.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { strict as assert } from 'node:assert';
import { writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it } from 'node:test';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -516,7 +516,6 @@ describe('Glob Loader', () => {
it('throws on duplicate IDs when prerenderConflictBehavior is error', async () => {
const tempDir = createTempDir();
const contentDir = join(fileURLToPath(tempDir), 'src', 'content', 'posts');
const { mkdirSync } = await import('node:fs');
mkdirSync(contentDir, { recursive: true });
writeFileSync(join(contentDir, 'post.md'), '---\ntitle: Post MD\n---\nContent MD');
writeFileSync(join(contentDir, 'post.mdx'), '---\ntitle: Post MDX\n---\nContent MDX');
Expand Down Expand Up @@ -556,7 +555,6 @@ describe('Glob Loader', () => {
it('suppresses duplicate ID warning when prerenderConflictBehavior is ignore', async () => {
const tempDir = createTempDir();
const contentDir = join(fileURLToPath(tempDir), 'src', 'content', 'posts');
const { mkdirSync } = await import('node:fs');
mkdirSync(contentDir, { recursive: true });
writeFileSync(join(contentDir, 'post.md'), '---\ntitle: Post MD\n---\nContent MD');
writeFileSync(join(contentDir, 'post.mdx'), '---\ntitle: Post MDX\n---\nContent MDX');
Expand Down Expand Up @@ -601,4 +599,57 @@ describe('Glob Loader', () => {
// No duplicate warnings should be logged
assert.ok(!warnings.some((w) => w.includes('post')));
});

// Colons are reserved in Windows filenames, so the file under test cannot be created there.
it('loads files whose names contain a colon', {
skip: process.platform === 'win32',
}, async () => {
const tempDir = createTempDir();
const contentDir = join(fileURLToPath(tempDir), 'src', 'content', 'space');
mkdirSync(contentDir, { recursive: true });
writeFileSync(
join(contentDir, 'Guide: Architecture.md'),
'---\ntitle: Guide Architecture\n---\n\nA document with a colon in its filename.',
);

const store = new MutableDataStore();
const errors: string[] = [];
const settings = createMinimalSettings(tempDir, {
contentEntryTypes: [createMarkdownEntryType()],
});
const logger = new AstroLogger({
destination: {
write: (msg: any) => {
if (msg.level === 'error') {
errors.push(msg.message);
}
return true;
},
},
level: 'info',
});

const collections = {
spacecraft: defineCollection({
loader: glob({ pattern: '*.md', base: 'src/content/space' }),
}),
};

const contentLayer = new ContentLayer({
settings,
logger,
store,
contentConfigObserver: createTestConfigObserver(collections),
});

await contentLayer.sync();

// The colon-containing file should be loaded without errors
assert.ok(!errors.some((e) => e.includes('The URL must be of scheme file')));

const entries = store.values('spacecraft');
const colonEntry = entries.find((e) => e.id === 'guide-architecture');
assert.ok(colonEntry, 'Entry with colon in filename should be loaded');
assert.ok(colonEntry.body?.includes('colon in its filename'));
});
});
Loading