diff --git a/src/preview.ts b/src/preview.ts index 42f3c931a..023e03c3a 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -4,7 +4,7 @@ import type {FSWatcher, WatchEventType} from "node:fs"; import {access, constants} from "node:fs/promises"; import {createServer} from "node:http"; import type {IncomingMessage, RequestListener, Server, ServerResponse} from "node:http"; -import {basename, dirname, join, normalize} from "node:path/posix"; +import {basename, dirname, extname, join, normalize} from "node:path/posix"; import {difference} from "d3-array"; import type {PatchItem} from "fast-array-diff"; import {getPatch} from "fast-array-diff"; @@ -176,6 +176,8 @@ export class PreviewServer { } else { if ((pathname = normalize(pathname)).startsWith("..")) throw new Error("Invalid path: " + pathname); + const ext = extname(pathname).replace(/^\.html$/, ""); + // Normalize the pathname (e.g., adding ".html" if cleanUrls is false, // dropping ".html" if cleanUrls is true) and redirect if necessary. const normalizedPathname = encodeURI(config.normalizePath(pathname)); @@ -189,7 +191,7 @@ export class PreviewServer { // request represents a JavaScript embed (such as /chart.js), and takes // precedence over any page (such as /chart.js.md). Generate a wrapper // module that allows this JavaScript module to be embedded remotely. - if (pathname.endsWith(".js")) { + if (ext === ".js") { try { end(req, res, await renderModule(root, pathname), "text/javascript"); return; @@ -198,6 +200,20 @@ export class PreviewServer { } } + // If an export asset (such as /robots.txt or /embed/[param].svg) exists + // for this path, send it. It takes priority over a page robots.txt.md. + if (ext && ext !== ".md") { + const file = loaders.find(pathname); + if (file) { + try { + send(req, join(root, await file.load())).pipe(res); + return; + } catch (error) { + if (!isEnoent(error)) throw error; + } + } + } + // If this path ends with a slash, then add an implicit /index to the // end of the path. Otherwise, remove the .html extension (we use clean // paths as the internal canonical representation; see normalizePage). diff --git a/test/preview/dashboard/asset.txt.ts b/test/preview/dashboard/asset.txt.ts new file mode 100644 index 000000000..3dc10d1df --- /dev/null +++ b/test/preview/dashboard/asset.txt.ts @@ -0,0 +1 @@ +process.stdout.write(`Built by ${import.meta.url}`); diff --git a/test/preview/dashboard/robots.txt b/test/preview/dashboard/robots.txt new file mode 100644 index 000000000..1f53798bb --- /dev/null +++ b/test/preview/dashboard/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/test/preview/preview-test.ts b/test/preview/preview-test.ts index dfa8a72cd..62e78c665 100644 --- a/test/preview/preview-test.ts +++ b/test/preview/preview-test.ts @@ -48,7 +48,11 @@ describe("preview server", () => { expect(res.text).to.have.string("This text is not visible by default."); }); - // TODO - tests for /_observablehq and data loader requests + it("serves scripts from _observablehq/", async () => { + const res = await chai.request(testServerUrl).get("/_observablehq/stdlib.js"); + expect(res).to.have.status(200); + expect(res.text).to.have.string("class Library"); + }); it("serves local imports", async () => { const res = await chai.request(testServerUrl).get("/_import/format.js"); @@ -68,9 +72,27 @@ describe("preview server", () => { assert.ok(res.text); }); + it("serves files built with a data loader", async () => { + const res = await chai.request(testServerUrl).get("/_file/asset.txt"); + expect(res).to.have.status(200); + expect(res.text).to.have.string("Built by"); + }); + it("handles missing files", async () => { const res = await chai.request(testServerUrl).get("/_file/idontexist.csv"); expect(res).to.have.status(404); expect(res.text).to.have.string("File not found"); }); + + it("serves exported files", async () => { + const res = await chai.request(testServerUrl).get("/robots.txt"); + expect(res).to.have.status(200); + expect(res.text).to.have.string("User-agent:"); + }); + + it("serves exported files built with a data loader", async () => { + const res = await chai.request(testServerUrl).get("/asset.txt"); + expect(res).to.have.status(200); + expect(res.text).to.have.string("Built by"); + }); });