Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .claude/rules/formats/ojs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
paths:
- "src/execute/ojs/**"
- "packages/ojs/**"
- "src/resources/formats/html/ojs/**"
- "src/resources/filters/quarto-post/ojs.lua"
---

# OJS

`src/resources/formats/html/ojs/quarto-ojs-runtime{,.min}.js` are build output of `packages/ojs`: change the sources there, then rebuild with `npm ci && npm run build` in `packages/ojs` and commit both bundles with the source change. CI fails when committed bundles differ from a rebuild. `quarto-ojs.css` is edited in place.

Architecture (compile path, runtime injection, inline expressions, `ojs_define`, tests): `llm-docs/ojs-architecture.md`. Update procedure and verification checklist: `dev-docs/update-ojs-runtime.md`.
3 changes: 3 additions & 0 deletions .claude/rules/formats/vendored-html-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ Files under `src/resources/formats/html/*/` (e.g. `axe`, `anchor`, `popper`, `cl
`tippy`, `glightbox`, `fuse`, `bootstrap`) are vendored third-party dependencies, managed by
`package/src/common/update-html-dependencies.ts` — not hand-edited.

Exception: `ojs/` is not managed by the updater. Its runtime bundles are built from `packages/ojs`
and `quarto-ojs.css` is edited in place; see `.claude/rules/formats/ojs.md`.

## Verify provenance before trusting a change

When reviewing a change to one of these files, fetch the same version from upstream and compare
Expand Down
66 changes: 66 additions & 0 deletions dev-docs/update-ojs-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Updating the OJS runtime

How to change the browser runtime behind `{ojs}` cells: Quarto's own runtime code, a bundled npm dependency, or one of the Observable forks.

The sources live in [`packages/ojs/`](../packages/ojs/). Its [README](../packages/ojs/README.md) describes the layout, the build, how the forks are maintained, and the dependency pins. Read it first; this page is the end-to-end procedure around it. For how Quarto compiles `{ojs}` cells and injects the runtime, see [`llm-docs/ojs-architecture.md`](../llm-docs/ojs-architecture.md).

## What the build produces

`npm run build` in `packages/ojs` writes two bundles into [`src/resources/formats/html/ojs/`](../src/resources/formats/html/ojs/):

- `quarto-ojs-runtime.js` is the bundle Quarto uses. Renders link it as a module script or inline it when self-contained.
- `quarto-ojs-runtime.min.js` is built, committed, and shipped in the distribution with the rest of `src/resources/`, but nothing in quarto-cli references it.

`quarto-ojs.css` in the same directory is not built. Edit it there directly.

## Procedure

1. Make the change in `packages/ojs/`:
- Quarto's runtime: edit `quarto-ojs-runtime/src/`.
- A bundled npm dependency: `npm install <package>@<version> --workspace quarto-ojs-runtime` from `packages/ojs`, which updates `quarto-ojs-runtime/package.json` and `package-lock.json`. Check the README notes for pinned packages (for example `@observablehq/inputs` stays on 0.10).
- An upstream fork change: apply it by hand in the matching `external-*/` directory (see the README).
2. Bump the runtime version, so the bundle banner (`// quarto-ojs-runtime vX.Y.Z ...`) identifies the build:

```bash
cd packages/ojs
npm version patch --workspace quarto-ojs-runtime --no-git-tag-version
```

This updates `quarto-ojs-runtime/package.json` and the matching entry in `package-lock.json`.
3. Rebuild with Node.js 24 or later:

```bash
cd packages/ojs
npm ci
npm run build
```

4. Commit the source change, `package-lock.json`, and both bundles together. CI rebuilds them and fails if the committed bundles differ.

The build is reproducible: the banner holds no date, so the same sources produce identical bundles. On Windows, rollup writes the unminified bundle with CRLF line endings because the sources are checked out with CRLF. The `eol=lf` attribute on `src/**` normalizes them, so `git diff` shows no line-ending changes.

npm workspaces create junctions under `node_modules`. To remove `node_modules` on Windows, unlink the junctions first (`Remove-Item` without `-Recurse` on each reparse point); `Remove-Item -Recurse` on a junction deletes the files of the package it points to.

## Verify

- The bundle must not contain a closing script tag, because self-contained renders inline it without escaping:

```bash
rg -c '</script' src/resources/formats/html/ojs/quarto-ojs-runtime.js # must print nothing
```

- Third-party notices in `quarto-ojs-runtime/src/escodegen.js` (BSD-2-Clause) and `escape.js` (MIT, from escape-html) must still appear in the unminified bundle. The minified bundle keeps only the escape-html notice, which is a `/*!` comment; Terser drops the escodegen one.
- `git diff --stat` after the build lists only the bundles you expect to change.
- Run the OJS Playwright specs (render `tests/docs/playwright/ojs/` first; see `.claude/rules/testing/playwright-tests.md`):

```bash
cd tests/integration/playwright
npx playwright test ojs-
```

- Run the OJS smoke tests: `tests/smoke/ojs/*.test.ts` and `tests/smoke/render/render-ojs.test.ts`, plus the smoke-all documents with `{ojs}` cells (`rg -l '```\{ojs' tests/docs/smoke-all --glob '*.qmd'`).
- Render an `{ojs}` document with `embed-resources: true` and open the HTML from disk. This is the inlined-bundle path, which the linked path does not exercise.
- For changes to `@observablehq/inputs` or the Shiny bridge (`quarto-observable-shiny.js`), check a Shiny document with OJS inputs. Button styling depends on the Inputs form class name.
- For a `mime` upgrade, compare `FileAttachment(...).mimeType` for common extensions before and after, in a render without `embed-resources`. The runtime reports the bundled `mime` package's type to user code there; self-contained attachments resolve to `data:` URLs and report no `mimeType`.

The [OJS Runtime Bundle workflow](../.github/workflows/test-ojs-runtime.yml) runs on pull requests that touch `packages/ojs/` or the bundles. It checks that the bundles match a rebuild, not that they work; the Playwright and smoke suites cover behavior.
4 changes: 4 additions & 0 deletions dev-docs/upgrade-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,7 @@ For version 11.2.0, for example, these are:
- https://unpkg.com/mermaid@11.2.0/dist/mermaid.min.js

Copy these files to `src/resources/formats/html/mermaid`.

## Upgrade the OJS runtime

The OJS runtime and its bundled npm dependencies are built from `packages/ojs`, not downloaded. See [update-ojs-runtime.md](update-ojs-runtime.md).
142 changes: 142 additions & 0 deletions llm-docs/ojs-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
---
main_commit: 8ee034c21
analyzed_date: 2026-10-15
key_files:
- src/execute/ojs/compile.ts
- src/execute/ojs/extract-resources.ts
- src/command/render/render-files.ts
- src/command/render/pandoc.ts
- src/command/render/resources.ts
- src/resources/filters/quarto-post/ojs.lua
- src/core/jupyter/jupyter.ts
- src/resources/rmd/ojs.R
- src/resources/jupyter/lang/python/setup.py
- packages/ojs/package.json
- packages/ojs/quarto-ojs-runtime/rollup.config.js
- packages/ojs/quarto-ojs-runtime/src/quarto-ojs.js
- package/src/common/prepare-dist.ts
- .github/workflows/test-ojs-runtime.yml
---

# OJS Architecture

How `{ojs}` cells become Observable JavaScript in an HTML page: the render-time compile step in TypeScript, the Lua filter for inline `${...}` expressions, and the browser runtime built from `packages/ojs`.

## Entry Points

- `src/command/render/render-files.ts` calls `ojsExecuteResult()` after the engine has executed the document's other cells.
- `ojsExecuteResult()` (`src/execute/ojs/compile.ts`) wraps `ojsCompile()` and merges its markdown, includes, and filters into the execute result.
- `ojsCompile()` (same file) does the work: it rewrites `{ojs}` cells into placeholder divs and builds the scripts that hand the cell sources to the runtime.
- `src/command/render/resources.ts` calls `extractResolvedResourceFilenamesFromQmd()` (`src/execute/ojs/extract-resources.ts`) so files referenced by OJS code are copied as render resources.

## When OJS Runs

`ojsCompile()` returns the markdown untouched unless all of these hold:

1. `isJavascriptCompatible(format)` (`src/config/format.ts`) is true: HTML-compatible output (`isHtmlCompatible`) except EPUB.
2. `ojs-engine` metadata is not `false`.
3. The document has an `ojs` cell, contains the text `ojs_define`, or sets `ojs-engine: true`.

The `ojs_define` check is a plain substring search over the markdown, because the R/Python/Julia cells have already executed by this point and their outputs are no longer cells.

## Compile Output

For each `{ojs}` cell, `ojsCompile()` emits a div with an `ojs-cell-<n>` id and collects the cell source. It then writes an include-after-body file containing:

- `<script type="ojs-module-contents">` holding base64-encoded JSON (`{ contents: [{ methodName, source, cellName, ... }] }`). Base64 keeps user code from closing the script tag.
- A `<script type="module">` that ends with `window._ojs.runtime.interpretFromScriptTags()`, which makes the runtime read every `ojs-module-contents` and `ojs-define` script tag.

Shiny documents (`server: { type: shiny }`) get extra module contents. `server.ojs-export` (default `viewof`, i.e. every `viewof` name; also `all`, explicit names, `~name` exclusions) adds `shinyInput('<name>')` calls. `server.ojs-import` adds `<name> = shinyOutput('<name>')`. Misspelled plural keys (`ojs-exports`, `ojs-imports`) throw an error and abort the render.

## Runtime Injection

`ojsFormatDependency(selfContained)` declares the `quarto-ojs` HTML dependency: `quarto-ojs.css` always, plus `quarto-ojs-runtime.js` as a `type="module"` script unless the render is self-contained.

Self-contained renders (`embed-resources` / `self-contained`) inline the bundle instead, because a module script loaded by `src=` fails under `file://` CORS rules. `ojsCompile()` reads `formats/html/ojs/quarto-ojs-runtime.js` from `quartoConfig.sharePath()` and wraps it in `<script type="module">...</script>` inside an include-in-header file. The code carries an open `FIXME` asking whether `sharePath()` is the right way to locate the resource.

Invariant: the inlined bundle is not escaped, so `quarto-ojs-runtime.js` must never contain the text `</script`. Check after every rebuild:

```bash
rg -c '</script' src/resources/formats/html/ojs/quarto-ojs-runtime.js # must print nothing
```

Only the unminified `quarto-ojs-runtime.js` is used at render time: it is both the linked and the inlined script. `quarto-ojs-runtime.min.js` is built and committed, and ships in the distribution because `package/src/common/prepare-dist.ts` copies all of `src/resources/`, but nothing in quarto-cli references it.

## Inline Expressions (Lua)

`src/resources/filters/quarto-post/ojs.lua` handles inline `${expr}` in prose. It runs as the `post-ojs` stage in `main.lua` and is active only when the `ojs` filter param is set. `ojsCompile()` returns `filters: ["ojs"]`; `src/command/render/pandoc.ts` turns that pseudo-filter name into `formatFilterParams.ojs = true` instead of running a filter file.

The filter replaces each `${...}` with an empty span (`ojs-element-id-<n>`) and appends a hidden `ojs-auto-generated` div holding its own base64 `ojs-module-contents` script, which `interpretFromScriptTags()` picks up alongside the compile output.

Other Lua touch points:

- `quarto-pre/outputs.lua` and `layout/hugo.lua` recognise `ojs-cell-` ids so Hugo output keeps OJS cell divs.
- `modules/constants.lua` lists `//` as the `ojs` comment syntax.

## `ojs_define` (engine side)

`ojs_define()` passes data from R, Python, or Julia to OJS by emitting `<script type="ojs-define">` JSON that the runtime reads on load.

| Engine | Definition |
|--------|------------|
| knitr | `src/resources/rmd/ojs.R` (Shiny, via `sendCustomMessage`), `src/resources/rmd/ojs_static.R` |
| Jupyter (Python) | `src/resources/jupyter/lang/python/setup.py` |
| Jupyter (Julia) | `src/resources/jupyter/lang/julia/setup.jl` |

The static knitr `ojs_define()` (`ojs_static.R`) stops with an error in a cached chunk (`cache: false` is required).

`src/core/jupyter/jupyter.ts` keeps outputs whose metadata has `ojs_define` even when the cell has `output: false`.

## Resource Extraction

`extract-resources.ts` finds files that OJS code references (`FileAttachment(...)`, `import` of local `.js`/`.ojs` modules) by parsing cell sources and following imports recursively. Local `.js` modules are read as-is; `.ts`/`.tsx` imports are transpiled to `.js` with the bundled esbuild (`src/core/esbuild.ts`).

For self-contained renders, `makeSelfContainedResources()` turns those files into `data:` URLs: local `.js` imports are bundled into one ES module with esbuild, and attachments get MIME types from the Deno `media_types` `lookup()`.

In the browser, `fileAttachmentPathResolver` (`packages/ojs/quarto-ojs-runtime/src/quarto-ojs.js`) first checks the local resolver that `compile.ts` fills via `setLocalResolver()` in self-contained renders. A hit returns the `data:` URL string, and `FileAttachment(...).mimeType` is then `undefined`. Other attachments get `mimeType` from the bundled `mime` package's `getType()`, so upgrading `mime` can change the MIME types users observe in non-self-contained renders.

## Generated vs Hand-Edited

| Path | How it changes |
|------|----------------|
| `packages/ojs/quarto-ojs-runtime/src/` | Hand-edited: Quarto's runtime (connector, code transforms, inspector, JSX, Shiny bridge) |
| `packages/ojs/external-*/` | Hand-edited copies of Observable runtime/stdlib/parser, d3-require, and unofficial-observablehq-compiler; no `git subtree` |
| `src/resources/formats/html/ojs/quarto-ojs-runtime{,.min}.js` | Generated by the `packages/ojs` build; never hand-edit |
| `src/resources/formats/html/ojs/quarto-ojs.css` | Hand-edited in place; not produced by the build |
| `src/execute/ojs/`, `quarto-post/ojs.lua` | Normal quarto-cli source |

Rebuild rule: any change under `packages/ojs/` is incomplete until both bundles are rebuilt and committed in the same change:

```bash
cd packages/ojs && npm ci && npm run build
```

`rollup.config.js` writes straight into `src/resources/formats/html/ojs/`. `.github/workflows/test-ojs-runtime.yml` rebuilds on Node 24 and fails when `git diff` on that directory is not empty. The bundles are `linguist-vendored` in `.gitattributes`. `src/resources/formats/html/ojs/` is not managed by `update-html-dependencies.ts`.

Maintainer procedure (dependency bumps, fork changes, verification): `dev-docs/update-ojs-runtime.md`.

## Tests

No unit tests cover OJS. Coverage is render-level:

- Playwright: `tests/integration/playwright/tests/ojs-*.spec.ts`, rendering docs from `tests/docs/playwright/ojs/`. Helpers `ojsVal()` / `ojsRuns()` are described in `.claude/rules/testing/playwright-tests.md`.
- Smoke: `tests/smoke/ojs/*.test.ts` (layout, dependency traversal, `ojs_define` numeric precision across knitr/jupyter/julia) and `tests/smoke/render/render-ojs.test.ts`.
- smoke-all: documents with `{ojs}` cells, found with `rg -l '```\{ojs' tests/docs/smoke-all --glob '*.qmd'`.

A change to the runtime bundle needs the Playwright `ojs-*` specs. A change to `compile.ts` or `ojs.lua` also needs the smoke tests.

## Key Files

| File | Purpose |
|------|---------|
| `src/execute/ojs/compile.ts` | `ojsCompile()`, `ojsExecuteResult()`, `ojsFormatDependency()`, Shiny export/import |
| `src/execute/ojs/extract-resources.ts` | Resource discovery, esbuild of TypeScript imports, self-contained `data:` URLs |
| `src/command/render/pandoc.ts` | `ojs` pseudo-filter → `ojs` filter param |
| `src/resources/filters/quarto-post/ojs.lua` | Inline `${...}` expressions |
| `src/resources/rmd/ojs.R`, `ojs_static.R` | `ojs_define()` for knitr |
| `src/resources/jupyter/lang/python/setup.py`, `julia/setup.jl` | `ojs_define()` for Jupyter |
| `packages/ojs/` | npm workspace: runtime source and Observable forks |
| `packages/ojs/quarto-ojs-runtime/src/quarto-ojs.js` | Runtime core: `interpretFromScriptTags()`, `FileAttachment` resolver |
| `src/resources/formats/html/ojs/` | Committed bundles and `quarto-ojs.css` |
| `package/src/common/prepare-dist.ts` | Copies `src/resources/` (both bundles) into the distribution |
| `.github/workflows/test-ojs-runtime.yml` | CI check that committed bundles match a rebuild |
Loading