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
49 changes: 23 additions & 26 deletions src/project/types/website/listing/website-listing-feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}
}

Expand Down Expand Up @@ -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);
Expand Down
47 changes: 16 additions & 31 deletions src/project/types/website/website-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,18 @@ 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,
} from "../../../core/markdown-pipeline.ts";
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";
Expand Down Expand Up @@ -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];
Expand Down
17 changes: 17 additions & 0 deletions src/project/types/website/website-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`);
Expand Down
1 change: 1 addition & 0 deletions tests/docs/listings-feed-image/posts/post.qmd
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: "A post"
date: "2026-10-12"
image: ../feed.png
---

A post listed by the root feed.
4 changes: 4 additions & 0 deletions tests/smoke/site/render-listing-feed-image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), [
/<media:content url="https:\/\/example\.com\/feed\.png" medium="image" type="image\/png" height="96" width="144"\/>/,
]),
// Site `image` fallback
ensureFileRegexMatches(join(outputDir, "archive", "index.xml"), feedImage),
// Project-relative path with a leading slash
Expand Down
74 changes: 74 additions & 0 deletions tests/unit/project/website-resolve-project-image.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
Loading