Bug description
With llms-txt: true, every link and image target inside a .llms.md file stays relative to the page that contains it. The llms.txt index, produced by the same feature, writes absolute URLs as soon as site-url is set. The two outputs of one feature therefore disagree.
The consequence is that a client cannot follow a target found inside a .llms.md file unless it also kept the URL it fetched that file from. A model that is handed the raw text of a page, or a tool that concatenates several pages, loses that base and the targets become dead.
On the published site, https://quarto.org/llms.txt lists absolute entries such as https://quarto.org/docs/cli/publish.llms.md, while https://quarto.org/docs/extensions/lua-api.llms.md contains only relative targets, for example ../../docs/advanced/quarto-ast.llms.md#custom-nodes. Images behave the same way: https://quarto.org/docs/get-started/hello/rstudio.llms.md points at ../images/positron-logo.svg.
This is a separate concern from #14806, which covers table content lost to a [TABLE] placeholder in the same output.
Steps to reproduce
_quarto.yml:
project:
type: website
website:
title: "Repro"
site-url: "https://example.com"
llms-txt: true
index.qmd:
---
title: "Home"
---
See [the other page](sub/other.qmd).

sub/other.qmd:
---
title: "Other"
---
Back to [home](../index.qmd).
Add any logo.png next to index.qmd, then:
quarto render
cat _site/llms.txt
cat _site/index.llms.md
cat _site/sub/other.llms.md
Actual behavior
_site/llms.txt uses the site URL:
# Repro
## Pages
- [Other](https://example.com/sub/other.llms.md)
- [Home](https://example.com/index.llms.md)
_site/index.llms.md does not:
# Home
See [the other page](sub/other.llms.md).

A logo
_site/sub/other.llms.md climbs out of its own directory:
# Other
Back to [home](../index.llms.md).
Expected behavior
Targets inside .llms.md resolve without extra knowledge of where the file came from, consistent with the index that lists those files. With site-url set, sub/other.llms.md would read https://example.com/index.llms.md, and logo.png would carry the site URL as well.
Root cause
The Link handler in the llms.lua filter rewrites the extension from .html to .llms.md, strips a leading ./, and returns the link. It never touches the rest of the target, so a relative path stays relative.
|
function Link(link) |
|
clean_element(link) |
|
|
|
-- Check if link has meaningful text content |
|
local has_content = false |
|
for _, item in ipairs(link.content) do |
|
if item.t == "Str" and item.text:match("%S") then |
|
has_content = true |
|
break |
|
elseif item.t ~= "Space" and item.t ~= "SoftBreak" and item.t ~= "LineBreak" then |
|
has_content = true |
|
break |
|
end |
|
end |
|
|
|
-- Drop empty links (return just the content, which may be empty) |
|
if not has_content then |
|
return link.content |
|
end |
|
|
|
local is_absolute = link.target:match("^%a[%w+%-%.]*:") or link.target:match("^//") |
|
if link.target and not is_absolute and (link.target:match("%.html$") or link.target:match("%.html#")) then |
|
link.target = link.target:gsub("%.html#", ".llms.md#") |
|
link.target = link.target:gsub("%.html$", ".llms.md") |
|
link.target = link.target:gsub("^%./", "") |
|
if link.classes:includes("btn") then |
|
link.attr = pandoc.Attr() |
|
end |
|
return link |
|
end |
|
return link |
|
end |
Images are affected for a simpler reason: the filter defines handlers for Header, Inline, Block, Link, CodeBlock, RawBlock, and Div, and none of them is Image. The generic Inline handler only cleans attributes on elements that carry an identifier, so an image target is never inspected at all.
|
function Inline(inline) |
|
if type(inline.identifier) == "string" then |
|
clean_element(inline) |
|
return inline |
|
end |
|
end |
The filter also has no way to know the site URL. convertHtmlToLlmsMarkdown() runs Pandoc with an input file, an output file, the filter, and --wrap=none, and passes no metadata.
|
const cmd = [pandocBinaryPath()]; |
|
cmd.push(tempHtml); |
|
cmd.push("-f", "html"); |
|
cmd.push("-t", "gfm-raw_html"); |
|
cmd.push("--lua-filter", filterPath); |
|
cmd.push("-o", outputPath); |
|
cmd.push("--wrap=none"); |
The index takes the other path. updateLlmsTxt() reads the base URL from the project configuration and joins it with the path of each .llms.md file, which is why that file alone comes out absolute.
|
const relativePath = pathWithForwardSlashes( |
|
relative(outputDir, llmsPath), |
|
); |
|
const filePath = baseUrl |
|
? (baseUrl.endsWith("/") ? baseUrl : baseUrl + "/") + relativePath |
|
: relativePath; |
The value comes from websiteBaseurl(), the helper that resolves site-url. It is already available in the module that spawns Pandoc, so the information exists at the point where the pages are converted.
|
export function websiteBaseurl(project?: ProjectConfig): string | undefined { |
|
return websiteConfigString(kSiteUrl, project); |
|
} |
Your environment
- IDE: Terminal
- OS: macOS 26.6.1
Quarto check output
Quarto 99.9.9
[✓] Checking environment information...
Quarto cache location: /Users/mcanouil/Library/Caches/quarto
[✓] Checking versions of quarto binary dependencies...
Pandoc version 3.10.0: OK
Dart Sass version 1.101.0: OK
Deno version 2.7.14: OK
Typst version 0.15.1: OK
[✓] Checking versions of quarto dependencies......OK
[✓] Checking Quarto installation......OK
Version: 99.9.9
commit: d4cb49f1e70fb34e4cdf38edbb2f938c3ce7cc21
Path: /Users/mcanouil/Projects/quarto-dev/quarto-cli/package/dist/bin
[✓] Checking tools....................OK
TinyTeX: v2026.07
Chrome Headless Shell: (not installed)
VeraPDF: (not installed)
[✓] Checking LaTeX....................OK
Using: TinyTex
Path: /Users/mcanouil/Library/TinyTeX/bin/universal-darwin
Version: 2026
[✓] Checking Chrome Headless....................OK
Using: Chrome from QUARTO_CHROMIUM
Path: /Applications/Brave Browser.app/Contents/MacOS/Brave Browser
[✓] Checking basic markdown render....OK
(|) Checking R installation...........ℹ R version 4.6.1 (2026-06-24)
! Config '~/.Rprofile' was loaded!
[✓] Checking R installation...........OK
Version: 4.6.1
Path: /Library/Frameworks/R.framework/Versions/4.6/Resources
LibPaths:
- /Users/mcanouil/Projects/quarto-dev/quarto-playground/renv/library/macos/R-4.6/aarch64-apple-darwin23
- /Users/mcanouil/Library/Caches/org.R-project.R/R/renv/sandbox/macos/R-4.6/aarch64-apple-darwin23/46003b10
knitr: 1.51
rmarkdown: 2.31
[✓] Checking Knitr engine render......OK
[✓] Checking Python 3 installation....OK
Version: 3.9.6
Path: /Library/Developer/CommandLineTools/usr/bin/python3
Jupyter: (None)
Jupyter is not available in this Python installation.
Install with python3 -m pip install jupyter
There is an unactivated Python environment in .venv. Did you forget to activate it?
[✓] Checking Julia installation...
Bug description
With
llms-txt: true, every link and image target inside a.llms.mdfile stays relative to the page that contains it. Thellms.txtindex, produced by the same feature, writes absolute URLs as soon assite-urlis set. The two outputs of one feature therefore disagree.The consequence is that a client cannot follow a target found inside a
.llms.mdfile unless it also kept the URL it fetched that file from. A model that is handed the raw text of a page, or a tool that concatenates several pages, loses that base and the targets become dead.On the published site, https://quarto.org/llms.txt lists absolute entries such as
https://quarto.org/docs/cli/publish.llms.md, while https://quarto.org/docs/extensions/lua-api.llms.md contains only relative targets, for example../../docs/advanced/quarto-ast.llms.md#custom-nodes. Images behave the same way: https://quarto.org/docs/get-started/hello/rstudio.llms.md points at../images/positron-logo.svg.This is a separate concern from #14806, which covers table content lost to a
[TABLE]placeholder in the same output.Steps to reproduce
_quarto.yml:index.qmd:sub/other.qmd:Add any
logo.pngnext toindex.qmd, then:Actual behavior
_site/llms.txtuses the site URL:_site/index.llms.mddoes not:_site/sub/other.llms.mdclimbs out of its own directory:Expected behavior
Targets inside
.llms.mdresolve without extra knowledge of where the file came from, consistent with the index that lists those files. Withsite-urlset,sub/other.llms.mdwould readhttps://example.com/index.llms.md, andlogo.pngwould carry the site URL as well.Root cause
The
Linkhandler in thellms.luafilter rewrites the extension from.htmlto.llms.md, strips a leading./, and returns the link. It never touches the rest of the target, so a relative path stays relative.quarto-cli/src/resources/filters/llms/llms.lua
Lines 173 to 204 in 55389ba
Images are affected for a simpler reason: the filter defines handlers for
Header,Inline,Block,Link,CodeBlock,RawBlock, andDiv, and none of them isImage. The genericInlinehandler only cleans attributes on elements that carry an identifier, so an image target is never inspected at all.quarto-cli/src/resources/filters/llms/llms.lua
Lines 158 to 163 in 55389ba
The filter also has no way to know the site URL.
convertHtmlToLlmsMarkdown()runs Pandoc with an input file, an output file, the filter, and--wrap=none, and passes no metadata.quarto-cli/src/project/types/website/website-llms.ts
Lines 276 to 282 in 55389ba
The index takes the other path.
updateLlmsTxt()reads the base URL from the project configuration and joins it with the path of each.llms.mdfile, which is why that file alone comes out absolute.quarto-cli/src/project/types/website/website-llms.ts
Lines 372 to 377 in 55389ba
The value comes from
websiteBaseurl(), the helper that resolvessite-url. It is already available in the module that spawns Pandoc, so the information exists at the point where the pages are converted.quarto-cli/src/project/types/website/website-config.ts
Lines 174 to 176 in 55389ba
Your environment
Quarto check output
Quarto 99.9.9 [✓] Checking environment information... Quarto cache location: /Users/mcanouil/Library/Caches/quarto [✓] Checking versions of quarto binary dependencies... Pandoc version 3.10.0: OK Dart Sass version 1.101.0: OK Deno version 2.7.14: OK Typst version 0.15.1: OK [✓] Checking versions of quarto dependencies......OK [✓] Checking Quarto installation......OK Version: 99.9.9 commit: d4cb49f1e70fb34e4cdf38edbb2f938c3ce7cc21 Path: /Users/mcanouil/Projects/quarto-dev/quarto-cli/package/dist/bin [✓] Checking tools....................OK TinyTeX: v2026.07 Chrome Headless Shell: (not installed) VeraPDF: (not installed) [✓] Checking LaTeX....................OK Using: TinyTex Path: /Users/mcanouil/Library/TinyTeX/bin/universal-darwin Version: 2026 [✓] Checking Chrome Headless....................OK Using: Chrome from QUARTO_CHROMIUM Path: /Applications/Brave Browser.app/Contents/MacOS/Brave Browser [✓] Checking basic markdown render....OK (|) Checking R installation...........ℹ R version 4.6.1 (2026-06-24) ! Config '~/.Rprofile' was loaded! [✓] Checking R installation...........OK Version: 4.6.1 Path: /Library/Frameworks/R.framework/Versions/4.6/Resources LibPaths: - /Users/mcanouil/Projects/quarto-dev/quarto-playground/renv/library/macos/R-4.6/aarch64-apple-darwin23 - /Users/mcanouil/Library/Caches/org.R-project.R/R/renv/sandbox/macos/R-4.6/aarch64-apple-darwin23/46003b10 knitr: 1.51 rmarkdown: 2.31 [✓] Checking Knitr engine render......OK [✓] Checking Python 3 installation....OK Version: 3.9.6 Path: /Library/Developer/CommandLineTools/usr/bin/python3 Jupyter: (None) Jupyter is not available in this Python installation. Install with python3 -m pip install jupyter There is an unactivated Python environment in .venv. Did you forget to activate it? [✓] Checking Julia installation...