From db31fdd7989d5092577dcc616ebaaafca3ec4c7d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 14 Oct 2026 20:57:19 +0200 Subject: [PATCH 1/4] Add a helper resolving a website image to a project href and size --- src/project/types/website/website-shared.ts | 17 +++++ .../website-resolve-project-image.test.ts | 74 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 tests/unit/project/website-resolve-project-image.test.ts diff --git a/src/project/types/website/website-shared.ts b/src/project/types/website/website-shared.ts index 667c023b70..b1f8931b0a 100644 --- a/src/project/types/website/website-shared.ts +++ b/src/project/types/website/website-shared.ts @@ -10,6 +10,7 @@ import * as ld from "../../../core/lodash.ts"; import { dirAndStem, pathWithForwardSlashes } from "../../../core/path.ts"; import { isAbsoluteRef } from "../../../core/http.ts"; +import { imageSize } from "../../../core/image.ts"; import { NavigationItemObject, @@ -127,6 +128,22 @@ export function projectRelativeHref(href: string, documentDir: string) { ); } +// Resolves an image written in a document in `documentDir` (project relative) +// to its project relative href and the pixel size of the project file it +// names. Web URLs have no local file, so they get no size; joining a URL onto +// the project dir makes an invalid path on Windows and `stat` throws. +export function resolveProjectImage( + image: string, + projectDir: string, + documentDir = ".", +): { href: string; height?: number; width?: number } { + const href = projectRelativeHref(image, documentDir); + const size = isAbsoluteRef(href) + ? undefined + : imageSize(join(projectDir, href)); + return { href, height: size?.height, width: size?.width }; +} + export function inputFileHref(href: string) { const [hrefDir, hrefStem] = dirAndStem(href); const htmlHref = "/" + join(hrefDir, `${hrefStem}.html`); diff --git a/tests/unit/project/website-resolve-project-image.test.ts b/tests/unit/project/website-resolve-project-image.test.ts new file mode 100644 index 0000000000..3ca4f5ad99 --- /dev/null +++ b/tests/unit/project/website-resolve-project-image.test.ts @@ -0,0 +1,74 @@ +/* + * website-resolve-project-image.test.ts + * + * Tests for resolveProjectImage href resolution and size lookup + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../../test.ts"; +import { assertEquals } from "testing/asserts"; +import { docs } from "../../utils.ts"; +import { resolveProjectImage } from "../../../src/project/types/website/website-shared.ts"; + +// site.png and news/thumb.png are both 600x400 +const dir = docs("website-image-base"); +const size = { height: 400, width: 600 }; + +// deno-lint-ignore require-await +unitTest("resolveProjectImage - document relative", async () => { + assertEquals(resolveProjectImage("thumb.png", dir, "news"), { + href: "/news/thumb.png", + ...size, + }); +}); + +// deno-lint-ignore require-await +unitTest("resolveProjectImage - parent relative", async () => { + assertEquals(resolveProjectImage("../site.png", dir, "news"), { + href: "/site.png", + ...size, + }); +}); + +// deno-lint-ignore require-await +unitTest("resolveProjectImage - project relative", async () => { + const expected = { href: "/site.png", ...size }; + assertEquals(resolveProjectImage("/site.png", dir, "news"), expected); + assertEquals(resolveProjectImage("site.png", dir), expected); + assertEquals(resolveProjectImage("./site.png", dir), expected); +}); + +// deno-lint-ignore require-await +unitTest("resolveProjectImage - normalized", async () => { + assertEquals(resolveProjectImage("news//thumb.png", dir), { + href: "/news/thumb.png", + ...size, + }); + // A leading slash keeps the href as written, the size is still read + assertEquals(resolveProjectImage("/news//thumb.png", dir), { + href: "/news//thumb.png", + ...size, + }); +}); + +// deno-lint-ignore require-await +unitTest("resolveProjectImage - url", async () => { + assertEquals( + resolveProjectImage("https://example.com/a.png", dir, "news"), + { + href: "https://example.com/a.png", + height: undefined, + width: undefined, + }, + ); +}); + +// deno-lint-ignore require-await +unitTest("resolveProjectImage - missing file", async () => { + assertEquals(resolveProjectImage("nope.png", dir), { + href: "/nope.png", + height: undefined, + width: undefined, + }); +}); From e88bfe5b95a95b1e1f5b1d3951db9f7d26c5b9ea Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 14 Oct 2026 20:59:10 +0200 Subject: [PATCH 2/4] Cover a local per-item image in the listing feed image test --- tests/docs/listings-feed-image/posts/post.qmd | 1 + tests/smoke/site/render-listing-feed-image.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/tests/docs/listings-feed-image/posts/post.qmd b/tests/docs/listings-feed-image/posts/post.qmd index f56fd13272..b0cc9680b0 100644 --- a/tests/docs/listings-feed-image/posts/post.qmd +++ b/tests/docs/listings-feed-image/posts/post.qmd @@ -1,6 +1,7 @@ --- title: "A post" date: "2026-10-12" +image: ../feed.png --- A post listed by the root feed. diff --git a/tests/smoke/site/render-listing-feed-image.test.ts b/tests/smoke/site/render-listing-feed-image.test.ts index 8ffeb3b9d6..095df33fb7 100644 --- a/tests/smoke/site/render-listing-feed-image.test.ts +++ b/tests/smoke/site/render-listing-feed-image.test.ts @@ -24,6 +24,10 @@ const verify: Verify[] = [ noErrorsOrWarnings, // Listing `feed: image:` option ensureFileRegexMatches(join(outputDir, "index.xml"), feedImage), + // The post image is document relative and scaled like the feed image + ensureFileRegexMatches(join(outputDir, "index.xml"), [ + //, + ]), // Site `image` fallback ensureFileRegexMatches(join(outputDir, "archive", "index.xml"), feedImage), // Project-relative path with a leading slash From 3c446d31aa00d4ebbbe2b9e7a986adea1b39427a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 14 Oct 2026 21:00:43 +0200 Subject: [PATCH 3/4] Resolve listing feed images through the shared helper --- .../website/listing/website-listing-feed.ts | 49 +++++++++---------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/project/types/website/listing/website-listing-feed.ts b/src/project/types/website/listing/website-listing-feed.ts index 95e245185e..c196b68add 100644 --- a/src/project/types/website/listing/website-listing-feed.ts +++ b/src/project/types/website/listing/website-listing-feed.ts @@ -16,7 +16,7 @@ import { quartoConfig } from "../../../../core/quarto.ts"; import { resourcePath } from "../../../../core/resources.ts"; import { ProjectContext } from "../../../types.ts"; import { kImage } from "../website-constants.ts"; -import { projectRelativeHref } from "../website-shared.ts"; +import { resolveProjectImage } from "../website-shared.ts"; import { websiteBaseurl, websiteDescription, @@ -49,8 +49,7 @@ import { import { ProjectOutputFile } from "../../types.ts"; import { resolveInputTarget } from "../../../project-index.ts"; import { projectOutputDir } from "../../../project-shared.ts"; -import { imageContentType, imageSize } from "../../../../core/image.ts"; -import { isAbsoluteRef } from "../../../../core/http.ts"; +import { imageContentType } from "../../../../core/image.ts"; import { warnOnce } from "../../../../core/log.ts"; import { isHtmlOutput } from "../../../../config/format.ts"; @@ -170,24 +169,26 @@ export async function createFeed( // Add any image metadata // The page image is relative to the listing document, the others to the project const pageImage = format.metadata[kImage] as string | undefined; - const image = options.image || - (pageImage && - projectRelativeHref(pageImage, dirname(relative(project.dir, source)))) || - websiteImage(project.config)?.src; + const [image, imageDir] = options.image + ? [options.image, "."] + : pageImage + ? [pageImage, dirname(relative(project.dir, source))] + : [websiteImage(project.config)?.src, "."]; if (image) { + const { href, height, width } = resolveProjectImage( + image, + project.dir, + imageDir, + ); feed.image = { title: feedTitle, link: link, - url: absoluteUrl(siteUrl, image), + url: absoluteUrl(siteUrl, href), }; - // Read the size from the project file the url points at, not the cwd - const size = isAbsoluteRef(image) - ? undefined - : imageSize(join(project.dir, image)); - if (size) { - const [height, width] = feedImageSize(size.height, size.width); - feed.image.height = height; - feed.image.width = width; + if (height !== undefined && width !== undefined) { + const [feedHeight, feedWidth] = feedImageSize(height, width); + feed.image.height = feedHeight; + feed.image.width = feedWidth; } } @@ -492,17 +493,13 @@ async function renderFeed( // Image Data if (item[kFieldImage] && typeof item[kFieldImage] === "string") { const image = item[kFieldImage] as string; - feedItem.image = absoluteUrl(siteUrl, image); + const { href, height, width } = resolveProjectImage(image, project.dir); + feedItem.image = absoluteUrl(siteUrl, href); feedItem.imageContentType = imageContentType(image); - // Web images have no local file to read a size from. Joining a URL onto - // the project dir makes an invalid path on Windows and `stat` throws. - if (!isAbsoluteRef(image)) { - const size = imageSize(join(project.dir, image)); - if (size) { - const [height, width] = feedImageSize(size.height, size.width); - feedItem.imageHeight = height; - feedItem.imageWidth = width; - } + if (height !== undefined && width !== undefined) { + const [feedHeight, feedWidth] = feedImageSize(height, width); + feedItem.imageHeight = feedHeight; + feedItem.imageWidth = feedWidth; } } feedItems.push(feedItem); From dda2913b9629cdecde965077a912db12565d8d94 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 14 Oct 2026 21:03:25 +0200 Subject: [PATCH 4/4] Resolve page metadata images through the shared helper --- src/project/types/website/website-meta.ts | 47 ++++++++--------------- 1 file changed, 16 insertions(+), 31 deletions(-) diff --git a/src/project/types/website/website-meta.ts b/src/project/types/website/website-meta.ts index f6db3714c6..e03f31b816 100644 --- a/src/project/types/website/website-meta.ts +++ b/src/project/types/website/website-meta.ts @@ -37,7 +37,11 @@ import { kTwitterSite, kWebsite, } from "./website-constants.ts"; -import { computePageTitle, projectRelativeHref } from "./website-shared.ts"; +import { + computePageTitle, + projectRelativeHref, + resolveProjectImage, +} from "./website-shared.ts"; import { createMarkdownPipeline, MarkdownPipeline, @@ -45,7 +49,6 @@ import { import { findDescription, findPreviewImg } from "./util/discover-meta.ts"; import { isAbsoluteRef } from "../../../core/http.ts"; import { HtmlPostProcessResult } from "../../../command/render/types.ts"; -import { imageSize } from "../../../core/image.ts"; import { writeMetaTag } from "../../../format/html/format-html-shared.ts"; import { joinUrl } from "../../../core/url.ts"; import { truncateText } from "../../../core/text.ts"; @@ -435,36 +438,18 @@ function imageMetadata( return { href: image, }; - } else if (image.startsWith("/")) { - // This is a project relative path - const imagePath = join(context.dir, image.slice(1)); - const size = imageSize(imagePath); - - // read the image size - return { - path: image, - href: joinUrl(baseUrl, image), - height: size?.height, - width: size?.width, - }; - } else { - // This is an input relative path - const sourceRelative = relative(context.dir, source); - const imageProjectRelative = join( - dirname(sourceRelative), - image, - ); - const imagePath = join(context.dir, imageProjectRelative); - const size = imageSize(imagePath); - - // resolve the image path into an absolute href - return { - path: imageProjectRelative, - href: joinUrl(baseUrl, imageProjectRelative), - height: size?.height, - width: size?.width, - }; } + // Image paths are relative to the source document unless they start with `/` + const { href, height, width } = resolveProjectImage( + image, + context.dir, + dirname(relative(context.dir, source)), + ); + return { + href: joinUrl(baseUrl, href), + height, + width, + }; } type metaVal = [string, string];