diff --git a/.claude/rules/filters/lua-development.md b/.claude/rules/filters/lua-development.md index b9da063f629..42db96f78b5 100644 --- a/.claude/rules/filters/lua-development.md +++ b/.claude/rules/filters/lua-development.md @@ -42,6 +42,20 @@ local md = require("modules/md") import("./modules/patterns.lua") ``` +### In `import()`ed Files, Prefer the `_quarto.modules` Registry Over a Fresh `require()` + +Files loaded via `import()` are textually concatenated into one shared Lua chunk for +the packaged distribution build, so every top-level `local` they declare — including +`local x = require("modules/y")` — competes for Lua's hard 200-local-per-function +limit across *all* inlined files combined. Most `modules/*` are already registered +in `_quarto.modules` by `modules/import_all.lua` (a single global table assignment, +so it costs zero additional locals). In an `import()`ed file, reference +`_quarto.modules..(...)` directly instead of adding a new top-level +`local = require("modules/")`. This doesn't apply inside files loaded +via `require()` themselves — those get their own independent chunk and aren't +affected. See [llm-docs/lua-filter-bundle-local-limit.md](../../../llm-docs/lua-filter-bundle-local-limit.md) +for the full mechanism and why this keeps recurring. + ## Custom Node Patterns ### Walking Custom Nodes diff --git a/configuration b/configuration index e70824da128..e14fba27530 100644 --- a/configuration +++ b/configuration @@ -13,10 +13,10 @@ export DENO=v2.7.14 # TODO figure out where 0.1.41 apple silicon libs are available export DENO_DOM=v0.1.41-alpha-artifacts -export PANDOC=3.8.3 -export DARTSASS=1.87.0 -export ESBUILD=0.25.10 -export TYPST=0.14.2 +export PANDOC=3.10 +export DARTSASS=1.101.0 +export ESBUILD=0.28.1 +export TYPST=0.15.0 export TYPST_GATHER=0.2.3 export VERAPDF=1.28.2 diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-fuchsia.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-fuchsia.qmd index 99cf9d49e59..7ef72078771 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-fuchsia.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-fuchsia.qmd @@ -20,7 +20,7 @@ _quarto: ensureTypstFileRegexMatches: - # note css fuchsia #f012be is not the same as css fuchsia #ff00ff - - '\[#set text\(fill: fuchsia\); B\]' + - '\[#set text\(fill: fuchsia\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-magenta.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-magenta.qmd index 71585f94638..af4b1d908cc 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-magenta.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-magenta.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(255, 0, 255\)\); B\]' + - '\[#set text\(fill: rgb\(255, 0, 255\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-mediumseagreen.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-mediumseagreen.qmd index 366d2196c16..0f5f873c982 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-mediumseagreen.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-mediumseagreen.qmd @@ -20,7 +20,7 @@ _quarto: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(60, 179, 113\)\); B\]' + - '\[#set text\(fill: rgb\(60, 179, 113\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-rgb-hex.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-rgb-hex.qmd index d254c5eec54..dc4f78fceb0 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-rgb-hex.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-rgb-hex.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\("#000066"\)\); B\]' + - '\[#set text\(fill: rgb\("#000066"\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-transparent.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-transparent.qmd index 1a1e0a07cf4..f9f2618457f 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-transparent.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/color-transparent.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(0, 0, 0, 0%\)\); B\]' + - '\[#set text\(fill: rgb\(0, 0, 0, 0%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/document.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/document.qmd index 58aa758687e..0a1eb03f307 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/color/document.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/color/document.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: maroon\); B\]' + - '\[#set text\(fill: maroon\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-family/document.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-family/document.qmd index b21d3aa0247..3888020500a 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-family/document.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-family/document.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '#{set text\(font: \("Libertinus Serif", "serif"\)\); table\(' + - '#{set text\(font: \("Libertinus Serif", "serif"\)\);\s*table\(' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-size/cell-percentage.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-size/cell-percentage.qmd index 8ff3c17d2f9..ad191c3fe30 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-size/cell-percentage.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-size/cell-percentage.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(size: 0.8em\); B\]' + - '\[#set text\(size: 0.8em\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-size/document.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-size/document.qmd index 6b628220b22..18be8a80c59 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-size/document.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-size/document.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '#{set text\(size: 6pt\); table\(' + - '#{set text\(size: 6pt\);\s*table\(' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/div-font-style-italic.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/div-font-style-italic.qmd index 98d7ba9cb89..95f90ed2dcc 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/div-font-style-italic.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/div-font-style-italic.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '#block\[\s*#set text\(style: "italic"\); Italic div\s*\]' + - '#block\[\s*#set text\(style: "italic"\);\s*Italic div\s*\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-italic.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-italic.qmd index 4ee03425f7b..412faa7b535 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-italic.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-italic.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(style: "italic"\); B\]' + - '\[#set text\(style: "italic"\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-normal.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-normal.qmd index ffb5d519640..53e8e08048d 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-normal.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-normal.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(style: "normal"\); B\]' + - '\[#set text\(style: "normal"\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-oblique.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-oblique.qmd index bbdd55add35..0877b843516 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-oblique.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-style/td-font-style-oblique.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(style: "oblique"\); B\]' + - '\[#set text\(style: "oblique"\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/div-font-weight-demi-bold.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/div-font-weight-demi-bold.qmd index c566e82541a..36dca59b154 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/div-font-weight-demi-bold.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/div-font-weight-demi-bold.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '#block\[\s*#set text\(weight: "semibold"\); Demi-bold div\s*\]' + - '#block\[\s*#set text\(weight: "semibold"\);\s*Demi-bold div\s*\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/td-font-weight-bold.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/td-font-weight-bold.qmd index 71585f94638..af4b1d908cc 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/td-font-weight-bold.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/td-font-weight-bold.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(255, 0, 255\)\); B\]' + - '\[#set text\(fill: rgb\(255, 0, 255\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/td-font-weight-ultra-light.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/td-font-weight-ultra-light.qmd index aa947fa4e09..9cb68594be0 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/td-font-weight-ultra-light.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/font-weight/td-font-weight-ultra-light.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(weight: "extralight"\); B\]' + - '\[#set text\(weight: "extralight"\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/document.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/document.qmd index be2140439d5..54b392a4e2b 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/document.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/document.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\("#85144b7f"\)\); B\]' + - '\[#set text\(fill: rgb\("#85144b7f"\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-mediumseagreen.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-mediumseagreen.qmd index 669fabf0a7a..f4e2f1ade89 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-mediumseagreen.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-mediumseagreen.qmd @@ -20,7 +20,7 @@ _quarto: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(60, 179, 113, 50%\)\); B\]' + - '\[#set text\(fill: rgb\(60, 179, 113, 50%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-nocolor.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-nocolor.qmd index de52a2db1c7..e1d832c079d 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-nocolor.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-nocolor.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(0, 0, 0, 50%\)\); B\]' + - '\[#set text\(fill: rgb\(0, 0, 0, 50%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-one.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-one.qmd index f443d9643ce..38427b9467d 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-one.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-one.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(0, 0, 0, 100%\)\); B\]' + - '\[#set text\(fill: rgb\(0, 0, 0, 100%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-fraction.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-fraction.qmd index 36df23baaaa..a42ee17cf40 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-fraction.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-fraction.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(70, 130, 180, 17%\)\); B\]' + - '\[#set text\(fill: rgb\(70, 130, 180, 17%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-int.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-int.qmd index 3b23fb1effb..42e83d271fa 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-int.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-int.qmd @@ -20,7 +20,7 @@ _quarto: ensureTypstFileRegexMatches: - # note the input is invalid, there is no "int alpha" input for rgb() in css - - '\[#set text\(fill: rgb\(70, 130, 180, 50%\)\); B\]' + - '\[#set text\(fill: rgb\(70, 130, 180, 50%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-percent.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-percent.qmd index af491fd07d9..2062658b54f 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-percent.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-alpha-percent.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(70, 130, 180, 30%\)\); B\]' + - '\[#set text\(fill: rgb\(70, 130, 180, 30%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-percent-alpha.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-percent-alpha.qmd index 208d426507d..a0b943718a1 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-percent-alpha.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-percent-alpha.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(94%, 42%, 0%, 37.5%\)\); B\]' + - '\[#set text\(fill: rgb\(94%, 42%, 0%, 37.5%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-percent.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-percent.qmd index 315c3128c3a..dcd49ca1b5c 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-percent.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma-percent.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(90%, 85%, 30%, 50%\)\); B\]' + - '\[#set text\(fill: rgb\(90%, 85%, 30%, 50%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma.qmd index d85fef961f7..a8dd9ee8a94 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-comma.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(70, 130, 180, 50%\)\); B\]' + - '\[#set text\(fill: rgb\(70, 130, 180, 50%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-hex-alpha.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-hex-alpha.qmd index 2491bd8e2a1..e04a31c7211 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-hex-alpha.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-hex-alpha.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\("#4682b440"\)\); B\]' + - '\[#set text\(fill: rgb\("#4682b440"\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-hex.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-hex.qmd index c3b3b416c3c..17821136dee 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-hex.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-hex.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\("#4682b47f"\)\); B\]' + - '\[#set text\(fill: rgb\("#4682b47f"\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-shorthex-alpha.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-shorthex-alpha.qmd index 3ef612801db..2f3d8e86a39 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-shorthex-alpha.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-shorthex-alpha.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\("#c1d4"\)\); B\]' + - '\[#set text\(fill: rgb\("#c1d4"\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-shorthex.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-shorthex.qmd index e7560680d5d..19d4902b3ef 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-shorthex.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-shorthex.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\("#c1d7"\)\); B\]' + - '\[#set text\(fill: rgb\("#c1d7"\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-alpha-fraction.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-alpha-fraction.qmd index b372d396bdc..fa327687686 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-alpha-fraction.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-alpha-fraction.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(70, 130, 180, 45%\)\); B\]' + - '\[#set text\(fill: rgb\(70, 130, 180, 45%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-alpha-percent.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-alpha-percent.qmd index 8c7bc367c83..2aa0dc7f114 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-alpha-percent.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-alpha-percent.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(70, 130, 180, 42%\)\); B\]' + - '\[#set text\(fill: rgb\(70, 130, 180, 42%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-fraction.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-fraction.qmd index 0a45f594bb0..82ff540b713 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-fraction.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-fraction.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(94%, 42%, 0%, 36%\)\); B\]' + - '\[#set text\(fill: rgb\(94%, 42%, 0%, 36%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-none.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-none.qmd index 04d0164793c..837eb0a5319 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-none.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-none.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(94%, 42%, 0%, 50%\)\); B\]' + - '\[#set text\(fill: rgb\(94%, 42%, 0%, 50%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-percent.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-percent.qmd index 2da64581d61..a3f18770b47 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-percent.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent-alpha-percent.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(94%, 42%, 0%, 42%\)\); B\]' + - '\[#set text\(fill: rgb\(94%, 42%, 0%, 42%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent.qmd index 2c477f8ee24..80e1de3e075 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space-percent.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(94%, 42%, 0%, 50%\)\); B\]' + - '\[#set text\(fill: rgb\(94%, 42%, 0%, 50%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space.qmd b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space.qmd index 46f078b3ff9..e322dd91f16 100644 --- a/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/css-properties/opacity/opacity-rgb-space.qmd @@ -19,7 +19,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(70, 130, 180, 50%\)\); B\]' + - '\[#set text\(fill: rgb\(70, 130, 180, 50%\)\);\s*B\]' - [] --- diff --git a/dev-docs/feature-format-matrix/qmd-files/raw-blocks/interpreted/pandoc-json/document.qmd b/dev-docs/feature-format-matrix/qmd-files/raw-blocks/interpreted/pandoc-json/document.qmd index 604bbff0474..b9e34b75825 100644 --- a/dev-docs/feature-format-matrix/qmd-files/raw-blocks/interpreted/pandoc-json/document.qmd +++ b/dev-docs/feature-format-matrix/qmd-files/raw-blocks/interpreted/pandoc-json/document.qmd @@ -27,7 +27,7 @@ _quarto: - ["–"] typst: ensureTypstFileRegexMatches: - - ["--"] + - ['do-\\-not-\\-escape-\\-this'] # each hyphen pair is now escaped individually; still renders as literal -- (verified via pdftotext), not en dash - ["–"] latex: ensureFileRegexMatches: diff --git a/llm-docs/lua-filter-bundle-local-limit.md b/llm-docs/lua-filter-bundle-local-limit.md new file mode 100644 index 00000000000..9018edf24d0 --- /dev/null +++ b/llm-docs/lua-filter-bundle-local-limit.md @@ -0,0 +1,141 @@ +# Lua Filter Bundling and the 200-Local-Variable Limit + +## The mechanism + +`main.lua` (`src/resources/filters/main.lua`) loads almost every filter file via +`import()`, a thin wrapper around `dofile()`: + +```lua +function import(script) + local path = PANDOC_SCRIPT_FILE:match("(.*[/\\])") + dofile(path .. script) +end +``` + +In **dev mode**, each `import()`ed file is its own `dofile()` call — its own +independent Lua chunk, with its own local-variable scope. Nothing outside that +file can see its top-level `local`s; cross-file communication only works +through globals (`quarto`, `_quarto`) or `require()`'d modules. + +For **distribution**, `package/src/common/package-filters.ts`'s `buildFilter()` +takes a different path: it parses the `-- [import] ... -- [/import]` block at +the top of `main.lua`, and **textually concatenates** every listed file's +source into one physical `main.lua`, verbatim, in order. This is a pure +string-join — it does not wrap each file's content in any scope. As a result: + +- Every top-level `local` declared in **every** inlined file becomes part of + **one single Lua chunk** (the "main function"). +- Lua enforces a hard limit of 200 local variables per function + (`MAXVARS` in Lua's compiler). Once the sum of all top-level locals across + every inlined file exceeds 200, the packaged `main.lua` fails to even + *compile*: `too many local variables (limit is 200) in main function`. +- Files loaded via `require()` (mostly things under `modules/`) are **not** + affected — `require()` resolves at runtime to its own independent chunk, + same in dev mode and in the packaged build. + +This failure only surfaces in the **"Bundle Test" / `test-bundle` CI job**, +which builds and smoke-tests the packaged distribution. Every other CI job +runs dev-mode `quarto` directly against the source tree, where each file is +still its own `dofile()` chunk — so this class of bug is invisible until the +packaged-bundle build runs. + +## Confirmed empirically (2026-07-16) + +Using a standalone reimplementation of `buildFilter()` plus pandoc itself as +a compile oracle (`pandoc doc.md --lua-filter=inlined.lua`), we measured the +actual margin on `chore/pandoc-3.10`: + +- At commit `e8921e613` (last known-good state): **199** top-level locals in + the inlined chunk — one spare slot. +- Commit `08deb91d7` added four new call sites each declaring their own + `local path = require("modules/path")` in four different `import()`ed + files (`quarto-pre/book-links.lua`, `quarto-pre/project-paths.lua`, + `quarto-post/typst-brand-yaml.lua`, `quarto-post/typst.lua`) — pushing the + count to **203** and breaking the compile. + +The codebase was already sitting at the edge (199/200) before this PR. This +is not a pandoc-3.10-specific problem — any future PR adding even one new +top-level local to an inlined file can trip it again. + +### This has already recurred multiple times + +- `c4d2198ad` (2023, PR #5146) — fixed by deleting one unused top-level local + in `crossref/listings.lua`. +- `ae392ad23` (2026-03, open PR #14170) — fixed by moving typst annotation + helpers out of an inlined file into a new `require()`-based + `modules/typst-code-annotations.lua`. +- `514baffae` (2026-04, open PR #14312) — same pattern: moved + `common/theorems.lua` logic into `modules/theorems.lua` behind `require()`. + +Every prior fix has been an ad-hoc, per-offending-file stopgap. None +addressed the inliner itself. The problem has recurred at least three times +independently within a few weeks on unrelated branches — evidence the +stopgap approach doesn't scale as filter code accumulates. + +## Fix patterns, in order of preference + +1. **Use the existing `_quarto.modules` registry instead of a fresh + `require()` alias.** `modules/import_all.lua` already centralizes most + `modules/*` requires into one table (`_quarto.modules.constants`, + `_quarto.modules.patterns`, `_quarto.modules.path`, etc.), assigned via a + single **global** table constructor — which costs **zero** additional + top-level locals no matter how many modules are registered in it. Any + `import()`ed file that needs a module already in that registry should + reference `_quarto.modules..(...)` directly instead of adding + its own `local x = require("modules/")`. This is the cheapest fix + and requires no new file. + + Caution: many pre-existing call sites (`constants` alone is re-required + as a fresh top-level local in ~10 different inlined files) do **not** + follow this convention yet, despite the module already being registered. + This is a large, low-risk cleanup opportunity — see "Known follow-up + work" below. + +2. **Move the logic into a `modules/*.lua` file loaded via `require()`.** + This is what `ae392ad23` and `514baffae` did. It fully escapes the shared + chunk (its own independent scope, same in dev and packaged builds) but + costs a new file and a new `import_all.lua` (or ad-hoc `require()`) + registration. + +3. **(Not yet implemented) Wrap each inlined file's body in `do ... end` + inside `buildFilter()`.** Verified empirically (2026-07-16, against Lua's + actual reference-implementation source in `lparser.c`) that this is + mechanically sound: Lua's compiler tracks *currently active* locals via + `reglevel`, not a cumulative count, and `do...end` blocks trigger + `leaveblock()` → `removevars()`, which rolls the active-local counter back + to what it was at block entry. So each file's top-level locals go out of + scope at that file's own `end`, freeing register slots for the next + file — the 200-slot budget only needs to cover the single most + local-heavy file, not the sum across all ~150 inlined files. + + This is the pattern real Lua bundlers (`amalg.lua`, `luabundle`) use — + though they wrap in a full closure (`function() ... end`) rather than a + plain block, because they additionally need `require()`/lazy-load/ + module-return semantics. Quarto's inliner doesn't need those semantics + (it's linear, order-preserving script concatenation, not a module + system), so a plain `do...end` wrapper is the minimal correct fix. + + Verified safe by construction: since dev-mode `dofile()` already + isolates each file into its own chunk, no filter file can rely today on + reading a *preceding* file's top-level local (that pattern would already + be broken in dev mode). Wrapping each inlined segment in `do...end` for + the packaged build only makes its scoping match dev-mode's existing + isolation — it cannot introduce new incompatibilities, though it was + only validated by compiling the wrapped bundle, not by running a full + smoke suite against it. + +## Known follow-up work (tracked separately, not urgent) + +- Sweep the ~25+ pre-existing inlined call sites that redundantly + `require()` a module already available via `_quarto.modules` (`constants` + ×~10, `patterns` ×~6, `lightbox` ×2, others) to use the registry instead. + Reclaims significant headroom; low risk, mechanical. +- Implement the `do...end` wrap in `buildFilter()` (fix pattern 3 above) as + a permanent structural fix, with a full smoke-all run against the + *packaged* bundle (not just a compile check) before merging. +- Given how often this recurs, once the process is well understood a + dedicated skill or lint check (e.g. a CI step that runs the real + `buildFilter()` + a pandoc compile-only check on every PR, rather than + discovering the break only in the full packaged smoke-test job) would + catch this class of regression immediately instead of ~20+ minutes into a + CI run. diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index c645725178f..e42508d1f61 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -11,6 +11,12 @@ All changes included in 1.10: ## Dependencies - ([#14291](https://github.com/quarto-dev/quarto-cli/issues/14291)): Update `deno` to v2.7.14 (fixes silent crash on Windows builds older than 16299). +- ([#14664](https://github.com/quarto-dev/quarto-cli/issues/14664)): Update more dependencies: + - Update `pandoc` to 3.10. + - Update `typst` to 0.15.0. + - Update `typst-gather` to 0.2.3. + - Update `dart-sass` to 1.101.0. + - Update `esbuild` to 0.28.1. ## Accessibility diff --git a/src/command/check/check.ts b/src/command/check/check.ts index dce5c9c8bbb..7d18d40e072 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -244,10 +244,10 @@ async function checkVersions(conf: CheckConfiguration) { // file is in an awkward format and it is not packaged // with our installers const versionConstraints: [string | undefined, string, string][] = [ - [pandocVersion, "3.8.3", "Pandoc"], - [sassVersion, "1.87.0", "Dart Sass"], + [pandocVersion, "3.10.0", "Pandoc"], + [sassVersion, "1.101.0", "Dart Sass"], [denoVersion, "2.7.14", "Deno"], - [typstVersion, "0.14.2", "Typst"], + [typstVersion, "0.15.0", "Typst"], ]; const checkData: [string | undefined, string, string][] = versionConstraints .map(([version, ver, name]) => [ diff --git a/src/core/brand/brand.ts b/src/core/brand/brand.ts index 8cd7b7ce486..af3063e85ff 100644 --- a/src/core/brand/brand.ts +++ b/src/core/brand/brand.ts @@ -39,7 +39,7 @@ import { LogoSpecifier, LogoSpecifierPathOptional, } from "../../resources/types/schema-types.ts"; -import { ensureLeadingSlash } from "../path.ts"; +import { ensureLeadingSlash, pathWithForwardSlashes } from "../path.ts"; type ProcessedBrandData = { color: Record; @@ -246,14 +246,21 @@ export class Brand { resolvePath(entry: BrandLogoResource) { const pathPrefix = relative(this.projectDir, this.brandDir); + // Always use forward slashes: this path can flow into writers (e.g. + // Typst 0.15+) that reject backslash path separators, while forward + // slashes work fine for the actual file resolution on Windows too. if (typeof entry === "string") { - return { path: isExternalPath(entry) ? entry : join(pathPrefix, entry) }; + return { + path: isExternalPath(entry) + ? entry + : pathWithForwardSlashes(join(pathPrefix, entry)), + }; } return { ...entry, path: isExternalPath(entry.path) ? entry.path - : join(pathPrefix, entry.path), + : pathWithForwardSlashes(join(pathPrefix, entry.path)), }; } diff --git a/src/core/handlers/base.ts b/src/core/handlers/base.ts index b0e6d63a00b..68969660bfe 100644 --- a/src/core/handlers/base.ts +++ b/src/core/handlers/base.ts @@ -79,7 +79,7 @@ import { ensureDirSync } from "../../deno_ral/fs.ts"; import { mappedStringFromFile } from "../mapped-text.ts"; import { error } from "../../deno_ral/log.ts"; import { withCriClient } from "../cri/cri.ts"; -import { normalizePath } from "../path.ts"; +import { normalizePath, pathWithForwardSlashes } from "../path.ts"; import { InvalidShortcodeError, isBlockShortcode, @@ -247,7 +247,10 @@ function makeHandlerContext( const pngName = `${prefix}${globalFigureCounter[prefix]}${extension}`; const tempName = join(context.figuresDir(), pngName); const baseDir = dirname(options.context.target.source); - const mdName = relative(baseDir, tempName); + // Always use forward slashes: this path is embedded as an image source + // and can flow into writers (e.g. Typst 0.15+) that reject backslash + // path separators, while forward slashes work fine on Windows too. + const mdName = pathWithForwardSlashes(relative(baseDir, tempName)); this.addSupporting(relative(baseDir, context.figuresDir())); diff --git a/src/resources/filters/modules/import_all.lua b/src/resources/filters/modules/import_all.lua index 61429636fca..b642911129f 100644 --- a/src/resources/filters/modules/import_all.lua +++ b/src/resources/filters/modules/import_all.lua @@ -16,6 +16,7 @@ _quarto.modules = { lightbox = require("modules/lightbox"), mediabag = require("modules/mediabag"), openxml = require("modules/openxml"), + path = require("modules/path"), patterns = require("modules/patterns"), scope = require("modules/scope"), string = require("modules/string"), diff --git a/src/resources/filters/modules/jog.lua b/src/resources/filters/modules/jog.lua index f3ab5c07302..a3547570c40 100644 --- a/src/resources/filters/modules/jog.lua +++ b/src/resources/filters/modules/jog.lua @@ -118,6 +118,9 @@ local function recurse (element, tp, jogger) elseif tp == 'pandoc TableHead' or tp == 'pandoc TableFoot' or tp == 'TableHead' or tp == 'TableFoot' then element.rows = jogger(element.rows) + elseif tp == 'pandoc TableBody' or tp == 'TableBody' then + element.head = jogger(element.head) + element.body = jogger(element.body) elseif tp == 'Blocks' or tp == 'Inlines' then local expected_itemtype = tp == 'Inlines' and 'Inline' or 'Block' local pos = 0 diff --git a/src/resources/filters/modules/mediabag.lua b/src/resources/filters/modules/mediabag.lua index 658b613b156..183527e8fae 100644 --- a/src/resources/filters/modules/mediabag.lua +++ b/src/resources/filters/modules/mediabag.lua @@ -4,6 +4,7 @@ -- helpers for working with the pandoc mediabag local filenames = require 'modules/filenames' +local path = require 'modules/path' -- A cache of image urls that we've resolved into the mediabag -- keyed by {url: mediabagpath} @@ -82,7 +83,9 @@ local function write_mediabag_entry(src) if contents == nil then return nil end local mediabagDir = param("mediabag-dir", nil) - local mediaFile = pandoc.path.join{mediabagDir, src} + -- This path is assigned to el.src, so it needs forward slashes. See + -- modules/path.lua. + local mediaFile = path.to_forward_slashes(pandoc.path.join{mediabagDir, src}) local file = _quarto.file.write(mediaFile, contents) if not file then diff --git a/src/resources/filters/modules/path.lua b/src/resources/filters/modules/path.lua new file mode 100644 index 00000000000..384434a7ae1 --- /dev/null +++ b/src/resources/filters/modules/path.lua @@ -0,0 +1,14 @@ +-- path.lua +-- Copyright (C) 2026 Posit Software, PBC + +-- Always use forward slashes: a path built with the native separator can +-- flow into writers (e.g. Typst 0.15+) that reject backslash path +-- separators, while forward slashes work fine for file resolution on +-- Windows too. +local function to_forward_slashes(path) + return path:gsub('\\', '/') +end + +return { + to_forward_slashes = to_forward_slashes +} diff --git a/src/resources/filters/quarto-post/typst-brand-yaml.lua b/src/resources/filters/quarto-post/typst-brand-yaml.lua index d4225c4904a..ae2243cf002 100644 --- a/src/resources/filters/quarto-post/typst-brand-yaml.lua +++ b/src/resources/filters/quarto-post/typst-brand-yaml.lua @@ -110,7 +110,8 @@ function render_typst_brand_yaml() local declImage = {} for name, image in pairs(brandLogo.images) do declImage[name] = { - path = quote_string(image.path):gsub('\\', '\\\\'), + -- Typst 0.15+ rejects backslash path separators. + path = _quarto.modules.path.to_forward_slashes(quote_string(image.path)), alt = quote_string(image.alt), } end @@ -121,7 +122,8 @@ function render_typst_brand_yaml() for _, size in pairs({'small', 'medium', 'large'}) do if brandLogo[size] then declLogo[size] = { - path = quote_string(brandLogo[size].path):gsub('\\', '\\\\'), + -- Typst 0.15+ rejects backslash path separators. + path = _quarto.modules.path.to_forward_slashes(quote_string(brandLogo[size].path)), alt = quote_string(brandLogo[size].alt), } end @@ -328,8 +330,8 @@ function render_typst_brand_yaml() imageFilename = _quarto.modules.mediabag.write_mediabag_entry(imageFilename) or imageFilename imageFilename = imageFilename and imageFilename:gsub('\\_', '_') else - -- backslashes need to be doubled for Windows - imageFilename = string.gsub(imageFilename, '\\', '\\\\') + -- Typst 0.15+ rejects backslash path separators. + imageFilename = _quarto.modules.path.to_forward_slashes(imageFilename) end logoOptions.path = pandoc.RawInline('typst', imageFilename) meta.logo = logoOptions diff --git a/src/resources/filters/quarto-post/typst.lua b/src/resources/filters/quarto-post/typst.lua index 10e822405e4..c4a7bfe19af 100644 --- a/src/resources/filters/quarto-post/typst.lua +++ b/src/resources/filters/quarto-post/typst.lua @@ -252,8 +252,8 @@ function render_typst_fixups() -- Build image() parameters local params = {} - -- Source path (escape backslashes for Windows paths) - src = src:gsub('\\', '\\\\') + -- Typst 0.15+ rejects backslash path separators in image() calls. + src = _quarto.modules.path.to_forward_slashes(src) table.insert(params, '"' .. src .. '"') -- Alt text second (escape backslashes and quotes) @@ -274,6 +274,21 @@ function render_typst_fixups() return pandoc.RawInline("typst", "#box(image(" .. table.concat(params, ", ") .. "))") end + -- When caption-as-alt is deliberately suppressed (this Image is the + -- sole content of a Figure whose caption is rendered separately, see + -- layout/pandoc3_figure.lua) and no other alt text applies, clear the + -- caption before returning the bare Image. Otherwise Pandoc's own + -- Typst writer independently re-derives alt: from the Image's + -- caption, reintroducing the leak this suppression exists to prevent. + if no_caption_alt then + image.caption = pandoc.Inlines{} + end + + -- Typst 0.15+ rejects backslash path separators in image() calls, and + -- this bare-Image path (no alt text) is handed to Pandoc's own Typst + -- writer, which emits image.src verbatim. + image.src = _quarto.modules.path.to_forward_slashes(image.src) + return image end, Div = function(div) diff --git a/src/resources/filters/quarto-pre/bibliography-formats.lua b/src/resources/filters/quarto-pre/bibliography-formats.lua index da7c632f8da..0b9c687b37b 100644 --- a/src/resources/filters/quarto-pre/bibliography-formats.lua +++ b/src/resources/filters/quarto-pre/bibliography-formats.lua @@ -9,6 +9,18 @@ function bibliography_formats() doc.meta.references = pandoc.utils.references(doc) doc.meta.bibliography = nil return doc + elseif _quarto.format.isTypstOutput() and doc.meta.bibliography ~= nil + and quarto.doc.cite_method() ~= "citeproc" then + -- pandoc's Typst writer backslash-escapes any metadata string starting + -- with a literal dot (jgm/pandoc#11511). Rewriting each bibliography + -- path as a raw typst inline bypasses that escaping entirely. + -- + -- Skip this when citeproc is handling citations natively (cite-method + -- "citeproc", set when the user opts into `citeproc: true` for Typst): + -- pandoc's own citeproc reads doc.meta.bibliography to load the actual + -- bib file, so it must stay a plain path string here. + doc.meta.bibliography = quarto.utils.as_raw_metadata(doc.meta.bibliography) + return doc end end } diff --git a/src/resources/filters/quarto-pre/book-links.lua b/src/resources/filters/quarto-pre/book-links.lua index 41e19222d63..f277e24c84a 100644 --- a/src/resources/filters/quarto-pre/book-links.lua +++ b/src/resources/filters/quarto-pre/book-links.lua @@ -1,7 +1,7 @@ -- book-links.lua -- Copyright (C) 2020-2022 Posit Software, PBC -function index_book_file_targets() +function index_book_file_targets() if not param("single-file-book", false) then return {} else @@ -53,7 +53,7 @@ function resolve_book_file_targets() package.config:sub(1,1) -- Paths are always using '/' separator (even on windows) - linkTarget = linkTarget:gsub("\\", "/") + linkTarget = _quarto.modules.path.to_forward_slashes(linkTarget) local sectionId = quarto_global_state.fileSectionIds[linkTarget]; if sectionId ~= nil then el.target = '#' .. sectionId diff --git a/src/resources/filters/quarto-pre/project-paths.lua b/src/resources/filters/quarto-pre/project-paths.lua index 8fb6d9053e9..af1b1117dbe 100644 --- a/src/resources/filters/quarto-pre/project-paths.lua +++ b/src/resources/filters/quarto-pre/project-paths.lua @@ -6,7 +6,9 @@ local constants = require("modules/constants") local function resolveProjectPath(path) local offset = _quarto.projectOffset() if offset and path and startsWith(path, '/') then - return pandoc.path.join({offset, pandoc.text.sub(path, 2, #path)}) + -- This path can flow into writers (e.g. Typst 0.15+) that reject + -- backslash path separators, so it needs forward slashes. + return _quarto.modules.path.to_forward_slashes(pandoc.path.join({offset, pandoc.text.sub(path, 2, #path)})) else return nil end diff --git a/src/resources/formats/beamer/pandoc/beamer.template b/src/resources/formats/beamer/pandoc/beamer.template index 6e2c0ed80cf..bb88d82662f 100644 --- a/src/resources/formats/beamer/pandoc/beamer.template +++ b/src/resources/formats/beamer/pandoc/beamer.template @@ -125,7 +125,7 @@ $for(titlegraphic)$ $endfor$} $endif$ $if(logo)$ -\logo{\includegraphics{$logo$}} +\logo{\includegraphics$if(logooptions)$[$for(logooptions)$$logooptions$$sep$, $endfor$]$endif${$logo$}} $endif$ \begin{document} diff --git a/src/resources/formats/beamer/pandoc/hypersetup.latex b/src/resources/formats/beamer/pandoc/hypersetup.latex index ff67655576e..610ba2c8fd8 100644 --- a/src/resources/formats/beamer/pandoc/hypersetup.latex +++ b/src/resources/formats/beamer/pandoc/hypersetup.latex @@ -1,3 +1,7 @@ +% fallback for those not using the hyperref driver hyperxmp: +\makeatletter +\@ifundefined{xmpquote}{\newcommand{\xmpquote}[1]{#1}}{} +\makeatother \hypersetup{ $if(title-meta)$ pdftitle={$title-meta$}, @@ -12,7 +16,7 @@ $if(subject)$ pdfsubject={$subject$}, $endif$ $if(keywords)$ - pdfkeywords={$for(keywords)$$keywords$$sep$, $endfor$}, + pdfkeywords={$for(keywords)$\xmpquote{$keywords$}$sep$, $endfor$}, $endif$ $if(colorlinks)$ colorlinks=true, diff --git a/src/resources/formats/beamer/pandoc/title.tex b/src/resources/formats/beamer/pandoc/title.tex index a3163a911c2..ccbe5198f1f 100644 --- a/src/resources/formats/beamer/pandoc/title.tex +++ b/src/resources/formats/beamer/pandoc/title.tex @@ -18,5 +18,5 @@ $endfor$} $endif$ $if(logo)$ -\logo{\includegraphics{$logo$}} +\logo{\includegraphics$if(logooptions)$[$for(logooptions)$$logooptions$$sep$, $endfor$]$endif${$logo$}} $endif$ \ No newline at end of file diff --git a/src/resources/formats/html/pandoc/html.styles b/src/resources/formats/html/pandoc/html.styles index 800f63c7a6a..cac6172c5ed 100644 --- a/src/resources/formats/html/pandoc/html.styles +++ b/src/resources/formats/html/pandoc/html.styles @@ -106,6 +106,7 @@ div.abstract-title { } $endif$ code { + white-space: pre-wrap; font-family: $if(monofont)$$monofont$$else$Menlo, Monaco, Consolas, 'Lucida Console', monospace$endif$; $if(monobackgroundcolor)$ background-color: $monobackgroundcolor$; @@ -183,10 +184,13 @@ header { text-decoration: none; } $endif$ -code{white-space: pre-wrap;} span.smallcaps{font-variant: small-caps;} -div.columns{display: flex; gap: min(4vw, 1.5em);} -div.column{flex: auto; overflow-x: auto;} +div.columns{display: flex; gap: 1.5em;} +div.column{flex: auto;} +@media screen { +div.columns{gap: min(4vw, 1.5em);} +div.column{overflow-x: auto;} +} div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;} /* The extra [class] is a hack that increases specificity enough to override a similar rule in reveal.js */ diff --git a/src/resources/formats/html/pandoc/html.template b/src/resources/formats/html/pandoc/html.template index 26740d1b0ec..2f39bc95ec6 100644 --- a/src/resources/formats/html/pandoc/html.template +++ b/src/resources/formats/html/pandoc/html.template @@ -1,8 +1,8 @@ - + - + $for(author-meta)$ diff --git a/src/resources/formats/html/pandoc/styles.html b/src/resources/formats/html/pandoc/styles.html index ebcc014be1f..795fd311307 100644 --- a/src/resources/formats/html/pandoc/styles.html +++ b/src/resources/formats/html/pandoc/styles.html @@ -118,6 +118,7 @@ } $endif$ code { + white-space: pre-wrap; font-family: $if(monofont)$$monofont$$else$Menlo, Monaco, Consolas, 'Lucida Console', monospace$endif$; $if(monobackgroundcolor)$ background-color: $monobackgroundcolor$; @@ -190,10 +191,13 @@ text-decoration: none; } $endif$ -code{white-space: pre-wrap;} span.smallcaps{font-variant: small-caps;} -div.columns{display: flex; gap: min(4vw, 1.5em);} -div.column{flex: auto; overflow-x: auto;} +div.columns{display: flex; gap: 1.5em;} +div.column{flex: auto;} +@media screen { +div.columns{gap: min(4vw, 1.5em);} +div.column{overflow-x: auto;} +} div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;} ul.task-list{list-style: none;} ul.task-list li input[type="checkbox"] { diff --git a/src/resources/formats/html/pandoc/template.html b/src/resources/formats/html/pandoc/template.html index 3a7b1b9675f..5ae339e9211 100644 --- a/src/resources/formats/html/pandoc/template.html +++ b/src/resources/formats/html/pandoc/template.html @@ -1,5 +1,5 @@ - + diff --git a/src/resources/formats/pdf/pandoc/hypersetup.latex b/src/resources/formats/pdf/pandoc/hypersetup.latex index ff67655576e..610ba2c8fd8 100644 --- a/src/resources/formats/pdf/pandoc/hypersetup.latex +++ b/src/resources/formats/pdf/pandoc/hypersetup.latex @@ -1,3 +1,7 @@ +% fallback for those not using the hyperref driver hyperxmp: +\makeatletter +\@ifundefined{xmpquote}{\newcommand{\xmpquote}[1]{#1}}{} +\makeatother \hypersetup{ $if(title-meta)$ pdftitle={$title-meta$}, @@ -12,7 +16,7 @@ $if(subject)$ pdfsubject={$subject$}, $endif$ $if(keywords)$ - pdfkeywords={$for(keywords)$$keywords$$sep$, $endfor$}, + pdfkeywords={$for(keywords)$\xmpquote{$keywords$}$sep$, $endfor$}, $endif$ $if(colorlinks)$ colorlinks=true, diff --git a/src/resources/formats/pdf/pandoc/latex.template b/src/resources/formats/pdf/pandoc/latex.template index 6c70459554f..e993af1d77d 100644 --- a/src/resources/formats/pdf/pandoc/latex.template +++ b/src/resources/formats/pdf/pandoc/latex.template @@ -1,3 +1,4 @@ +$document-metadata.latex()$ $passoptions.latex()$ \documentclass[ $for(babel-otherlangs)$ diff --git a/src/resources/formats/revealjs/pandoc/revealjs.template b/src/resources/formats/revealjs/pandoc/revealjs.template index 5278dd14571..0fc4464f170 100644 --- a/src/resources/formats/revealjs/pandoc/revealjs.template +++ b/src/resources/formats/revealjs/pandoc/revealjs.template @@ -29,6 +29,9 @@ $if(theme)$ $else$ $endif$ +$if(highlight-js)$ + +$endif$ $for(css)$ $endfor$ @@ -87,6 +90,9 @@ $body$ $if(mathjax)$ $endif$ +$if(highlight-js)$ + +$endif$ $if(mathjax)$ +$endif$ +$if(highlight-js)$ + $endif$ @@ -313,6 +319,9 @@ plugins: [ $if(mathjax)$ RevealMath, +$endif$ +$if(highlight-js)$ + RevealHighlight, $endif$ RevealNotes, RevealSearch, diff --git a/src/resources/formats/typst/pandoc/template.typst b/src/resources/formats/typst/pandoc/template.typst index 648476620f5..1d90b1d9769 100644 --- a/src/resources/formats/typst/pandoc/template.typst +++ b/src/resources/formats/typst/pandoc/template.typst @@ -73,47 +73,48 @@ } } - block(below: 1em, width: 100%)[ - #if title != none { - align(center, block[ - #text(weight: "bold", size: 1.5em)[#title #if thanks != none { - footnote(thanks, numbering: "*") - counter(footnote).update(n => n - 1) - }] - #( - if subtitle != none { - parbreak() - text(weight: "bold", size: 1.25em)[#subtitle] - } - )]) - } - - #if authors != none and authors != [] { - let count = authors.len() - let ncols = calc.min(count, 3) - grid( - columns: (1fr,) * ncols, - row-gutter: 1.5em, - ..authors.map(author => align(center)[ - #author.name \ - #author.affiliation \ - #author.email - ]) - ) - } + if title != none { + place(top, float: true, scope: "parent", clearance: 4mm, block(below: 1em, width: 100%)[ + #if title != none { + align(center, block[ + #text(weight: "bold", size: 1.5em, hyphenate: false)[#title #if thanks != none { + footnote(thanks, numbering: "*") + counter(footnote).update(n => n - 1) + }] + #( + if subtitle != none { + parbreak() + text(weight: "bold", size: 1.25em, hyphenate: false)[#subtitle] + } + )]) + } - #if date != none { - align(center)[#block(inset: 1em)[ - #date - ]] - } + #if authors != none and authors != [] { + let count = authors.len() + let ncols = calc.min(count, 3) + grid( + columns: (1fr,) * ncols, + row-gutter: 1.5em, + ..authors.map(author => align(center)[ + #author.name \ + #author.affiliation \ + #author.email + ]) + ) + } - #if abstract != none { - block(inset: 2em)[ - #text(weight: "semibold")[#abstract-title] #h(1em) #abstract - ] - } - ] + #if date != none { + align(center)[#block(inset: 1em)[ + #date + ]] + } + #if abstract != none { + block(inset: 2em)[ + #text(weight: "semibold")[#abstract-title] #h(1em) #abstract + ] + } + ]) + } doc } diff --git a/src/resources/formats/typst/pandoc/typst.template b/src/resources/formats/typst/pandoc/typst.template index a499cd8db9f..9b161a4c873 100644 --- a/src/resources/formats/typst/pandoc/typst.template +++ b/src/resources/formats/typst/pandoc/typst.template @@ -1,13 +1,9 @@ #let horizontalrule = line(start: (25%,0%), end: (75%,0%)) -#show terms: it => { - it.children - .map(child => [ - #strong[#child.term] - #block(inset: (left: 1.5em, top: -0.4em))[#child.description] - ]) - .join() -} +#show terms.item: it => block(breakable: false)[ + #text(weight: "bold")[#it.term] + #block(inset: (left: 1.5em, top: -0.4em))[#it.description] +] #set table( inset: 6pt, diff --git a/src/resources/lua-types/pandoc/components.lua b/src/resources/lua-types/pandoc/components.lua index 7c838708590..06b1e65a777 100644 --- a/src/resources/lua-types/pandoc/components.lua +++ b/src/resources/lua-types/pandoc/components.lua @@ -198,6 +198,16 @@ number of row header columns. pandoc.TableBody = {} +--[[ +Creates a table body +]] +---@param body? pandoc.List List of `Row` +---@param head? pandoc.List Intermediate head (list of `Row`) +---@param row_head_columns? integer Number of columns taken up by the row head of each row of a `TableBody` +---@param attr? pandoc.Attr Table body attributes +---@return pandoc.TableBody +function pandoc.TableBody(body, head, row_head_columns, attr) end + --[[ Make a clone ]] diff --git a/src/resources/lua-types/pandoc/pandoc.lua b/src/resources/lua-types/pandoc/pandoc.lua index 0c33cb5e453..cea7c64e344 100644 --- a/src/resources/lua-types/pandoc/pandoc.lua +++ b/src/resources/lua-types/pandoc/pandoc.lua @@ -3,10 +3,10 @@ ---@module 'pandoc' pandoc = {} ----@type table +---@type table pandoc.readers = {} ----@type table +---@type table pandoc.writers = {} --[[ diff --git a/src/resources/lua-types/pandoc/version.lua b/src/resources/lua-types/pandoc/version.lua index ecf1c5394a8..f0d1aa8f3ae 100644 --- a/src/resources/lua-types/pandoc/version.lua +++ b/src/resources/lua-types/pandoc/version.lua @@ -27,6 +27,30 @@ pandoc.Version = {} function pandoc.types.Version(version_specifier) end +--[[ +A named source of text, e.g. a file name paired with its contents. +Used as an item in a `Sources` list. +]] +---@class pandoc.types.Source +---@field name string Name/path the text originated from +---@field text string The source text + +--[[ +A list of `Source` items, pairing input text with information on +where it originated (e.g. a file name). Used by Pandoc's text readers. +]] +---@class pandoc.types.Sources: pandoc.List + +---@alias sources_specifier string|pandoc.types.Source[]|pandoc.types.Sources + +--[[ +Creates a new Sources element, i.e., a list of `Source` items. +]] +---@param srcs sources_specifier +---@return pandoc.types.Sources +function pandoc.types.Sources(srcs) end + + --[[ Raise an error message if the version is older than the expected version; does nothing if actual is equal to or newer than the expected version. diff --git a/src/resources/pandoc/datadir/_utils.lua b/src/resources/pandoc/datadir/_utils.lua index 2ea4b0580c1..8a204fbaef4 100644 --- a/src/resources/pandoc/datadir/_utils.lua +++ b/src/resources/pandoc/datadir/_utils.lua @@ -607,6 +607,28 @@ local function walk(node, filter) return node:walk(filter) end +--- Rewrites a Meta scalar or list value so every entry becomes a `RawInline` +-- in the given format, verbatim -- bypassing that format's writer-side string +-- escaping entirely. Preserves the scalar-vs-list shape of the input. +-- Useful for metadata values (paths, etc.) that a pandoc writer's own +-- escaping would otherwise corrupt. `format` defaults to the current output +-- format (pandoc's `FORMAT` global) when omitted. +local function as_raw_metadata(value, format) + format = format or FORMAT + local function raw(str) + return pandoc.MetaInlines({ pandoc.RawInline(format, str) }) + end + if get_type(value) == "List" then + local result = pandoc.List() + for _, item in ipairs(value) do + result:insert(raw(pandoc.utils.stringify(item))) + end + return result + else + return raw(pandoc.utils.stringify(value)) + end +end + return { dump = dump, type = get_type, @@ -617,6 +639,7 @@ return { }, as_inlines = as_inlines, as_blocks = as_blocks, + as_raw_metadata = as_raw_metadata, is_empty_node = is_empty_node, match = match, walk = walk, diff --git a/src/resources/pandoc/datadir/init.lua b/src/resources/pandoc/datadir/init.lua index 8eb2be0ae76..bd19df840ff 100644 --- a/src/resources/pandoc/datadir/init.lua +++ b/src/resources/pandoc/datadir/init.lua @@ -971,6 +971,7 @@ quarto = { resolve_path_relative_to_document = resolvePath, as_inlines = utils.as_inlines, as_blocks = utils.as_blocks, + as_raw_metadata = utils.as_raw_metadata, is_empty_node = utils.is_empty_node, string_to_blocks = utils.string_to_blocks, string_to_inlines = utils.string_to_inlines, diff --git a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd index 6c1b98a8a77..00db55838c3 100644 --- a/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd +++ b/tests/docs/smoke-all/2026/02/04/issue-13992-proof.qmd @@ -14,7 +14,7 @@ _quarto: - ['Proof content visible'] typst: ensureTypstFileRegexMatches: - - ['#emph\[Proof\]\. Proof content visible'] + - ['#emph\[Proof\]\\?\. Proof content visible'] - [] --- diff --git a/tests/docs/smoke-all/2026/06/29/14583-typst.qmd b/tests/docs/smoke-all/2026/06/29/14583-typst.qmd index 50fad66de63..fafd36670cf 100644 --- a/tests/docs/smoke-all/2026/06/29/14583-typst.qmd +++ b/tests/docs/smoke-all/2026/06/29/14583-typst.qmd @@ -11,9 +11,9 @@ _quarto: # Typst's default-image-extension is svg. A shortcode resolving to a # .png path becomes .png.svg; the spurious .svg must be stripped, # leaving the real .png (issue #14583, mismatched-extension case). - - 'image\("assets/diagram\.png"\)' + - 'image\("assets/diagram\.png"' - - - 'image\("assets/diagram\.png\.svg"\)' + - 'image\("assets/diagram\.png\.svg"' --- Shortcode resolving to a `.png` path in a Typst document (default extension diff --git a/tests/docs/smoke-all/crossrefs/float/asciidoc/asciidoc-float-caption-formatting-1.qmd b/tests/docs/smoke-all/crossrefs/float/asciidoc/asciidoc-float-caption-formatting-1.qmd index aa91f501746..439382f2d21 100644 --- a/tests/docs/smoke-all/crossrefs/float/asciidoc/asciidoc-float-caption-formatting-1.qmd +++ b/tests/docs/smoke-all/crossrefs/float/asciidoc/asciidoc-float-caption-formatting-1.qmd @@ -6,7 +6,7 @@ _quarto: asciidoc: ensureFileRegexMatches: - - - "\\.Customers _query_" + - "\\.Customers _{1,2}query_{1,2}" - "<>" - "\\[\\[lst-customers\\]\\]" --- diff --git a/tests/docs/smoke-all/typst/.gitignore b/tests/docs/smoke-all/typst/.gitignore index 3e0982561e1..25197bc23cb 100644 --- a/tests/docs/smoke-all/typst/.gitignore +++ b/tests/docs/smoke-all/typst/.gitignore @@ -1,3 +1,6 @@ # Generated test outputs (from keep-typ option) *.typ *.pdf + +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/typst/brand-yaml/typography/brand-extension/.gitignore b/tests/docs/smoke-all/typst/brand-yaml/typography/brand-extension/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/typst/brand-yaml/typography/brand-extension/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/typst/css-property-processing/default.qmd b/tests/docs/smoke-all/typst/css-property-processing/default.qmd index 2d554c374f3..9f8f44bd615 100644 --- a/tests/docs/smoke-all/typst/css-property-processing/default.qmd +++ b/tests/docs/smoke-all/typst/css-property-processing/default.qmd @@ -6,7 +6,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(255, 0, 255\)\); B\]' + - '\[#set text\(fill: rgb\(255, 0, 255\)\);\s*B\]' - [] --- diff --git a/tests/docs/smoke-all/typst/css-property-processing/translate.qmd b/tests/docs/smoke-all/typst/css-property-processing/translate.qmd index b5c57ed355a..3cfc8703b4d 100644 --- a/tests/docs/smoke-all/typst/css-property-processing/translate.qmd +++ b/tests/docs/smoke-all/typst/css-property-processing/translate.qmd @@ -8,7 +8,7 @@ _quarto: typst: ensureTypstFileRegexMatches: - - - '\[#set text\(fill: rgb\(255, 0, 255\)\); B\]' + - '\[#set text\(fill: rgb\(255, 0, 255\)\);\s*B\]' - [] --- diff --git a/tests/docs/smoke-all/typst/gt-islands.qmd b/tests/docs/smoke-all/typst/gt-islands.qmd index 6f80c9ac663..61ca2751ad0 100644 --- a/tests/docs/smoke-all/typst/gt-islands.qmd +++ b/tests/docs/smoke-all/typst/gt-islands.qmd @@ -11,7 +11,7 @@ _quarto: tests: typst: ensureTypstFileRegexMatches: - - ['#set text\(size: 1\.25em , weight: "regular" , fill: rgb\("#333333"\)\); Large Landmasses of the World'] + - ['#set text\(size: 1\.25em , weight: "regular" , fill: rgb\("#333333"\)\);\s*Large Landmasses of the World'] - [] --- diff --git a/tests/docs/smoke-all/typst/no-packages-project/.gitignore b/tests/docs/smoke-all/typst/no-packages-project/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/typst/no-packages-project/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/typst/pandas-cell-css-rules.qmd b/tests/docs/smoke-all/typst/pandas-cell-css-rules.qmd index ccf52d08534..6ccfcf0f6c5 100644 --- a/tests/docs/smoke-all/typst/pandas-cell-css-rules.qmd +++ b/tests/docs/smoke-all/typst/pandas-cell-css-rules.qmd @@ -16,8 +16,8 @@ _quarto: typst: ensureTypstFileRegexMatches: - [ - 'table.cell\(fill: rgb\("#ffc0cb"\)\)\[#set text\(fill: white\); 1.867558\]', - '\[#set text\(fill: rgb\("#ff413633"\)\); -0.151357\]' + 'table.cell\(fill: rgb\("#ffc0cb"\)\)\[#set text\(fill: white\);\s*1.867558\]', + '\[#set text\(fill: rgb\("#ff413633"\)\);\s*\\?-0.151357\]' ] - [] --- diff --git a/tests/docs/smoke-all/typst/relative-font-path/.gitignore b/tests/docs/smoke-all/typst/relative-font-path/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/smoke-all/typst/relative-font-path/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/smoke-all/typst/typst-bibliography-leading-dot.qmd b/tests/docs/smoke-all/typst/typst-bibliography-leading-dot.qmd new file mode 100644 index 00000000000..834829c226e --- /dev/null +++ b/tests/docs/smoke-all/typst/typst-bibliography-leading-dot.qmd @@ -0,0 +1,22 @@ +--- +title: "Typst bibliography with leading-dot path" +format: + typst: + keep-typ: true +bibliography: ./refs.bib +_quarto: + tests: + typst: + ensureTypstFileRegexMatches: + - ['\#bibliography\(\("\./refs\.bib"\)\)'] + - ['\\\.', '\\/'] + noErrors: default +--- + +Pandoc 3.10's Typst writer backslash-escapes any metadata string starting with a +literal dot (jgm/pandoc#11511, jgm/pandoc#11761). Quarto's `biblio.typ` partial +interpolates the raw `bibliography` metadata value into `#bibliography(...)`, so a +relative path with a leading dot (`./refs.bib`) previously compiled to invalid Typst +(`path must not contain a backslash`). This document exercises that path directly. + +See @Cronbach_1951 for a citation that must still resolve correctly. diff --git a/tests/smoke/lua-unit/lua-unit.test.ts b/tests/smoke/lua-unit/lua-unit.test.ts index 1553052f60f..5a4f61341b9 100644 --- a/tests/smoke/lua-unit/lua-unit.test.ts +++ b/tests/smoke/lua-unit/lua-unit.test.ts @@ -25,6 +25,8 @@ import { unitTest } from "../../test.ts"; // Explicit list, relative to tests/unit-lua/. Keep alphabetized. const LUA_TESTS: string[] = [ + "mediabag.test.lua", + "path.test.lua", "typst-css.test.lua", ]; diff --git a/tests/unit-lua/mediabag.test.lua b/tests/unit-lua/mediabag.test.lua new file mode 100644 index 00000000000..8a00d546c22 --- /dev/null +++ b/tests/unit-lua/mediabag.test.lua @@ -0,0 +1,57 @@ +-- Unit tests for src/resources/filters/modules/mediabag.lua +-- +-- Run via the Deno smoke test in tests/smoke/lua-unit/lua-unit.test.ts +-- (which sets LUA_PATH and invokes `quarto run`), or directly with: +-- LUA_PATH="tests/unit-lua/?.lua;src/resources/filters/modules/?.lua;;" \ +-- package/dist/bin/quarto run tests/unit-lua/mediabag.test.lua + +-- Mocks for filter-runtime globals ------------------------------------------- + +local param_values = {} +function param(name, default) + if param_values[name] ~= nil then return param_values[name] end + return default +end + +function warn(_msg) end + +_quarto = { + file = { + write = function(_path, _contents) return true end, + }, +} + +local lu = require('luaunit') +local mediabag = require('mediabag') + +-- Tests ---------------------------------------------------------------------- + +-- Regression test for Typst 0.15.0 hard-rejecting backslash path separators +-- in image() calls (quarto-cli-fpil). pandoc.path.join uses the host OS's +-- native separator, which is a backslash on Windows. Any path handed to +-- el.src must use forward slashes only, since it flows straight into +-- writers (e.g. Typst's image()) that may not accept native separators. +TestWriteMediabagEntry = {} + +function TestWriteMediabagEntry:setUp() + param_values = { ["mediabag-dir"] = "imgtest_files/mediabag" } + pandoc.mediabag.insert("png-base64,abc123.png", "image/png", "fake-bytes") +end + +function TestWriteMediabagEntry:tearDown() + pandoc.mediabag.empty() +end + +function TestWriteMediabagEntry:testResultHasNoBackslash() + local path = mediabag.write_mediabag_entry("png-base64,abc123.png") + lu.assertNotNil(path) + lu.assertNil(path:find('\\', 1, true), + 'write_mediabag_entry returned a path with a backslash: ' .. path) +end + +function TestWriteMediabagEntry:testResultJoinsDirAndFilename() + local path = mediabag.write_mediabag_entry("png-base64,abc123.png") + lu.assertEquals(path, "imgtest_files/mediabag/png-base64,abc123.png") +end + +os.exit(lu.LuaUnit.run()) diff --git a/tests/unit-lua/path.test.lua b/tests/unit-lua/path.test.lua new file mode 100644 index 00000000000..a71c1aeb7d8 --- /dev/null +++ b/tests/unit-lua/path.test.lua @@ -0,0 +1,40 @@ +-- Unit tests for src/resources/filters/modules/path.lua +-- +-- Run via the Deno smoke test in tests/smoke/lua-unit/lua-unit.test.ts +-- (which sets LUA_PATH and invokes `quarto run`), or directly with: +-- LUA_PATH="tests/unit-lua/?.lua;src/resources/filters/modules/?.lua;;" \ +-- package/dist/bin/quarto run tests/unit-lua/path.test.lua + +local lu = require('luaunit') +local path = require('path') + +-- Tests ---------------------------------------------------------------------- + +-- Regression test for Typst 0.15.0 hard-rejecting backslash path separators +-- (quarto-cli-ooky). Any path built with the host OS's native separator +-- (backslash on Windows) must be normalized to forward slashes before it can +-- flow into writers (e.g. Typst's image()) that reject native separators. +TestToForwardSlashes = {} + +function TestToForwardSlashes:testReplacesBackslashes() + lu.assertEquals( + path.to_forward_slashes("a\\b\\c.png"), + "a/b/c.png" + ) +end + +function TestToForwardSlashes:testLeavesForwardSlashesUnchanged() + lu.assertEquals( + path.to_forward_slashes("a/b/c.png"), + "a/b/c.png" + ) +end + +function TestToForwardSlashes:testMixedSeparators() + lu.assertEquals( + path.to_forward_slashes("../../..\\media/table.jpg"), + "../../../media/table.jpg" + ) +end + +os.exit(lu.LuaUnit.run())