diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index e10a2250d0a..98cded1618b 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -60,6 +60,7 @@ All changes included in 1.10: - ([#13565](https://github.com/quarto-dev/quarto-cli/issues/13565), [#14353](https://github.com/quarto-dev/quarto-cli/issues/14353)): Fix sidebar logo not appearing on secondary sidebars in multi-sidebar website layouts. - ([#14562](https://github.com/quarto-dev/quarto-cli/issues/14562)): Fix a heading inside `content-hidden when-format="llms-txt"` (visible in HTML) losing its `
` wrapper and `id` in a website with `llms-txt` enabled, which broke its table-of-contents link, anchors, and cross-references. - ([#14563](https://github.com/quarto-dev/quarto-cli/issues/14563)): Fix a fatal error when a shortcode is used inside conditional content (e.g. `content-visible when-format="llms-txt"`) in a website with `llms-txt` enabled. +- ([#14667](https://github.com/quarto-dev/quarto-cli/issues/14667)): Fix the clean-URL rewrite in `quarto-nav.js` breaking links to files whose names merely start with `index.html`, such as the `index.html.md` "Other Formats" links produced when `output-file: index.html` is paired with a markdown format (e.g. by nbdev for llms.txt workflows). The rewrite now only strips `index.html` at the end of the path. ## Commands diff --git a/src/project/types/website/website-shared.ts b/src/project/types/website/website-shared.ts index d762d3a4840..a48f159ad96 100644 --- a/src/project/types/website/website-shared.ts +++ b/src/project/types/website/website-shared.ts @@ -474,5 +474,5 @@ export function containsHref(href: string, items: SidebarItem[]) { export function itemHasNavTarget(item: SidebarItem, href: string) { return item.href === href || - item.href === href.replace(/\/index\.html/, "/"); + item.href === href.replace(/\/index\.html(?=[?#]|$)/, "/"); } diff --git a/src/resources/projects/website/navigation/quarto-nav.js b/src/resources/projects/website/navigation/quarto-nav.js index 38cc43057bd..8b12432c71c 100644 --- a/src/resources/projects/website/navigation/quarto-nav.js +++ b/src/resources/projects/website/navigation/quarto-nav.js @@ -274,7 +274,10 @@ window.document.addEventListener("DOMContentLoaded", function () { for (let i = 0; i < links.length; i++) { if (links[i].href) { links[i].dataset.originalHref = links[i].href; - links[i].href = links[i].href.replace(/\/index\.html/, "/"); + // only strip index.html at the end of the path (optionally followed + // by a query or fragment) so that files like index.html.md + // (alternate-format twins) are left alone (#14667) + links[i].href = links[i].href.replace(/\/index\.html(?=[?#]|$)/, "/"); } } diff --git a/src/webui/quarto-preview/dist/quarto-preview.js b/src/webui/quarto-preview/dist/quarto-preview.js index fa2166aeba3..31414b7c6d0 100644 --- a/src/webui/quarto-preview/dist/quarto-preview.js +++ b/src/webui/quarto-preview/dist/quarto-preview.js @@ -1,5 +1,5 @@ (function(){"use strict";try{if(typeof document<"u"){var a=document.createElement("style");a.appendChild(document.createTextNode(".ansi-display{font-family:monospace;white-space:pre-wrap;--ansiBlack: #000000;--ansiRed: #cd3131;--ansiGreen: #00BC00;--ansiYellow: #949800;--ansiBlue: #0451a5;--ansiMagenta: #bc05bc;--ansiCyan: #0598bc;--ansiWhite: #555555;--ansiBrightBlack: #666666;--ansiBrightRed: #cd3131;--ansiBrightGreen: #14CE14;--ansiBrightYellow: #b5ba00;--ansiBrightBlue: #0451a5;--ansiBrightMagenta: #bc05bc;--ansiBrightCyan: #0598bc;--ansiBrightWhite: #a5a5a5}.dark-mode .ansi-display{--ansiBlack: #000000;--ansiRed: #cd3131;--ansiGreen: #0DBC79;--ansiYellow: #e5e510;--ansiBlue: #2472c8;--ansiMagenta: #bc3fbc;--ansiCyan: #11a8cd;--ansiWhite: #e5e5e5;--ansiBrightBlack: #666666;--ansiBrightRed: #f14c4c;--ansiBrightGreen: #23d18b;--ansiBrightYellow: #f5f543;--ansiBrightBlue: #3b8eea;--ansiBrightMagenta: #d670d6;--ansiBrightCyan: #29b8db;--ansiBrightWhite: #e5e5e5}@keyframes ansi-displaly-run-blink{50%{opacity:0}}.fui-FluentProvider h2{border-bottom:none;padding-bottom:0}")),document.head.appendChild(a)}}catch(i){console.error("vite-plugin-css-injected-by-js",i)}})(); -(function(Ke,Me){typeof exports=="object"&&typeof module<"u"?Me(exports):typeof define=="function"&&define.amd?define(["exports"],Me):(Ke=typeof globalThis<"u"?globalThis:Ke||self,Me(Ke.QuartoPreview={}))})(this,function(Ke){"use strict";var Q2=Object.defineProperty;var J2=(Ke,Me,Rr)=>Me in Ke?Q2(Ke,Me,{enumerable:!0,configurable:!0,writable:!0,value:Rr}):Ke[Me]=Rr;var fe=(Ke,Me,Rr)=>(J2(Ke,typeof Me!="symbol"?Me+"":Me,Rr),Rr);function Me(e,t){for(var r=0;rn[o]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}function Rr(e){const t=window.location.protocol==="https:"?"wss:":"ws:";let r=window.location.pathname;/\/$/.test(r)||(r+="/");let n=!0;addEventListener("beforeunload",()=>{n=!1});const o=new WebSocket(t+"//"+window.location.host+r);o.onopen=()=>{console.log("Socket connection open. Listening for events."),c0(o,()=>n)};const i=()=>{n=!1,o.send("stop")},l=e.map(a=>a(i));return o.onmessage=a=>{for(const s of l)if(s(a))break},()=>{try{o.close()}catch(a){console.error(a)}}}function c0(e,t){e.onclose=()=>{t()&&(console.log("Socket connection closed. Reloading."),window.location.reload())}}function d0(){return()=>{const e=t=>t.replace(/\/index\.html/,"/");return t=>{if(t.data.startsWith("reload")){let r=e(t.data.replace(/^reload/,""));if(r){const n=window.location.pathname.match(/^.*?\/p\/\w+\//)||window.location.pathname.match(/^.*?\/user\/[\w\d]+\/proxy\/\d+\//)||window.location.pathname.match(/^.*?\/(?:abs)?proxy\/\d+\//);n&&(r=n+r.slice(1))}return r&&r!==e(window.location.pathname)?window.location.replace(r):window.location.reload(),!0}else return!1}}}function bc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var S={exports:{}},$={};/** +(function(Ke,Me){typeof exports=="object"&&typeof module<"u"?Me(exports):typeof define=="function"&&define.amd?define(["exports"],Me):(Ke=typeof globalThis<"u"?globalThis:Ke||self,Me(Ke.QuartoPreview={}))})(this,function(Ke){"use strict";var Q2=Object.defineProperty;var J2=(Ke,Me,Rr)=>Me in Ke?Q2(Ke,Me,{enumerable:!0,configurable:!0,writable:!0,value:Rr}):Ke[Me]=Rr;var fe=(Ke,Me,Rr)=>(J2(Ke,typeof Me!="symbol"?Me+"":Me,Rr),Rr);function Me(e,t){for(var r=0;rn[o]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}function Rr(e){const t=window.location.protocol==="https:"?"wss:":"ws:";let r=window.location.pathname;/\/$/.test(r)||(r+="/");let n=!0;addEventListener("beforeunload",()=>{n=!1});const o=new WebSocket(t+"//"+window.location.host+r);o.onopen=()=>{console.log("Socket connection open. Listening for events."),c0(o,()=>n)};const i=()=>{n=!1,o.send("stop")},l=e.map(a=>a(i));return o.onmessage=a=>{for(const s of l)if(s(a))break},()=>{try{o.close()}catch(a){console.error(a)}}}function c0(e,t){e.onclose=()=>{t()&&(console.log("Socket connection closed. Reloading."),window.location.reload())}}function d0(){return()=>{const e=t=>t.replace(/\/index\.html(?=[?#]|$)/,"/");return t=>{if(t.data.startsWith("reload")){let r=e(t.data.replace(/^reload/,""));if(r){const n=window.location.pathname.match(/^.*?\/p\/\w+\//)||window.location.pathname.match(/^.*?\/user\/[\w\d]+\/proxy\/\d+\//)||window.location.pathname.match(/^.*?\/(?:abs)?proxy\/\d+\//);n&&(r=n+r.slice(1))}return r&&r!==e(window.location.pathname)?window.location.replace(r):window.location.reload(),!0}else return!1}}}function bc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var S={exports:{}},$={};/** * @license React * react.production.min.js * diff --git a/src/webui/quarto-preview/src/server/navigation.ts b/src/webui/quarto-preview/src/server/navigation.ts index ca199e14ade..340e14ea5a2 100644 --- a/src/webui/quarto-preview/src/server/navigation.ts +++ b/src/webui/quarto-preview/src/server/navigation.ts @@ -8,7 +8,9 @@ export function navigationHandler() { return () => { const normalizeTarget = (target: string) => { - return target.replace(/\/index\.html/, "/") + // only strip index.html at the end of the path so that files like + // index.html.md (alternate-format twins) are left alone (#14667) + return target.replace(/\/index\.html(?=[?#]|$)/, "/") }; return (ev: MessageEvent): boolean => { diff --git a/tests/docs/playwright/website/issue-14667/.gitignore b/tests/docs/playwright/website/issue-14667/.gitignore new file mode 100644 index 00000000000..47c274c17b6 --- /dev/null +++ b/tests/docs/playwright/website/issue-14667/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +/_site/ diff --git a/tests/docs/playwright/website/issue-14667/_quarto.yml b/tests/docs/playwright/website/issue-14667/_quarto.yml new file mode 100644 index 00000000000..ba215f1f3c3 --- /dev/null +++ b/tests/docs/playwright/website/issue-14667/_quarto.yml @@ -0,0 +1,7 @@ +project: + type: website + +website: + title: "issue-14667" + +format: html diff --git a/tests/docs/playwright/website/issue-14667/index.qmd b/tests/docs/playwright/website/issue-14667/index.qmd new file mode 100644 index 00000000000..57c0be3ac92 --- /dev/null +++ b/tests/docs/playwright/website/issue-14667/index.qmd @@ -0,0 +1,18 @@ +--- +title: Home +--- + +Links exercising the clean-URL rewrite in `quarto-nav.js` (issue #14667). +The rewrite must strip `index.html` only at the end of the path, leaving +names that merely start with `index.html` (like the `index.html.md` +markdown twins generated for llms.txt workflows) untouched. + +- [markdown twin](index.html.md){#lnk-md} — must NOT be rewritten +- [clean url](sub/index.html){#lnk-clean} — must become `sub/` +- [fragment](index.html#section){#lnk-frag} — must become `#section` on `./` +- [query](index.html?a=1){#lnk-query} — must become `./?a=1` +- [path segment](index.html/foo){#lnk-segment} — must NOT be rewritten + +## Section {#section} + +Target for the fragment link. diff --git a/tests/docs/playwright/website/issue-14667/sub/index.qmd b/tests/docs/playwright/website/issue-14667/sub/index.qmd new file mode 100644 index 00000000000..21ac1302c5c --- /dev/null +++ b/tests/docs/playwright/website/issue-14667/sub/index.qmd @@ -0,0 +1,5 @@ +--- +title: Sub page +--- + +Target page for the clean-URL link on the home page. diff --git a/tests/integration/playwright/tests/website-index-html-links.spec.ts b/tests/integration/playwright/tests/website-index-html-links.spec.ts new file mode 100644 index 00000000000..7a7609865cd --- /dev/null +++ b/tests/integration/playwright/tests/website-index-html-links.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +// quarto-nav.js rewrites every link on page load to clean up index.html +// URLs. The rewrite must only strip index.html at the end of the path +// (optionally followed by a query or fragment); hrefs that merely start +// with index.html — like the index.html.md markdown twins generated for +// llms.txt workflows — must be left alone (#14667). +test("index.html clean-URL rewrite only strips whole path segments", async ({ page }) => { + await page.goto("./website/issue-14667/_site/index.html"); + + // data-original-href is stamped by the rewrite loop, so waiting on it + // ensures quarto-nav.js has processed the links + const mdLink = page.locator("#lnk-md"); + await expect(mdLink).toHaveAttribute("data-original-href", /\/index\.html\.md$/); + + // a link to an index.html-prefixed file must not be rewritten + await expect(mdLink).toHaveAttribute("href", /\/index\.html\.md$/); + + // ...while genuine index.html links still get cleaned up + await expect(page.locator("#lnk-clean")).toHaveAttribute("href", /\/sub\/$/); + await expect(page.locator("#lnk-frag")).toHaveAttribute("href", /\/_site\/#section$/); + await expect(page.locator("#lnk-query")).toHaveAttribute("href", /\/_site\/\?a=1$/); + + // index.html as an intermediate path segment is not the index page of + // the link's directory, so it must be left alone too + await expect(page.locator("#lnk-segment")).toHaveAttribute("href", /\/index\.html\/foo$/); +});