diff --git a/extensions/vscode/src/bundler/bundler.test.ts b/extensions/vscode/src/bundler/bundler.test.ts index 7920b3783..ce2779b09 100644 --- a/extensions/vscode/src/bundler/bundler.test.ts +++ b/extensions/vscode/src/bundler/bundler.test.ts @@ -8,7 +8,10 @@ import * as zlib from "zlib"; import { extract as tarExtract, Headers } from "tar-stream"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createBundle } from "./bundler"; +import { manifestFromConfig } from "./manifestFromConfig"; import { newManifest } from "./manifest"; +import { ContentType } from "../api/types/configurations"; +import { ProductType } from "../api/types/contentRecords"; import { Manifest, BundleProgressEvent } from "./types"; let tmpDir: string; @@ -414,3 +417,70 @@ describe("createBundle", () => { } }); }); + +describe("pre-rendered Quarto website bundle", () => { + it("sets content_category to 'site' in the archived manifest for HTML with subdirectory entrypoint", async () => { + // Simulate a pre-rendered Quarto website: output lives under _site/ + makeFile("_site/index.html", "Home"); + makeFile( + "_site/slides.html", + "Slides (revealjs)", + ); + makeFile("_site/site_libs/revealjs/reveal.js", "/* reveal.js library */"); + + // Build manifest from config the same way connectPublish does + const manifest = manifestFromConfig({ + $schema: "" as never, + productType: ProductType.CONNECT, + type: ContentType.HTML, + entrypoint: "_site/index.html", + validate: true, + files: ["/_site"], + }); + + const result = await createBundle({ + projectPath: tmpDir, + manifest, + filePatterns: ["/_site"], + }); + + // The archived manifest.json should have content_category: "site" + const entries = await extractTarEntries(result.bundle); + const archivedManifest = JSON.parse( + entries.get("manifest.json")!.data.toString(), + ); + expect(archivedManifest.metadata.content_category).toBe("site"); + expect(archivedManifest.metadata.appmode).toBe("static"); + expect(archivedManifest.metadata.primary_html).toBe("_site/index.html"); + + // All site files should be present in the bundle + expect(entries.has("_site/index.html")).toBe(true); + expect(entries.has("_site/slides.html")).toBe(true); + expect(entries.has("_site/site_libs/revealjs/reveal.js")).toBe(true); + }); + + it("does not set content_category for single-file HTML deployment", async () => { + makeFile("index.html", "Single page"); + + const manifest = manifestFromConfig({ + $schema: "" as never, + productType: ProductType.CONNECT, + type: ContentType.HTML, + entrypoint: "index.html", + validate: true, + files: ["/index.html"], + }); + + const result = await createBundle({ + projectPath: tmpDir, + manifest, + filePatterns: ["/index.html"], + }); + + const entries = await extractTarEntries(result.bundle); + const archivedManifest = JSON.parse( + entries.get("manifest.json")!.data.toString(), + ); + expect(archivedManifest.metadata.content_category).toBeUndefined(); + }); +}); diff --git a/extensions/vscode/src/bundler/manifestFromConfig.test.ts b/extensions/vscode/src/bundler/manifestFromConfig.test.ts index 0849ba233..a0b60b9b2 100644 --- a/extensions/vscode/src/bundler/manifestFromConfig.test.ts +++ b/extensions/vscode/src/bundler/manifestFromConfig.test.ts @@ -393,6 +393,67 @@ describe("manifestFromConfig", () => { }); }); + describe("content_category", () => { + it("sets content_category to 'site' for HTML with _site/ entrypoint", () => { + const m = manifestFromConfig( + minimalConfig({ + type: ContentType.HTML, + entrypoint: "_site/index.html", + }), + ); + expect(m.metadata.content_category).toBe("site"); + }); + + it("sets content_category to 'site' for HTML with _book/ entrypoint", () => { + const m = manifestFromConfig( + minimalConfig({ + type: ContentType.HTML, + entrypoint: "_book/index.html", + }), + ); + expect(m.metadata.content_category).toBe("site"); + }); + + it("does not set content_category for HTML with flat entrypoint", () => { + const m = manifestFromConfig( + minimalConfig({ + type: ContentType.HTML, + entrypoint: "index.html", + }), + ); + expect(m.metadata.content_category).toBeUndefined(); + }); + + it("does not set content_category for HTML in an arbitrary subdirectory", () => { + const m = manifestFromConfig( + minimalConfig({ + type: ContentType.HTML, + entrypoint: "subdir/single-page.html", + }), + ); + expect(m.metadata.content_category).toBeUndefined(); + }); + + it("does not set content_category for non-HTML types with _site/ entrypoint", () => { + const m = manifestFromConfig( + minimalConfig({ + type: ContentType.QUARTO_STATIC, + entrypoint: "_site/index.html", + }), + ); + expect(m.metadata.content_category).toBeUndefined(); + }); + + it("does not set content_category when entrypoint is undefined", () => { + const m = manifestFromConfig( + minimalConfig({ + type: ContentType.HTML, + }), + ); + expect(m.metadata.content_category).toBeUndefined(); + }); + }); + describe("combined R and Python environment", () => { it("sets both environment.r and environment.python", () => { const m = manifestFromConfig( diff --git a/extensions/vscode/src/bundler/manifestFromConfig.ts b/extensions/vscode/src/bundler/manifestFromConfig.ts index ab122b478..e93dace1c 100644 --- a/extensions/vscode/src/bundler/manifestFromConfig.ts +++ b/extensions/vscode/src/bundler/manifestFromConfig.ts @@ -25,6 +25,7 @@ export function manifestFromConfig(cfg: ConfigurationDetails): Manifest { appmode, entrypoint: cfg.entrypoint, ...primaryField(cfg.type, cfg.entrypoint), + ...contentCategoryField(cfg), // false is omitted so it doesn't appear in manifest.json has_parameters: cfg.hasParameters || undefined, }, @@ -115,3 +116,27 @@ function primaryField( return undefined; } } + +// Known Quarto output directories that indicate a pre-rendered multi-page site. +const quartoSiteOutputDirs = new Set(["_site", "_book"]); + +// Detect multi-page site content for the manifest's content_category field. +// When an HTML deployment's entrypoint lives inside a known Quarto output +// directory (e.g. "_site/index.html" or "_book/index.html"), the content is a +// pre-rendered multi-page site. Connect uses content_category="site" to serve +// all files in the bundle rather than only the entrypoint. +// +// Only known Quarto output directories trigger this — an arbitrary subdirectory +// (e.g. "subdir/page.html") does not, to avoid false positives for standalone +// HTML files that happen to be nested. +function contentCategoryField( + cfg: ConfigurationDetails, +): { content_category: string } | undefined { + if (cfg.type === ContentType.HTML && cfg.entrypoint) { + const parts = cfg.entrypoint.split("/"); + if (parts.length > 1 && quartoSiteOutputDirs.has(parts[0]!)) { + return { content_category: "site" }; + } + } + return undefined; +} diff --git a/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts b/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts index ef678b25d..e03b9f39f 100644 --- a/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts +++ b/extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts @@ -107,6 +107,75 @@ Hello world expect(fs.existsSync(path.join(tmpDir, "index.html"))).toBe(true); }, 30_000); + test("renders a standalone revealjs document to slides, not plain HTML", async () => { + const qmdContent = `--- +title: "Slide Deck" +format: revealjs +--- + +## Slide One + +Hello slides +`; + fs.writeFileSync(path.join(tmpDir, "slides.qmd"), qmdContent); + + const helper = new QuartoProjectHelper( + "slides.qmd", + "slides.html", + tmpDir, + ); + await helper.render(); + + const outputPath = path.join(tmpDir, "slides.html"); + expect(fs.existsSync(outputPath)).toBe(true); + + const html = fs.readFileSync(outputPath, "utf-8"); + // revealjs output contains reveal.js framework references + expect(html).toContain("reveal"); + }, 30_000); + + test("renders a Quarto project with mixed formats (html + revealjs)", async () => { + const quartoYml = `project: + type: website + output-dir: _site +`; + const indexQmd = `--- +title: "Home" +--- + +Welcome +`; + const slidesQmd = `--- +title: "Slides" +format: revealjs +--- + +## Slide One + +Content +`; + fs.writeFileSync(path.join(tmpDir, "_quarto.yml"), quartoYml); + fs.writeFileSync(path.join(tmpDir, "index.qmd"), indexQmd); + fs.writeFileSync(path.join(tmpDir, "slides.qmd"), slidesQmd); + + const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir); + await helper.render(); + + // Both files should be rendered + const indexPath = path.join(tmpDir, "_site", "index.html"); + const slidesPath = path.join(tmpDir, "_site", "slides.html"); + expect(fs.existsSync(indexPath)).toBe(true); + expect(fs.existsSync(slidesPath)).toBe(true); + + // slides.html should be revealjs, not plain HTML + const slidesHtml = fs.readFileSync(slidesPath, "utf-8"); + expect(slidesHtml).toContain("reveal"); + + // index.html should NOT contain revealjs + const indexHtml = fs.readFileSync(indexPath, "utf-8"); + expect(indexHtml).not.toContain("reveal"); + }, 60_000); + test("renders a Quarto project to HTML", async () => { const quartoYml = `project: type: website @@ -133,8 +202,63 @@ Hello project ); // -------------------------------------------------------------------------- -// Command construction with real filesystem +// Relative vs absolute projectDir — verifies the bug where a relative +// projectDir (e.g. ".") caused isQuartoYmlPresent() to check the wrong +// directory, falling through to single-document render. // -------------------------------------------------------------------------- +describe("QuartoProjectHelper - relative vs absolute projectDir", () => { + test("absolute projectDir detects _quarto.yml on disk", async () => { + fs.writeFileSync( + path.join(tmpDir, "_quarto.yml"), + "project:\n type: website\n", + ); + const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir); + expect(await helper.isQuartoYmlPresent()).toBe(true); + }); + + test("relative projectDir '.' only finds _quarto.yml if cwd matches", async () => { + // Guard: this test assumes cwd does NOT contain _quarto.yml. + // If it does, the assertion below would pass trivially or fail confusingly. + expect(fs.existsSync(path.join(process.cwd(), "_quarto.yml"))).toBe(false); + + fs.writeFileSync( + path.join(tmpDir, "_quarto.yml"), + "project:\n type: website\n", + ); + // With "." as projectDir, fileExistsAt resolves relative to process.cwd(), + // which is NOT tmpDir. This simulates the bug: the extension host's cwd + // differs from the project directory. + const helper = new QuartoProjectHelper("index.qmd", "index.html", "."); + // source doesn't contain "_quarto.yml", so it falls through to disk check. + // The disk check looks for path.join(".", "_quarto.yml") which resolves + // relative to cwd, not the project — so it won't find the file. + const found = await helper.isQuartoYmlPresent(); + expect(found).toBe(false); + }); + + test("absolute projectDir triggers project render, not document render", async () => { + fs.writeFileSync( + path.join(tmpDir, "_quarto.yml"), + "project:\n type: website\n", + ); + + let capturedCommand: string | undefined; + mockRunTerminalCommand.mockImplementation((cmd: string) => { + if (cmd === "quarto --version") { + return Promise.resolve(0); + } + capturedCommand = cmd; + return Promise.resolve(0); + }); + + const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir); + await helper.render(); + + // Should render the project directory, not the single document + expect(capturedCommand).toBe(`quarto render "${tmpDir}"`); + }); +}); + describe("QuartoProjectHelper - command construction smoke test", () => { let capturedCommand: string | undefined; @@ -158,7 +282,7 @@ describe("QuartoProjectHelper - command construction smoke test", () => { const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir); await helper.render(); - expect(capturedCommand).toBe(`quarto render "${tmpDir}" --to html`); + expect(capturedCommand).toBe(`quarto render "${tmpDir}"`); }); test("constructs document render command when _quarto.yml does not exist", async () => { @@ -166,7 +290,7 @@ describe("QuartoProjectHelper - command construction smoke test", () => { await helper.render(); expect(capturedCommand).toBe( - `quarto render "${path.join(tmpDir, "index.qmd")}" --to html`, + `quarto render "${path.join(tmpDir, "index.qmd")}"`, ); }); @@ -175,6 +299,6 @@ describe("QuartoProjectHelper - command construction smoke test", () => { const helper = new QuartoProjectHelper("_quarto.yml", "index.html", tmpDir); await helper.render(); - expect(capturedCommand).toBe(`quarto render "${tmpDir}" --to html`); + expect(capturedCommand).toBe(`quarto render "${tmpDir}"`); }); }); diff --git a/extensions/vscode/src/utils/quartoProjectHelper.test.ts b/extensions/vscode/src/utils/quartoProjectHelper.test.ts index 5d9b1834a..09de62f28 100644 --- a/extensions/vscode/src/utils/quartoProjectHelper.test.ts +++ b/extensions/vscode/src/utils/quartoProjectHelper.test.ts @@ -38,7 +38,7 @@ describe("QuartoProjectHelper", () => { const helper = new QuartoProjectHelper("index.qmd", "index.html", "."); await helper.render(); expect(mockRenderCmd).toHaveBeenCalledWith( - `quarto render "${path.join(".", "index.qmd")}" --to html`, + `quarto render "${path.join(".", "index.qmd")}"`, ); }); @@ -47,7 +47,7 @@ describe("QuartoProjectHelper", () => { const helper = new QuartoProjectHelper("index.qmd", "index.html", "."); await helper.render(); - expect(mockRenderCmd).toHaveBeenCalledWith(`quarto render "." --to html`); + expect(mockRenderCmd).toHaveBeenCalledWith(`quarto render "."`); }); test("source is _quarto.yml, renders as a project (uses dir)", async () => { @@ -55,7 +55,7 @@ describe("QuartoProjectHelper", () => { await helper.render(); // No need to check on files if source is already the .yml expect(mockFileExistsAt).not.toHaveBeenCalled(); - expect(mockRenderCmd).toHaveBeenCalledWith(`quarto render "." --to html`); + expect(mockRenderCmd).toHaveBeenCalledWith(`quarto render "."`); }); }); @@ -73,7 +73,7 @@ describe("QuartoProjectHelper", () => { ); await helper.render(); expect(mockRenderCmd).toHaveBeenCalledWith( - `quarto render "${path.join(projectDir, sourceEntrypoint)}" --to html`, + `quarto render "${path.join(projectDir, sourceEntrypoint)}"`, ); }); @@ -87,7 +87,7 @@ describe("QuartoProjectHelper", () => { ); await helper.render(); expect(mockRenderCmd).toHaveBeenCalledWith( - `quarto render "${projectDir}" --to html`, + `quarto render "${projectDir}"`, ); }); @@ -101,7 +101,7 @@ describe("QuartoProjectHelper", () => { // No need to check on files if source is already the .yml expect(mockFileExistsAt).not.toHaveBeenCalled(); expect(mockRenderCmd).toHaveBeenCalledWith( - `quarto render "${projectDir}" --to html`, + `quarto render "${projectDir}"`, ); }); }); diff --git a/extensions/vscode/src/utils/quartoProjectHelper.ts b/extensions/vscode/src/utils/quartoProjectHelper.ts index 7c911516b..9a053a04f 100644 --- a/extensions/vscode/src/utils/quartoProjectHelper.ts +++ b/extensions/vscode/src/utils/quartoProjectHelper.ts @@ -65,13 +65,13 @@ export class QuartoProjectHelper { } renderProject() { - const command = `quarto render "${this.projectDir}" --to html`; + const command = `quarto render "${this.projectDir}"`; return runTerminalCommand(command); } renderDocument() { const fullEntryPath = path.join(this.projectDir, this.source); - const command = `quarto render "${fullEntryPath}" --to html`; + const command = `quarto render "${fullEntryPath}"`; return runTerminalCommand(command); } } diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index 3b199d808..d579d34a3 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -289,10 +289,20 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { return; } + // Resolve projectDir to an absolute path. activeConfig.projectDir is + // relative to the workspace root (e.g. "."), but QuartoProjectHelper + // needs an absolute path for filesystem checks and quarto render commands. + const root = this.root?.uri.fsPath; + if (!root) { + window.showErrorMessage("No workspace folder open."); + return; + } + const absProjectDir = path.resolve(root, projectDir); + // Currently we only support rendering content with Quarto renderQuartoContent( this.webviewConduit, - projectDir, + absProjectDir, sourceEntrypoint, renderedEntrypoint, );