Skip to content

Commit 85b9c91

Browse files
committed
Merge branch 'main' into fix/issue-14047
2 parents 5dcfa3d + fbe6aa6 commit 85b9c91

64 files changed

Lines changed: 3061 additions & 63 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
---
2+
main_commit: 76b946025
3+
analyzed_date: 2026-02-16
4+
key_files:
5+
- src/publish/provider-types.ts
6+
- src/publish/provider.ts
7+
- src/publish/publish.ts
8+
- src/publish/config.ts
9+
- src/publish/common/publish.ts
10+
- src/publish/common/bundle.ts
11+
- src/publish/common/account.ts
12+
- src/publish/types.ts
13+
- src/command/publish/cmd.ts
14+
- src/publish/posit-connect-cloud/posit-connect-cloud.ts
15+
- src/publish/posit-connect-cloud/api/index.ts
16+
- src/publish/posit-connect-cloud/api/types.ts
17+
---
18+
19+
# Quarto Publishing Architecture
20+
21+
How `quarto publish` works internally. Covers the provider interface, publish patterns, account management, and the end-to-end publish flow.
22+
23+
## Provider Interface
24+
25+
File: `src/publish/provider-types.ts`
26+
27+
Every publish target implements `PublishProvider`:
28+
29+
```typescript
30+
export interface PublishProvider {
31+
name: string; // e.g. "netlify", "rsconnect", "quarto-pub"
32+
description: string; // Human-readable name
33+
requiresServer: boolean; // true if user provides server URL (e.g. rsconnect)
34+
hidden?: boolean; // Hide from provider selection
35+
listOriginOnly?: boolean; // Only list in origin project
36+
37+
accountTokens(): Promise<AccountToken[]>;
38+
removeToken(token: AccountToken): void;
39+
authorizeToken(options: PublishOptions, target?: PublishRecord): Promise<AccountToken | undefined>;
40+
resolveTarget(account: AccountToken, target: PublishRecord): Promise<PublishRecord | undefined>;
41+
publish(
42+
account: AccountToken, type: "document" | "site", input: string,
43+
title: string, slug: string,
44+
render: (flags?: RenderFlags) => Promise<PublishFiles>,
45+
options: PublishOptions, target?: PublishRecord
46+
): Promise<[PublishRecord | undefined, URL | undefined]>;
47+
isUnauthorized(error: Error): boolean;
48+
isNotFound(error: Error): boolean;
49+
}
50+
```
51+
52+
Key types:
53+
54+
- `AccountToken` — stored credential with `name`, `server?`, `token` (generic string or structured object)
55+
- `AccountTokenType``"authorized"` (user-stored) or `"environment"` (env var)
56+
- `PublishRecord` — entry in `_publish.yml` with `id`, `url`, `code?`
57+
- `PublishFiles``{ baseDir: string, rootFile: string, files: string[], metafiles?: string[] }`
58+
59+
## Provider Registration
60+
61+
File: `src/publish/provider.ts`
62+
63+
Providers are imported and added to `kPublishProviders`:
64+
65+
```typescript
66+
const kPublishProviders = [
67+
quartoPubProvider,
68+
ghpagesProvider,
69+
rsconnectProvider,
70+
netlifyProvider,
71+
positConnectCloudProvider,
72+
confluenceProvider,
73+
huggingfaceProvider,
74+
];
75+
```
76+
77+
Discovery functions: `publishProviders()`, `findProvider(name)`.
78+
79+
There is also a deprecation warning for the old `posit-cloud` provider (removed in `142a8791f`) that now suggests using `posit-connect-cloud` as an alternative.
80+
81+
## Two Publish Patterns
82+
83+
### Pattern A: File-by-file upload
84+
85+
Used by: `quarto-pub`, `netlify`
86+
87+
File: `src/publish/common/publish.ts`
88+
89+
Uses `handlePublish()` which:
90+
1. SHA-1 hashes each file in the rendered output
91+
2. Creates a deploy with a file manifest (listing all files + checksums)
92+
3. Server responds with which files it needs (doesn't already have)
93+
4. Uploads only changed files individually
94+
5. Activates the deploy
95+
96+
Providers using this pattern implement `PublishHandler`:
97+
98+
```typescript
99+
export interface PublishHandler {
100+
name: string;
101+
createSite(type, title, slug, account): Promise<[string, string]>;
102+
createDeploy(siteId, account, files): Promise<PublishDeploy>;
103+
getDeploy(deployId, account): Promise<PublishDeploy>;
104+
uploadDeployFile(deployId, path, fileBody, account): Promise<void>;
105+
}
106+
```
107+
108+
### Pattern B: Bundle upload
109+
110+
Used by: `rsconnect` (Posit Connect), `posit-connect-cloud` (Posit Connect Cloud)
111+
112+
File: `src/publish/common/bundle.ts`
113+
114+
Uses `createBundle()` which:
115+
1. Creates a `manifest.json` with file checksums, `appmode`, `content_category`, etc.
116+
2. Packages all files + manifest into a `.tar.gz` archive
117+
3. Returns `{ bundlePath: string, manifest: object }`
118+
119+
The provider then uploads the entire bundle as a single blob and triggers deployment. Each provider using this pattern has its own publish logic (not via `handlePublish()`).
120+
121+
`createBundle()` signature:
122+
```typescript
123+
function createBundle(
124+
type: "document" | "site",
125+
files: PublishFiles,
126+
tempContext: TempContext
127+
): Promise<{ bundlePath: string; manifest: Record<string, unknown> }>
128+
```
129+
130+
## End-to-End Publish Flow
131+
132+
### 1. CLI Entry
133+
134+
File: `src/command/publish/cmd.ts`
135+
136+
`quarto publish [provider] [path]` invokes `publishAction()`.
137+
138+
### 2. Resolve Deployment Target
139+
140+
File: `src/publish/config.ts`
141+
142+
Reads `_publish.yml` to find existing publish targets. Format:
143+
144+
```yaml
145+
- source: document.qmd
146+
provider-name:
147+
- id: site-or-content-id
148+
url: https://published-url.example.com
149+
```
150+
151+
### 3. Select Provider
152+
153+
If not specified on CLI, user is prompted to choose from available providers.
154+
155+
### 4. Resolve Account
156+
157+
File: `src/publish/common/account.ts`
158+
159+
Account resolution order:
160+
1. Environment variable tokens (via provider's `accountTokens()`)
161+
2. Stored tokens in `~/.quarto/publish/accounts/{provider}/accounts.json`
162+
3. Interactive authorization (via provider's `authorizeToken()`)
163+
164+
Token storage functions:
165+
- `readAccessTokens<T>(provider)`reads stored tokens
166+
- `writeAccessToken<T>(provider, token, compareFn)`writes/updates token
167+
- `readAccessTokenFile(provider)`raw file path
168+
169+
### 5. Publish
170+
171+
File: `src/publish/publish.ts`
172+
173+
`publishSite()` or `publishDocument()` coordinates:
174+
1. Calls provider's `publish()`, passing a `render` callback
175+
2. Provider calls `renderForPublish()` (or `render()` directly for sites)
176+
3. Provider uploads and deploys
177+
4. Returns `[PublishRecord, URL]`
178+
179+
**Important:** Providers publishing documents should call `renderForPublish()` instead of `render()` directly. `renderForPublish()` wraps `render()` and stages the output: for HTML documents it copies `document.html``index.html`, for PDFs it creates a pdf.js viewer wrapper as `index.html`. Without this staging, the primary file name won't match `index.html` and bundle-based providers will fail.
180+
181+
### 6. Update `_publish.yml`
182+
183+
File: `src/publish/config.ts`
184+
185+
The returned `PublishRecord` is written back to `_publish.yml` for future republishing.
186+
187+
## Account / Token Management
188+
189+
File: `src/publish/common/account.ts`
190+
191+
### Storage
192+
193+
Tokens stored at: `~/.quarto/publish/accounts/{provider}/accounts.json`
194+
195+
Format: JSON array of `AccountToken` objects. The `token` field is provider-specific (can be a string or structured object).
196+
197+
### Authorization Patterns
198+
199+
**Ticket-based auth** (quarto-pub): `authorizeAccessToken()` in `src/publish/common/account.ts`
200+
- Opens browser to auth URL
201+
- Polls a ticket endpoint until user completes auth
202+
- Exchanges ticket for access token
203+
204+
**API key auth** (rsconnect): User provides API key directly or via env var.
205+
206+
**OAuth Device Code** (posit-connect-cloud): Uses RFC 8628 Device Code flow. See `src/publish/posit-connect-cloud/api/index.ts` for implementation.
207+
208+
### Environment Variables
209+
210+
Each provider can check for env vars in `accountTokens()`. Convention:
211+
- `QUARTO_PUB_AUTH_TOKEN`
212+
- `CONNECT_SERVER` + `CONNECT_API_KEY` (rsconnect)
213+
- `NETLIFY_AUTH_TOKEN`
214+
- `POSIT_CONNECT_CLOUD_ACCESS_TOKEN` + `POSIT_CONNECT_CLOUD_REFRESH_TOKEN` + `POSIT_CONNECT_CLOUD_ACCOUNT_ID` (posit-connect-cloud)
215+
216+
## Existing Providers Summary
217+
218+
| Provider | Pattern | Auth | `requiresServer` |
219+
|----------|---------|------|-------------------|
220+
| `quarto-pub` | A (file-by-file) | Ticket-based OAuth | false |
221+
| `netlify` | A (file-by-file) | API key / env var | false |
222+
| `rsconnect` | B (bundle) | API key (`Key <key>`) | true |
223+
| `ghpages` | Custom (git push) | Git credentials | false |
224+
| `confluence` | Custom | API token | true |
225+
| `posit-connect-cloud` | B (bundle) | OAuth Device Code (RFC 8628) | false |
226+
| `huggingface` | Custom | HF token | false |
227+
228+
## Reusable Utilities
229+
230+
| Utility | File | Purpose |
231+
|---------|------|---------|
232+
| `createBundle()` | `src/publish/common/bundle.ts` | tar.gz bundle with manifest.json |
233+
| `readAccessTokens<T>()` | `src/publish/common/account.ts` | Read stored tokens |
234+
| `writeAccessToken<T>()` | `src/publish/common/account.ts` | Write/update token |
235+
| `authorizeAccessToken()` | `src/publish/common/account.ts` | Ticket-based auth flow |
236+
| `renderForPublish()` | `src/publish/common/publish.ts` | Render + stage documents (HTMLindex.html, PDFpdf.js wrapper) |
237+
| `handlePublish()` | `src/publish/common/publish.ts` | File-by-file upload orchestration |
238+
| `withSpinner()` | `src/core/console.ts` | Progress spinner display |
239+
| `completeMessage()` | `src/core/console.ts` | Success/failure messages |
240+
| `createTempContext()` | `src/core/temp.ts` | Temp file management |
241+
| `openUrl()` | `src/core/shell.ts` | Open URL in browser |
242+
| `isServerSession()` | `src/core/platform.ts` | Detect headless/CI environment |
243+
| `ApiError` | `src/publish/types.ts` | HTTP error type with status code |

news/changelog-1.9.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
All changes included in 1.9:
22

3+
## Shortcodes
4+
5+
- ([#13342](https://github.com/quarto-dev/quarto-cli/issues/13342)): Ensure that the `contents` shortcode works inside metadata.
6+
37
## Regression fixes
48

59
- ([#13396](https://github.com/quarto-dev/quarto-cli/issues/13396)): Fix `quarto publish connect` regression.
@@ -15,6 +19,7 @@ All changes included in 1.9:
1519
## Dependencies
1620

1721
- Update `pandoc` to 3.8.3
22+
- ([#13925](https://github.com/quarto-dev/quarto-cli/issues/13925)): Pandoc 3.8.3 introduces `^[text]^` as inline footnote syntax. This conflicts with superscript containing a Span at the start (e.g., `^[text]{.class}^`) since the `^[` sequence now triggers footnote parsing. A workaround is to insert a zero-width space or other invisible character between `^` and `[`.
1823
- Update `typst` to 0.14.2
1924
- Update `esbuild` to 0.25.10
2025
- Update `deno` to 2.4.5
@@ -41,7 +46,10 @@ All changes included in 1.9:
4146
- ([#11929](https://github.com/quarto-dev/quarto-cli/issues/11929)): Import all `brand.typography.fonts` in CSS, whether or not fonts are referenced by typography elements.
4247
- ([#13413](https://github.com/quarto-dev/quarto-cli/issues/13413)): Fix uncentered play button in `video` shortcodes from cross-reference divs. (author: @bruvellu)
4348
- ([#13508](https://github.com/quarto-dev/quarto-cli/issues/13508)): Add `aria-label` support to `video` shortcode for improved accessibility.
49+
- ([#13685](https://github.com/quarto-dev/quarto-cli/issues/13685)): Fix remote font URLs in brand extensions being incorrectly joined with the extension path, resulting in broken font imports.
50+
- ([#13825](https://github.com/quarto-dev/quarto-cli/issues/13825)): Fix `column: margin` not working with `renderings: [light, dark]` option. Column classes are now preserved when applying theme classes to cell outputs.
4451
- ([#13883](https://github.com/quarto-dev/quarto-cli/issues/13883)): Fix unequal top/bottom spacing in simple untitled callouts.
52+
- ([#13900](https://github.com/quarto-dev/quarto-cli/issues/13900)): Warn when `renderings` cell option contains duplicate names. Previously, duplicate names like `[dark, light, dark, light]` would silently use only the last output for each name.
4553

4654
### `typst`
4755

@@ -63,6 +71,7 @@ All changes included in 1.9:
6371
- Two-column layout now uses `set page(columns:)` instead of `columns()` function, fixing compatibility with landscape sections.
6472
- Title block now properly spans both columns in multi-column layouts.
6573
- ([#13870](https://github.com/quarto-dev/quarto-cli/issues/13870)): Add support for `alt` attribute on cross-referenced equations for improved accessibility. (author: @mcanouil)
74+
- ([#13942](https://github.com/quarto-dev/quarto-cli/issues/13942)): Fix Typst compilation errors showing confusing internal stack traces. Users now see only the relevant Typst error message.
6675
- ([#13950](https://github.com/quarto-dev/quarto-cli/pull/13950)): Replace ctheorems with theorion package for theorem environments. Add `theorem-appearance` option to control styling: `simple` (default, classic LaTeX style), `fancy` (colored boxes with brand colors), `clouds` (rounded backgrounds), or `rainbow` (colored start border and colored title).
6776
- ([#13954](https://github.com/quarto-dev/quarto-cli/issues/13954)): Add support for Typst book projects via format extensions. Quarto now bundles the `orange-book` extension which provides a textbook-style format with chapter numbering, cross-references, and professional styling. Book projects with `format: typst` automatically use this extension.
6877
- ([#13978](https://github.com/quarto-dev/quarto-cli/pull/13978)): Keep term and description together in definition lists to avoid breaking across pages. (author: @mcanouil)
@@ -106,6 +115,7 @@ All changes included in 1.9:
106115
- ([#13716](https://github.com/quarto-dev/quarto-cli/issues/13716)): Fix draft pages showing blank during preview when pre-render scripts are configured.
107116
- ([#13847](https://github.com/quarto-dev/quarto-cli/pull/13847)): Open graph title with markdown is now processed correctly. (author: @mcanouil)
108117
- ([#13910](https://github.com/quarto-dev/quarto-cli/issues/13910)): Add support for `logo: false` to disable sidebar and navbar logos when using `_brand.yml`. Works in website projects (`sidebar.logo: false`, `navbar.logo: false`) and book projects (`book.sidebar.logo: false`, `book.navbar.logo: false`).
118+
- ([#13932](https://github.com/quarto-dev/quarto-cli/pull/13932)): Add `llms-txt: true` option to generate LLM-friendly content for websites. Creates `.llms.md` markdown files alongside HTML pages and a root `llms.txt` index file following the [llms.txt](https://llmstxt.org/) specification.
109119
- ([#13951](https://github.com/quarto-dev/quarto-cli/issues/13951)): Fix `image-lazy-loading` not applying `loading="lazy"` attribute to auto-detected listing images.
110120
- ([#14003](https://github.com/quarto-dev/quarto-cli/pull/14003)): Add text fragments to search result links so browsers scroll to and highlight the matched text on the target page.
111121
- ([#14047](https://github.com/quarto-dev/quarto-cli/issues/14047)): Fix search highlights cleared before user can see them when landing on a page with `?q=` search parameter.
@@ -120,6 +130,10 @@ All changes included in 1.9:
120130

121131
## Publishing
122132

133+
### Posit Connect Cloud
134+
135+
- ([#14027](https://github.com/quarto-dev/quarto-cli/issues/14027)): Add `quarto publish posit-connect-cloud` for publishing static content to Posit Connect Cloud.
136+
123137
### Confluence
124138

125139
- ([#13414](https://github.com/quarto-dev/quarto-cli/issues/13414)): Be more forgiving when Confluence server returns malformed JSON response. (author: @m1no)

src/command/publish/cmd.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
* cmd.ts
33
*
4-
* Copyright (C) 2020-2022 Posit Software, PBC
4+
* Copyright (C) 2020-2026 Posit Software, PBC
55
*/
66

77
import { existsSync } from "../../deno_ral/fs.ts";
@@ -50,6 +50,7 @@ export const publishCommand =
5050
" - Quarto Pub (quarto-pub)\n" +
5151
" - GitHub Pages (gh-pages)\n" +
5252
" - Posit Connect (connect)\n" +
53+
" - Posit Connect Cloud (posit-connect-cloud)\n" +
5354
" - Netlify (netlify)\n" +
5455
" - Confluence (confluence)\n" +
5556
" - Hugging Face Spaces (huggingface)\n\n" +
@@ -113,6 +114,10 @@ export const publishCommand =
113114
"Publish with explicit credentials",
114115
"quarto publish connect --server example.com --token 01A24233E294",
115116
)
117+
.example(
118+
"Publish project to Posit Connect Cloud",
119+
"quarto publish posit-connect-cloud",
120+
)
116121
.example(
117122
"Publish without confirmation prompt",
118123
"quarto publish --no-prompt",

src/command/render/output-typst.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
kVariant,
3030
} from "../../config/constants.ts";
3131
import { error, warning } from "../../deno_ral/log.ts";
32+
import { ErrorEx } from "../../core/lib/error.ts";
3233
import { Format } from "../../config/types.ts";
3334
import { writeFileToStdout } from "../../core/console.ts";
3435
import { dirAndStem, expandPath } from "../../core/path.ts";
@@ -167,11 +168,10 @@ export function typstPdfOutputRecipe(
167168
typstOptions,
168169
);
169170
if (!result.success) {
170-
// Log the error so test framework can detect it via shouldError
171171
if (result.stderr) {
172172
error(result.stderr);
173173
}
174-
throw new Error("Typst compilation failed");
174+
throw new ErrorEx("Error", "Typst compilation failed", false, false);
175175
}
176176

177177
// Validate PDF against specified standards using verapdf (if available)

src/core/brand/brand.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { InternalError } from "../lib/error.ts";
3232
import { dirname, join, relative, resolve } from "../../deno_ral/path.ts";
3333
import { warnOnce } from "../log.ts";
3434
import { isCssColorName } from "../css/color-names.ts";
35+
import { isExternalPath } from "../url.ts";
3536
import {
3637
LogoLightDarkSpecifierPathOptional,
3738
LogoOptionsPathOptional,
@@ -272,10 +273,6 @@ export class Brand {
272273
}
273274
}
274275

275-
function isExternalPath(path: string) {
276-
return /^\w+:/.test(path);
277-
}
278-
279276
export type LightDarkBrand = {
280277
light?: Brand;
281278
dark?: Brand;

src/core/sass/brand.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { Brand } from "../brand/brand.ts";
2626
import { darkModeDefault } from "../../format/html/format-html-info.ts";
2727
import { kBrandMode } from "../../config/constants.ts";
2828
import { join, relative } from "../../deno_ral/path.ts";
29+
import { isExternalPath } from "../url.ts";
2930

3031
const defaultColorNameMap: Record<string, string> = {
3132
"link-color": "link",
@@ -162,9 +163,12 @@ const fileFontImportString = (brand: Brand, description: BrandFontFile) => {
162163
weight = file.weight;
163164
style = file.style;
164165
}
166+
const fontUrl = isExternalPath(path)
167+
? path
168+
: join(pathPrefix, path).replace(/\\/g, "/");
165169
parts.push(`@font-face {
166170
font-family: '${description.family}';
167-
src: url('${join(pathPrefix, path).replace(/\\/g, "/")}');
171+
src: url('${fontUrl}');
168172
font-weight: ${weight || "normal"};
169173
font-style: ${style || "normal"};
170174
}\n`);

0 commit comments

Comments
 (0)