Skip to content

Commit c5967cc

Browse files
ensure external links opened in separate window when document rendered in iframe (#2274)
* hook to inject code into iframe hosted quarto preview output * open links in new window by default * use devhost api for opening external windows (vscode and rstudio) * tidy up iframe devserver script * tweak check for viewer iframe request * compute and cache origin / search / isFrame Co-authored-by: JJ Allaire <[email protected]>
1 parent d5443a6 commit c5967cc

File tree

7 files changed

+125
-20
lines changed

7 files changed

+125
-20
lines changed

src/command/preview/preview.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -611,11 +611,11 @@ function htmlFileRequestHandlerOptions(
611611
file = format.formatPreviewFile(file, format);
612612
}
613613
const fileContents = await Deno.readFile(file);
614-
return reloader.injectClient(fileContents, inputFile);
614+
return reloader.injectClient(req, fileContents, inputFile);
615615
} else if (isTextContent(file)) {
616616
const html = await textPreviewHtml(file, req);
617617
const fileContents = new TextEncoder().encode(html);
618-
return reloader.injectClient(fileContents, inputFile);
618+
return reloader.injectClient(req, fileContents, inputFile);
619619
}
620620
},
621621
};

src/core/http-devserver.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface HttpDevServer {
2121
handle: (req: Request) => boolean;
2222
connect: (req: Request) => Promise<Response | undefined>;
2323
injectClient: (
24+
req: Request,
2425
file: Uint8Array,
2526
inputFile?: string,
2627
) => FileResponse;
@@ -75,6 +76,11 @@ export function httpDevServer(
7576
}
7677
});
7778

79+
let injectClientInitialized = false;
80+
let isFrame: boolean;
81+
let origin: string;
82+
let search: string;
83+
7884
return {
7985
handle: (req: Request) => {
8086
return req.headers.get("upgrade") === "websocket";
@@ -93,10 +99,29 @@ export function httpDevServer(
9399
return Promise.resolve(undefined);
94100
}
95101
},
96-
injectClient: (file: Uint8Array, inputFile?: string): FileResponse => {
97-
const scriptContents = new TextEncoder().encode(
98-
"\n" + devServerClientScript(port, inputFile, isPresentation),
102+
injectClient: (
103+
req: Request,
104+
file: Uint8Array,
105+
inputFile?: string,
106+
): FileResponse => {
107+
if (!injectClientInitialized) {
108+
const url = new URL(req.url);
109+
isFrame = isViewerIFrameRequest(req);
110+
origin = url.origin;
111+
search = url.search;
112+
injectClientInitialized = true;
113+
}
114+
115+
const script = devServerClientScript(
116+
origin,
117+
search,
118+
port,
119+
inputFile,
120+
isPresentation,
121+
isFrame,
99122
);
123+
124+
const scriptContents = new TextEncoder().encode("\n" + script);
100125
const fileWithScript = new Uint8Array(
101126
file.length + scriptContents.length,
102127
);
@@ -134,29 +159,61 @@ export function httpDevServer(
134159
}
135160

136161
function devServerClientScript(
162+
origin: string,
163+
search: string,
137164
port: number,
138165
inputFile?: string,
139166
isPresentation?: boolean,
167+
isFrame?: boolean,
140168
): string {
141169
// core devserver
142170
const devserver = [
143-
renderEjs(resourcePath("editor/devserver/devserver-core.html"), {
171+
renderEjs(devserverHtmlResourcePath("core"), {
144172
localhost: kLocalhost,
145173
port,
146174
}),
147175
];
148176
if (isPresentation) {
149177
devserver.push(
150-
renderEjs(resourcePath("editor/devserver/devserver-revealjs.html"), {}),
178+
renderEjs(devserverHtmlResourcePath("revealjs"), {}),
151179
);
152180
} else {
153181
// viewer devserver
154182
devserver.push(
155-
renderEjs(resourcePath("editor/devserver/devserver-viewer.html"), {
183+
renderEjs(devserverHtmlResourcePath("viewer"), {
156184
inputFile: inputFile || "",
157185
}),
158186
);
159187
}
160188

189+
if (isFrame) {
190+
devserver.push(
191+
renderEjs(devserverHtmlResourcePath("iframe"), {
192+
origin: origin,
193+
search: search,
194+
}),
195+
);
196+
}
197+
161198
return devserver.join("\n");
162199
}
200+
201+
function devserverHtmlResourcePath(resource: string) {
202+
return resourcePath(`editor/devserver/devserver-${resource}.html`);
203+
}
204+
205+
export function isViewerIFrameRequest(req: Request) {
206+
for (const url of [req.url, req.referrer]) {
207+
const isViewer = url && (
208+
url.includes("capabilities=") || // rstudio viewer
209+
url.includes("vscodeBrowserReqId=") || // vscode simple browser
210+
url.includes("quartoPreviewReqId=") // generic embedded browser
211+
);
212+
213+
if (isViewer) {
214+
return true;
215+
}
216+
}
217+
218+
return false;
219+
}

src/core/pdfjs.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { basename, join } from "path/mod.ts";
99
import { md5Hash } from "./hash.ts";
10+
import { isViewerIFrameRequest } from "./http-devserver.ts";
1011
import { FileResponse } from "./http.ts";
1112
import { contentType } from "./mime.ts";
1213
import { pathWithForwardSlashes } from "./path.ts";
@@ -63,12 +64,8 @@ export function pdfJsFileHandler(
6364
basename(pdfFile()),
6465
);
6566
// always hide the sidebar in the viewer pane
66-
const referrer = req.headers.get("Referer");
67-
const isViewer = referrer && (
68-
referrer.includes("capabilities=") || // rstudio viewer
69-
referrer.includes("vscodeBrowserReqId=") || // vscode simple browser
70-
referrer.includes("quartoPreviewReqId=") // generic embedded browser
71-
);
67+
const isViewer = isViewerIFrameRequest(req);
68+
7269
if (isViewer) {
7370
viewerJs = viewerJs.replace(
7471
"sidebarView: sidebarView",

src/project/serve/serve.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ export async function serveProject(
350350
},
351351

352352
// handle html file requests w/ re-renders
353-
onFile: async (file: string) => {
353+
onFile: async (file: string, req: Request) => {
354354
// if this is an html file or a pdf then re-render (using the freezer)
355355
if (isHtmlContent(file) || isPdfContent(file)) {
356356
// find the input file associated with this output and render it
@@ -442,6 +442,7 @@ export async function serveProject(
442442
relative(watcher.project().dir, inputFile),
443443
);
444444
return watcher.injectClient(
445+
req,
445446
fileContents,
446447
projInputFile,
447448
);
@@ -483,7 +484,7 @@ export async function serveProject(
483484
}
484485
return {
485486
print,
486-
response: watcher.injectClient(body),
487+
response: watcher.injectClient(req, body),
487488
};
488489
},
489490
};
@@ -538,11 +539,11 @@ export async function serveProject(
538539
// install custom handler for pdfjs
539540
handlerOptions.onFile = pdfJsFileHandler(
540541
pdfOutputFile!,
541-
async (file: string) => {
542+
async (file: string, req: Request) => {
542543
// inject watcher client for html
543544
if (isHtmlContent(file)) {
544545
const fileContents = await Deno.readFile(file);
545-
return watcher.injectClient(fileContents);
546+
return watcher.injectClient(req, fileContents);
546547
} else {
547548
return undefined;
548549
}

src/project/serve/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export interface ProjectWatcher {
1212
handle: (req: Request) => boolean;
1313
connect: (req: Request) => Promise<Response | undefined>;
1414
injectClient: (
15+
req: Request,
1516
file: Uint8Array,
1617
inputFile?: string,
1718
) => FileResponse;

src/project/serve/watch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,8 +346,8 @@ export function watchProject(
346346
return devServer.handle(req);
347347
},
348348
connect: devServer.connect,
349-
injectClient: (file: Uint8Array, inputFile?: string) => {
350-
return devServer.injectClient(file, inputFile);
349+
injectClient: (req: Request, file: Uint8Array, inputFile?: string) => {
350+
return devServer.injectClient(req, file, inputFile);
351351
},
352352
hasClients: () => devServer.hasClients(),
353353
reloadClients: async (output: boolean, reloadTarget?: string) => {
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
2+
<script type="text/javascript">
3+
(function() {
4+
5+
var origin = "<%- origin %>";
6+
var search = "<%- search %>";
7+
8+
function isLocalHref(href) {
9+
return href.startsWith(origin);
10+
}
11+
12+
function ensureLinkOpensInNewWindow(linkEl) {
13+
14+
var useOpenExternalMessage =
15+
search.includes("quartoPreviewReqId=") &&
16+
window.parent.postMessage;
17+
18+
if (useOpenExternalMessage) {
19+
linkEl.addEventListener("click", function(event) {
20+
window.parent.postMessage({
21+
type: "openExternal",
22+
url: linkEl.href,
23+
}, "*");
24+
event.preventDefault();
25+
return false;
26+
});
27+
}
28+
29+
var isRStudio = search.includes("capabilities=");
30+
if (isRStudio) {
31+
linkEl.target = "_blank";
32+
}
33+
34+
}
35+
36+
function initialize() {
37+
var linkEls = document.getElementsByTagName("a");
38+
for (var linkEl of linkEls) {
39+
if (!isLocalHref(linkEl.href)) {
40+
ensureLinkOpensInNewWindow(linkEl);
41+
}
42+
}
43+
}
44+
45+
initialize();
46+
47+
})();
48+
49+
</script>

0 commit comments

Comments
 (0)