diff --git a/.changeset/ready-monkeys-train.md b/.changeset/ready-monkeys-train.md
new file mode 100644
index 000000000000..27ea8c782393
--- /dev/null
+++ b/.changeset/ready-monkeys-train.md
@@ -0,0 +1,5 @@
+---
+'astro': patch
+---
+
+Adds support for importing `.apng` files as image metadata for use with standard `
` elements. Astro's image components reject APNG files to avoid removing their animation
diff --git a/.changeset/young-suns-walk.md b/.changeset/young-suns-walk.md
new file mode 100644
index 000000000000..44e9980137cd
--- /dev/null
+++ b/.changeset/young-suns-walk.md
@@ -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`)
diff --git a/packages/astro/client.d.ts b/packages/astro/client.d.ts
index 653e3b8bad2a..6acaf794c3dd 100644
--- a/packages/astro/client.d.ts
+++ b/packages/astro/client.d.ts
@@ -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;
diff --git a/packages/astro/src/assets/consts.ts b/packages/astro/src/assets/consts.ts
index 6255be0d38e4..82c155ea28e4 100644
--- a/packages/astro/src/assets/consts.ts
+++ b/packages/astro/src/assets/consts.ts
@@ -12,6 +12,7 @@ export const VALID_INPUT_FORMATS = [
'jpeg',
'jpg',
'png',
+ 'apng',
'tiff',
'webp',
'gif',
diff --git a/packages/astro/src/assets/utils/node.ts b/packages/astro/src/assets/utils/node.ts
index d93dbb0c5ad6..0ce56be83c9f 100644
--- a/packages/astro/src/assets/utils/node.ts
+++ b/packages/astro/src/assets/utils/node.ts
@@ -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 = {
src: '',
diff --git a/packages/astro/src/content/loaders/glob.ts b/packages/astro/src/content/loaders/glob.ts
index 90029fc6b739..0aa87f2238f9 100644
--- a/packages/astro/src/content/loaders/glob.ts
+++ b/packages/astro/src/content/loaders/glob.ts
@@ -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({
@@ -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;
@@ -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);
}
diff --git a/packages/astro/test/core-image.test.ts b/packages/astro/test/core-image.test.ts
index 2e572732991d..9774c6351616 100644
--- a/packages/astro/test/core-image.test.ts
+++ b/packages/astro/test/core-image.test.ts
@@ -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');
diff --git a/packages/astro/test/fixtures/core-image-errors/src/images/animated.apng b/packages/astro/test/fixtures/core-image-errors/src/images/animated.apng
new file mode 100644
index 000000000000..f93a1c90e72e
Binary files /dev/null and b/packages/astro/test/fixtures/core-image-errors/src/images/animated.apng differ
diff --git a/packages/astro/test/fixtures/core-image-errors/src/pages/apng-image.astro b/packages/astro/test/fixtures/core-image-errors/src/pages/apng-image.astro
new file mode 100644
index 000000000000..5d0b5348a68f
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-errors/src/pages/apng-image.astro
@@ -0,0 +1,6 @@
+---
+import { Image } from 'astro:assets';
+import image from '../images/animated.apng';
+---
+
+
diff --git a/packages/astro/test/fixtures/core-image-errors/src/pages/apng-img.astro b/packages/astro/test/fixtures/core-image-errors/src/pages/apng-img.astro
new file mode 100644
index 000000000000..d2409d03bff7
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-errors/src/pages/apng-img.astro
@@ -0,0 +1,6 @@
+---
+import image from '../images/animated.apng';
+---
+
+
+{image.format}
diff --git a/packages/astro/test/fixtures/core-image-errors/src/pages/apng-picture.astro b/packages/astro/test/fixtures/core-image-errors/src/pages/apng-picture.astro
new file mode 100644
index 000000000000..728d42524f42
--- /dev/null
+++ b/packages/astro/test/fixtures/core-image-errors/src/pages/apng-picture.astro
@@ -0,0 +1,6 @@
+---
+import { Picture } from 'astro:assets';
+import image from '../images/animated.apng';
+---
+
+
diff --git a/packages/astro/test/units/assets/emit-image-metadata.test.ts b/packages/astro/test/units/assets/emit-image-metadata.test.ts
index d43ea5bf9f95..c40a880f3ba9 100644
--- a/packages/astro/test/units/assets/emit-image-metadata.test.ts
+++ b/packages/astro/test/units/assets/emit-image-metadata.test.ts
@@ -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,
@@ -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');
+ });
});
diff --git a/packages/astro/test/units/assets/getImage.test.ts b/packages/astro/test/units/assets/getImage.test.ts
index 8618e438d37a..11609af2fb67 100644
--- a/packages/astro/test/units/assets/getImage.test.ts
+++ b/packages/astro/test/units/assets/getImage.test.ts
@@ -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',
diff --git a/packages/astro/test/units/content-layer/glob-loader.test.ts b/packages/astro/test/units/content-layer/glob-loader.test.ts
index ee6d2135070c..af8e1d0dcc61 100644
--- a/packages/astro/test/units/content-layer/glob-loader.test.ts
+++ b/packages/astro/test/units/content-layer/glob-loader.test.ts
@@ -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';
@@ -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');
@@ -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');
@@ -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'));
+ });
});