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'; +--- + +Animated image 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'; +--- + +Animated image +{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')); + }); });