diff --git a/Cargo.lock b/Cargo.lock index e9f19e9..9efb29a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -89,6 +89,21 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -169,6 +184,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -179,6 +206,66 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -194,6 +281,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "error-message-macros" version = "0.1.0" @@ -217,6 +314,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -295,6 +402,20 @@ dependencies = [ "hashbrown 0.16.0", ] +[[package]] +name = "insta" +version = "1.43.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fdb647ebde000f43b5b53f773c30cf9b0cb4300453208713fa38b2c70935a0" +dependencies = [ + "console", + "once_cell", + "pest", + "pest_derive", + "serde", + "similar", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -349,6 +470,21 @@ dependencies = [ "cc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.28" @@ -371,6 +507,18 @@ dependencies = [ "walkdir", ] +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -383,12 +531,78 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -432,9 +646,11 @@ version = "0.0.0" dependencies = [ "ariadne", "clap", + "crossterm", "error-message-macros", "glob", "hashlink", + "insta", "once_cell", "paste", "quarto-error-reporting", @@ -443,6 +659,7 @@ dependencies = [ "regex", "serde", "serde_json", + "supports-hyperlinks", "tree-sitter", "tree-sitter-qmd", "yaml-rust2", @@ -491,6 +708,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.2" @@ -520,6 +746,19 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -541,6 +780,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.228" @@ -585,12 +830,65 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "streaming-iterator" version = "0.1.9" @@ -603,6 +901,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-hyperlinks" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" + [[package]] name = "syn" version = "2.0.107" @@ -663,6 +967,18 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicode-ident" version = "1.0.20" @@ -681,6 +997,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "walkdir" version = "2.5.0" @@ -691,6 +1013,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -816,6 +1144,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -825,6 +1169,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/crates/quarto-markdown-pandoc/Cargo.toml b/crates/quarto-markdown-pandoc/Cargo.toml index 3933f8d..847d05f 100644 --- a/crates/quarto-markdown-pandoc/Cargo.toml +++ b/crates/quarto-markdown-pandoc/Cargo.toml @@ -13,6 +13,10 @@ repository.workspace = true [package.metadata] cargo-fuzz = true +[features] +default = ["terminal-hyperlinks"] +terminal-hyperlinks = ["dep:supports-hyperlinks"] + [dependencies] tree-sitter = { workspace = true } tree-sitter-qmd = { workspace = true } @@ -30,6 +34,8 @@ yaml-rust2 = { workspace = true } hashlink = { version = "0.10.0", features = ["serde_impl"] } error-message-macros = { path = "./error-message-macros" } ariadne = "0.4" +crossterm = "0.28" +supports-hyperlinks = { version = "3.0", optional = true } [dev-dependencies] insta = { version = "1.40", features = ["json", "redactions"] } diff --git a/crates/quarto-markdown-pandoc/snapshots/json/horizontal-rules-vs-metadata.snap b/crates/quarto-markdown-pandoc/snapshots/json/horizontal-rules-vs-metadata.snap new file mode 100644 index 0000000..dea71a5 --- /dev/null +++ b/crates/quarto-markdown-pandoc/snapshots/json/horizontal-rules-vs-metadata.snap @@ -0,0 +1,5 @@ +--- +source: crates/quarto-markdown-pandoc/tests/test.rs +expression: output +--- +{"astContext":{"files":[{"line_breaks":[3,24,44,48,49,83,84,88,89,129],"name":"tests/snapshots/json/horizontal-rules-vs-metadata.qmd","total_length":130}],"metaTopLevelKeySources":{"author":37,"title":35},"sourceInfoPool":[{"d":0,"r":[0,4],"t":0},{"d":0,"r":[4,5],"t":0},{"d":0,"r":[5,13],"t":0},{"d":0,"r":[0,49],"t":0},{"d":3,"r":[4,44],"t":1},{"d":4,"r":[7,20],"t":1},{"d":0,"r":[0,4],"t":0},{"d":0,"r":[4,5],"t":0},{"d":0,"r":[5,11],"t":0},{"d":3,"r":[4,44],"t":1},{"d":9,"r":[29,40],"t":1},{"d":0,"r":[50,57],"t":0},{"d":0,"r":[57,58],"t":0},{"d":0,"r":[58,67],"t":0},{"d":0,"r":[67,68],"t":0},{"d":0,"r":[68,73],"t":0},{"d":0,"r":[73,74],"t":0},{"d":0,"r":[74,82],"t":0},{"d":0,"r":[82,83],"t":0},{"d":[[17,0,8],[18,8,1]],"r":[0,9],"t":2},{"d":0,"r":[50,84],"t":0},{"d":0,"r":[85,89],"t":0},{"d":0,"r":[90,96],"t":0},{"d":0,"r":[96,97],"t":0},{"d":0,"r":[97,106],"t":0},{"d":0,"r":[106,107],"t":0},{"d":0,"r":[107,112],"t":0},{"d":0,"r":[112,113],"t":0},{"d":0,"r":[113,123],"t":0},{"d":0,"r":[123,124],"t":0},{"d":0,"r":[124,128],"t":0},{"d":0,"r":[128,129],"t":0},{"d":[[30,0,4],[31,4,1]],"r":[0,5],"t":2},{"d":0,"r":[90,130],"t":0},{"d":3,"r":[4,44],"t":1},{"d":34,"r":[0,5],"t":1},{"d":3,"r":[4,44],"t":1},{"d":36,"r":[21,27],"t":1}]},"blocks":[{"c":[{"c":"Content","s":11,"t":"Str"},{"s":12,"t":"Space"},{"c":"paragraph","s":13,"t":"Str"},{"s":14,"t":"Space"},{"c":"after","s":15,"t":"Str"},{"s":16,"t":"Space"},{"c":"metadata.","s":19,"t":"Str"}],"s":20,"t":"Para"},{"s":21,"t":"HorizontalRule"},{"c":[{"c":"Second","s":22,"t":"Str"},{"s":23,"t":"Space"},{"c":"paragraph","s":24,"t":"Str"},{"s":25,"t":"Space"},{"c":"after","s":26,"t":"Str"},{"s":27,"t":"Space"},{"c":"horizontal","s":28,"t":"Str"},{"s":29,"t":"Space"},{"c":"rule.","s":32,"t":"Str"}],"s":33,"t":"Para"}],"meta":{"author":{"c":[{"c":"Test","s":6,"t":"Str"},{"s":7,"t":"Space"},{"c":"Author","s":8,"t":"Str"}],"s":10,"t":"MetaInlines"},"title":{"c":[{"c":"Test","s":0,"t":"Str"},{"s":1,"t":"Space"},{"c":"Document","s":2,"t":"Str"}],"s":5,"t":"MetaInlines"}},"pandoc-api-version":[1,23,1]} diff --git a/crates/quarto-markdown-pandoc/snapshots/json/horizontal-rules.snap b/crates/quarto-markdown-pandoc/snapshots/json/horizontal-rules.snap new file mode 100644 index 0000000..49b97c9 --- /dev/null +++ b/crates/quarto-markdown-pandoc/snapshots/json/horizontal-rules.snap @@ -0,0 +1,5 @@ +--- +source: crates/quarto-markdown-pandoc/tests/test.rs +expression: output +--- +{"astContext":{"files":[{"line_breaks":[32,33,37,38,71,72,76,77,94],"name":"tests/snapshots/json/horizontal-rules.qmd","total_length":95}],"sourceInfoPool":[{"d":0,"r":[0,5],"t":0},{"d":0,"r":[5,6],"t":0},{"d":0,"r":[6,15],"t":0},{"d":0,"r":[15,16],"t":0},{"d":0,"r":[16,22],"t":0},{"d":0,"r":[22,23],"t":0},{"d":0,"r":[23,26],"t":0},{"d":0,"r":[26,27],"t":0},{"d":0,"r":[27,31],"t":0},{"d":0,"r":[31,32],"t":0},{"d":[[8,0,4],[9,4,1]],"r":[0,5],"t":2},{"d":0,"r":[0,33],"t":0},{"d":0,"r":[34,38],"t":0},{"d":0,"r":[39,45],"t":0},{"d":0,"r":[45,46],"t":0},{"d":0,"r":[46,55],"t":0},{"d":0,"r":[55,56],"t":0},{"d":0,"r":[56,61],"t":0},{"d":0,"r":[61,62],"t":0},{"d":0,"r":[62,65],"t":0},{"d":0,"r":[65,66],"t":0},{"d":0,"r":[66,70],"t":0},{"d":0,"r":[70,71],"t":0},{"d":[[21,0,4],[22,4,1]],"r":[0,5],"t":2},{"d":0,"r":[39,72],"t":0},{"d":0,"r":[73,77],"t":0},{"d":0,"r":[78,83],"t":0},{"d":0,"r":[83,84],"t":0},{"d":0,"r":[84,93],"t":0},{"d":0,"r":[93,94],"t":0},{"d":[[28,0,9],[29,9,1]],"r":[0,10],"t":2},{"d":0,"r":[78,95],"t":0}]},"blocks":[{"c":[{"c":"First","s":0,"t":"Str"},{"s":1,"t":"Space"},{"c":"paragraph","s":2,"t":"Str"},{"s":3,"t":"Space"},{"c":"before","s":4,"t":"Str"},{"s":5,"t":"Space"},{"c":"the","s":6,"t":"Str"},{"s":7,"t":"Space"},{"c":"rule.","s":10,"t":"Str"}],"s":11,"t":"Para"},{"s":12,"t":"HorizontalRule"},{"c":[{"c":"Second","s":13,"t":"Str"},{"s":14,"t":"Space"},{"c":"paragraph","s":15,"t":"Str"},{"s":16,"t":"Space"},{"c":"after","s":17,"t":"Str"},{"s":18,"t":"Space"},{"c":"the","s":19,"t":"Str"},{"s":20,"t":"Space"},{"c":"rule.","s":23,"t":"Str"}],"s":24,"t":"Para"},{"s":25,"t":"HorizontalRule"},{"c":[{"c":"Third","s":26,"t":"Str"},{"s":27,"t":"Space"},{"c":"paragraph.","s":30,"t":"Str"}],"s":31,"t":"Para"}],"meta":{},"pandoc-api-version":[1,23,1]} diff --git a/crates/quarto-markdown-pandoc/snapshots/native/horizontal-rules-vs-metadata.snap b/crates/quarto-markdown-pandoc/snapshots/native/horizontal-rules-vs-metadata.snap new file mode 100644 index 0000000..c9b7c79 --- /dev/null +++ b/crates/quarto-markdown-pandoc/snapshots/native/horizontal-rules-vs-metadata.snap @@ -0,0 +1,5 @@ +--- +source: crates/quarto-markdown-pandoc/tests/test.rs +expression: output +--- +[ Para [Str "Content", Space, Str "paragraph", Space, Str "after", Space, Str "metadata."], HorizontalRule, Para [Str "Second", Space, Str "paragraph", Space, Str "after", Space, Str "horizontal", Space, Str "rule."] ] diff --git a/crates/quarto-markdown-pandoc/snapshots/native/horizontal-rules.snap b/crates/quarto-markdown-pandoc/snapshots/native/horizontal-rules.snap new file mode 100644 index 0000000..ac0e57e --- /dev/null +++ b/crates/quarto-markdown-pandoc/snapshots/native/horizontal-rules.snap @@ -0,0 +1,5 @@ +--- +source: crates/quarto-markdown-pandoc/tests/test.rs +expression: output +--- +[ Para [Str "First", Space, Str "paragraph", Space, Str "before", Space, Str "the", Space, Str "rule."], HorizontalRule, Para [Str "Second", Space, Str "paragraph", Space, Str "after", Space, Str "the", Space, Str "rule."], HorizontalRule, Para [Str "Third", Space, Str "paragraph."] ] diff --git a/crates/quarto-markdown-pandoc/snapshots/qmd/horizontal-rules-vs-metadata.snap b/crates/quarto-markdown-pandoc/snapshots/qmd/horizontal-rules-vs-metadata.snap new file mode 100644 index 0000000..e5fff4b --- /dev/null +++ b/crates/quarto-markdown-pandoc/snapshots/qmd/horizontal-rules-vs-metadata.snap @@ -0,0 +1,14 @@ +--- +source: crates/quarto-markdown-pandoc/tests/test.rs +expression: output +--- +--- +title: Test Document +author: Test Author +--- + +Content paragraph after metadata. + +--- + +Second paragraph after horizontal rule. diff --git a/crates/quarto-markdown-pandoc/snapshots/qmd/horizontal-rules.snap b/crates/quarto-markdown-pandoc/snapshots/qmd/horizontal-rules.snap new file mode 100644 index 0000000..9f74253 --- /dev/null +++ b/crates/quarto-markdown-pandoc/snapshots/qmd/horizontal-rules.snap @@ -0,0 +1,13 @@ +--- +source: crates/quarto-markdown-pandoc/tests/test.rs +expression: output +--- +First paragraph before the rule. + +--- + +Second paragraph after the rule. + +--- + +Third paragraph. diff --git a/crates/quarto-markdown-pandoc/src/main.rs b/crates/quarto-markdown-pandoc/src/main.rs index f60e9c7..c2e69af 100644 --- a/crates/quarto-markdown-pandoc/src/main.rs +++ b/crates/quarto-markdown-pandoc/src/main.rs @@ -177,6 +177,7 @@ fn main() { "native" => writers::native::write(&pandoc, &mut buf), "markdown" | "qmd" => writers::qmd::write(&pandoc, &mut buf), "html" => writers::html::write(&pandoc, &mut buf), + "ansi" => writers::ansi::write(&pandoc, &mut buf), _ => { eprintln!("Unknown output format: {}", args.to); return; diff --git a/crates/quarto-markdown-pandoc/src/writers/ansi.rs b/crates/quarto-markdown-pandoc/src/writers/ansi.rs new file mode 100644 index 0000000..b940001 --- /dev/null +++ b/crates/quarto-markdown-pandoc/src/writers/ansi.rs @@ -0,0 +1,1385 @@ +/* + * ansi.rs + * Copyright (c) 2025 Posit, PBC + * + * ANSI terminal writer for Pandoc AST using crossterm for styling. + * + * Phase 1 implementation: Plain and Para blocks only. + * Other blocks panic with helpful messages indicating they need implementation. + */ + +use crate::pandoc::{ + Attr, Block, BlockQuote, BulletList, DefinitionList, Div, Inline, OrderedList, Pandoc, +}; +use crossterm::style::{Color, Stylize}; +use std::io::Write; + +#[cfg(feature = "terminal-hyperlinks")] +use supports_hyperlinks; + +/// Tracks the spacing behavior of the last block written +#[derive(Clone, Copy, PartialEq, Debug)] +enum LastBlockSpacing { + None, // Nothing written yet + Plain, // Plain block (ends with single \n) + Paragraph, // Para/Div/etc (ends with blank line) +} + +/// Tracks the current style context for nested styled elements +/// This allows us to restore parent colors after child styled elements complete +#[derive(Debug, Clone)] +struct StyleContext { + fg_stack: Vec>, + bg_stack: Vec>, +} + +impl StyleContext { + fn new() -> Self { + Self { + fg_stack: vec![None], // Start with default (no color) + bg_stack: vec![None], // Start with default (no color) + } + } + + fn push_fg(&mut self, color: Option) { + self.fg_stack.push(color); + } + + fn pop_fg(&mut self) { + if self.fg_stack.len() > 1 { + self.fg_stack.pop(); + } + } + + fn current_fg(&self) -> Option { + self.fg_stack.last().copied().flatten() + } + + fn push_bg(&mut self, color: Option) { + self.bg_stack.push(color); + } + + fn pop_bg(&mut self) { + if self.bg_stack.len() > 1 { + self.bg_stack.pop(); + } + } + + fn current_bg(&self) -> Option { + self.bg_stack.last().copied().flatten() + } + + /// Write ANSI codes to restore the current style context + fn restore_current_style(&self, buf: &mut W) -> std::io::Result<()> { + // Restore background first, then foreground (order matters for some terminals) + if let Some(bg) = self.current_bg() { + write!(buf, "\x1b[{}m", color_to_ansi_bg(bg))?; + } else { + write!(buf, "\x1b[49m")?; // Reset to default background + } + + if let Some(fg) = self.current_fg() { + write!(buf, "\x1b[{}m", color_to_ansi_fg(fg))?; + } else { + write!(buf, "\x1b[39m")?; // Reset to default foreground + } + + Ok(()) + } +} + +/// Convert a Color to ANSI foreground code +fn color_to_ansi_fg(color: Color) -> String { + match color { + Color::Black => "30".to_string(), + Color::DarkGrey => "90".to_string(), + Color::Red => "91".to_string(), + Color::DarkRed => "31".to_string(), + Color::Green => "92".to_string(), + Color::DarkGreen => "32".to_string(), + Color::Yellow => "93".to_string(), + Color::DarkYellow => "33".to_string(), + Color::Blue => "94".to_string(), + Color::DarkBlue => "34".to_string(), + Color::Magenta => "95".to_string(), + Color::DarkMagenta => "35".to_string(), + Color::Cyan => "96".to_string(), + Color::DarkCyan => "36".to_string(), + Color::White => "97".to_string(), + Color::Grey => "37".to_string(), + Color::Rgb { r, g, b } => format!("38;2;{};{};{}", r, g, b), + Color::AnsiValue(v) => format!("38;5;{}", v), + Color::Reset => "39".to_string(), + } +} + +/// Convert a Color to ANSI background code +fn color_to_ansi_bg(color: Color) -> String { + match color { + Color::Black => "40".to_string(), + Color::DarkGrey => "100".to_string(), + Color::Red => "101".to_string(), + Color::DarkRed => "41".to_string(), + Color::Green => "102".to_string(), + Color::DarkGreen => "42".to_string(), + Color::Yellow => "103".to_string(), + Color::DarkYellow => "43".to_string(), + Color::Blue => "104".to_string(), + Color::DarkBlue => "44".to_string(), + Color::Magenta => "105".to_string(), + Color::DarkMagenta => "45".to_string(), + Color::Cyan => "106".to_string(), + Color::DarkCyan => "46".to_string(), + Color::White => "107".to_string(), + Color::Grey => "47".to_string(), + Color::Rgb { r, g, b } => format!("48;2;{};{};{}", r, g, b), + Color::AnsiValue(v) => format!("48;5;{}", v), + Color::Reset => "49".to_string(), + } +} + +/// Configuration for ANSI writer +#[derive(Debug, Clone)] +pub struct AnsiConfig { + /// Enable colors and styling + pub colors: bool, + /// Terminal width for wrapping (0 = no wrapping) + pub width: usize, + /// Indent size for nested structures + pub indent: usize, + /// Enable clickable hyperlinks (OSC 8) + pub hyperlinks: bool, +} + +impl Default for AnsiConfig { + fn default() -> Self { + Self { + colors: true, + width: Self::detect_terminal_width(), + indent: 2, + hyperlinks: Self::detect_hyperlink_support(), + } + } +} + +impl AnsiConfig { + /// Detect terminal width, defaulting to 80 if detection fails + /// Can be overridden with QUARTO_TERMINAL_WIDTH environment variable + fn detect_terminal_width() -> usize { + // Check for environment variable override first + if let Ok(width_str) = std::env::var("QUARTO_TERMINAL_WIDTH") { + if let Ok(width) = width_str.parse::() { + return width; + } + } + + // Otherwise detect from terminal + use crossterm::terminal::size; + size().ok().map(|(cols, _rows)| cols as usize).unwrap_or(80) + } + + /// Detect if the terminal supports hyperlinks + fn detect_hyperlink_support() -> bool { + #[cfg(feature = "terminal-hyperlinks")] + { + supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout) + } + #[cfg(not(feature = "terminal-hyperlinks"))] + { + false + } + } +} + +/// Context for writing bullet list items with proper indentation and markers +struct BulletListContext<'a, W: Write + ?Sized> { + inner: &'a mut W, + at_line_start: bool, + is_first_line: bool, + bullet: &'static str, + config: &'a AnsiConfig, +} + +impl<'a, W: Write + ?Sized> BulletListContext<'a, W> { + fn new(inner: &'a mut W, depth: usize, config: &'a AnsiConfig) -> Self { + let bullet = match depth % 3 { + 0 => "* ", + 1 => "- ", + 2 => "+ ", + _ => unreachable!(), + }; + Self { + inner, + at_line_start: true, + is_first_line: true, + bullet, + config, + } + } +} + +impl<'a, W: Write + ?Sized> Write for BulletListContext<'a, W> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut written = 0; + for &byte in buf { + if self.at_line_start { + if self.is_first_line { + // Write bullet marker in muted color (dark grey) + if self.config.colors { + write!(self.inner, "{}", self.bullet.to_string().dark_grey())?; + } else { + self.inner.write_all(self.bullet.as_bytes())?; + } + self.is_first_line = false; + } else { + self.inner.write_all(b" ")?; // 3 spaces for continuation + } + self.at_line_start = false; + } + self.inner.write_all(&[byte])?; + written += 1; + if byte == b'\n' { + self.at_line_start = true; + } + } + Ok(written) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +/// Context for writing ordered list items with calculated indentation +struct OrderedListContext<'a, W: Write + ?Sized> { + inner: &'a mut W, + at_line_start: bool, + is_first_line: bool, + item_num_str: String, + continuation_indent: String, + config: &'a AnsiConfig, +} + +impl<'a, W: Write + ?Sized> OrderedListContext<'a, W> { + fn new(inner: &'a mut W, item_num: usize, indent_width: usize, config: &'a AnsiConfig) -> Self { + let item_num_str = format!("{}. ", item_num); + let continuation_indent = " ".repeat(indent_width.max(item_num_str.len())); + Self { + inner, + at_line_start: true, + is_first_line: true, + item_num_str, + continuation_indent, + config, + } + } +} + +impl<'a, W: Write + ?Sized> Write for OrderedListContext<'a, W> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut written = 0; + for &byte in buf { + if self.at_line_start { + if self.is_first_line { + // Write number marker in muted color (dark grey) + if self.config.colors { + write!(self.inner, "{}", self.item_num_str.as_str().dark_grey())?; + } else { + self.inner.write_all(self.item_num_str.as_bytes())?; + } + self.is_first_line = false; + } else { + self.inner.write_all(self.continuation_indent.as_bytes())?; + } + self.at_line_start = false; + } + self.inner.write_all(&[byte])?; + written += 1; + if byte == b'\n' { + self.at_line_start = true; + } + } + Ok(written) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +/// Context for writing block quotes with vertical line marker +struct BlockQuoteContext<'a, W: Write + ?Sized> { + inner: &'a mut W, + at_line_start: bool, + config: &'a AnsiConfig, +} + +impl<'a, W: Write + ?Sized> BlockQuoteContext<'a, W> { + fn new(inner: &'a mut W, config: &'a AnsiConfig) -> Self { + Self { + inner, + at_line_start: true, + config, + } + } +} + +impl<'a, W: Write + ?Sized> Write for BlockQuoteContext<'a, W> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut written = 0; + for &byte in buf { + if self.at_line_start { + // Write vertical line marker in muted color (dark grey) + if self.config.colors { + write!(self.inner, "{}", "│ ".dark_grey())?; + } else { + self.inner.write_all(b"> ")?; // Fallback to > for no-color + } + self.at_line_start = false; + } + self.inner.write_all(&[byte])?; + written += 1; + if byte == b'\n' { + self.at_line_start = true; + } + } + Ok(written) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +/// Context for writing divs with line-by-line color styling +struct DivContext<'a, W: Write + ?Sized> { + inner: &'a mut W, + fg_color: Option, + bg_color: Option, + line_buffer: Vec, + config: &'a AnsiConfig, +} + +impl<'a, W: Write + ?Sized> DivContext<'a, W> { + fn new(inner: &'a mut W, fg: Option, bg: Option, config: &'a AnsiConfig) -> Self { + Self { + inner, + fg_color: fg, + bg_color: bg, + line_buffer: Vec::new(), + config, + } + } + + fn flush_line(&mut self) -> std::io::Result<()> { + if self.line_buffer.is_empty() { + return Ok(()); + } + + if self.config.colors && (self.fg_color.is_some() || self.bg_color.is_some()) { + let line = String::from_utf8(self.line_buffer.clone()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + + let styled = match (self.fg_color, self.bg_color) { + (Some(fg), Some(bg)) => line.with(fg).on(bg), + (Some(fg), None) => line.with(fg), + (None, Some(bg)) => line.on(bg), + (None, None) => unreachable!(), + }; + + write!(self.inner, "{}", styled)?; + } else { + self.inner.write_all(&self.line_buffer)?; + } + + self.line_buffer.clear(); + Ok(()) + } +} + +impl<'a, W: Write + ?Sized> Write for DivContext<'a, W> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let mut written = 0; + for &byte in buf { + if byte == b'\n' { + self.flush_line()?; + self.inner.write_all(&[b'\n'])?; + } else { + self.line_buffer.push(byte); + } + written += 1; + } + Ok(written) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.flush_line()?; + self.inner.flush() + } +} + +impl<'a, W: Write + ?Sized> Drop for DivContext<'a, W> { + fn drop(&mut self) { + let _ = self.flush_line(); + } +} + +/// Calculate indent width for ordered lists based on maximum item number +fn calculate_indent_width(start: usize, count: usize) -> usize { + if count == 0 { + return 3; // Minimum "1. " + } + let max_num = start + count - 1; + let max_digits = if max_num == 0 { + 1 + } else { + (max_num as f64).log10().floor() as usize + 1 + }; + max_digits + 2 // digits + ". " +} + +/// Write Pandoc AST to ANSI terminal output +/// +/// This writer uses crossterm to render styled text. Currently supports: +/// - Plain and Para blocks (with minimal inline rendering) +/// +/// Other block types will panic with a message indicating they need implementation. +pub fn write(pandoc: &Pandoc, buf: &mut T) -> std::io::Result<()> { + write_with_config(pandoc, buf, &AnsiConfig::default()) +} + +/// Write Pandoc AST with custom configuration +pub fn write_with_config( + pandoc: &Pandoc, + buf: &mut T, + config: &AnsiConfig, +) -> std::io::Result<()> { + let mut last_spacing = LastBlockSpacing::None; + + for block in pandoc.blocks.iter() { + // Determine if we need a blank line before this block + let needs_blank = match (&last_spacing, block) { + (LastBlockSpacing::Plain, Block::Plain(_)) => false, // Consecutive Plains: single \n + (LastBlockSpacing::None, _) => false, // First block + _ => true, // All other cases: blank line + }; + + if needs_blank { + writeln!(buf)?; // Extra \n for blank line + } + + last_spacing = write_block_with_depth(block, buf, config, 0, 0)?; + } + Ok(()) +} + +fn write_block_with_depth( + block: &Block, + buf: &mut dyn Write, + config: &AnsiConfig, + list_depth: usize, + indent_chars: usize, // Total character indentation from all contexts +) -> std::io::Result { + let mut style_ctx = StyleContext::new(); + match block { + Block::Plain(plain) => { + write_inlines(&plain.content, buf, config, &mut style_ctx)?; + writeln!(buf)?; + Ok(LastBlockSpacing::Plain) + } + Block::Paragraph(para) => { + write_inlines(¶.content, buf, config, &mut style_ctx)?; + writeln!(buf)?; + Ok(LastBlockSpacing::Paragraph) + } + Block::Div(div) => write_div(div, buf, config, list_depth, indent_chars), + Block::BulletList(list) => write_bulletlist(list, buf, config, list_depth, indent_chars), + Block::OrderedList(list) => write_orderedlist(list, buf, config, list_depth, indent_chars), + Block::DefinitionList(list) => { + write_definitionlist(list, buf, config, list_depth, indent_chars) + } + Block::BlockQuote(bq) => write_blockquote(bq, buf, config, list_depth, indent_chars), + + // All other blocks panic with helpful messages + Block::LineBlock(_) => { + panic!( + "LineBlock not yet implemented in ANSI writer. Please add support in src/writers/ansi.rs" + ); + } + Block::CodeBlock(_) => { + panic!( + "CodeBlock not yet implemented in ANSI writer. Please add support in src/writers/ansi.rs" + ); + } + Block::RawBlock(raw) => { + // Only render raw content if format matches "ansi" + if raw.format == "ansi" { + // Write content directly with single newline (Plain semantics) + write!(buf, "{}", raw.text)?; + writeln!(buf)?; + Ok(LastBlockSpacing::Plain) + } else { + // Skip blocks with wrong format - return None to indicate nothing written + Ok(LastBlockSpacing::None) + } + } + Block::Header(header) => { + // Format the header content with styling + let content = format_inlines(&header.content, config, &mut style_ctx); + + // Apply level-specific styling + let styled_content = if config.colors { + match header.level { + 1 => { + // H1: bright (Color::White) and bold, centered + let text = content.white().bold().to_string(); + let text_width = display_width(&text); + let available_width = if config.width > indent_chars { + config.width - indent_chars + } else { + config.width + }; + + // Calculate left padding for centering + let left_padding = if available_width > text_width { + (available_width - text_width) / 2 + } else { + 0 + }; + + format!("{}{}", " ".repeat(left_padding), text) + } + 2 => { + // H2: bright and bold + content.white().bold().to_string() + } + 3 => { + // H3: bright + content.white().to_string() + } + 4 => { + // H4: muted + content.dark_grey().to_string() + } + _ => { + // H5, H6: default + content + } + } + } else { + content + }; + + writeln!(buf, "{}", styled_content)?; + Ok(LastBlockSpacing::Paragraph) + } + Block::HorizontalRule(_) => { + // Calculate effective width accounting for indentation + let line_width = if config.width > indent_chars { + config.width - indent_chars + } else { + 10 // Minimum width if indentation is too large + }; + + let line = "─".repeat(line_width); + if config.colors { + writeln!(buf, "{}", line.dark_grey())?; + } else { + writeln!(buf, "{}", line)?; + } + Ok(LastBlockSpacing::Paragraph) + } + Block::Table(_) => { + panic!( + "Table not yet implemented in ANSI writer. Please add support in src/writers/ansi.rs" + ); + } + Block::Figure(_) => { + panic!( + "Figure not yet implemented in ANSI writer. Please add support in src/writers/ansi.rs" + ); + } + Block::BlockMetadata(_) => { + panic!( + "BlockMetadata not yet implemented in ANSI writer. Please add support in src/writers/ansi.rs" + ); + } + Block::NoteDefinitionPara(_) => { + panic!( + "NoteDefinitionPara not yet implemented in ANSI writer. Please add support in src/writers/ansi.rs" + ); + } + Block::NoteDefinitionFencedBlock(_) => { + panic!( + "NoteDefinitionFencedBlock not yet implemented in ANSI writer. Please add support in src/writers/ansi.rs" + ); + } + Block::CaptionBlock(_) => { + panic!( + "CaptionBlock not yet implemented in ANSI writer. Please add support in src/writers/ansi.rs" + ); + } + } +} + +/// Write a Div block with optional color styling +fn write_div( + div: &Div, + buf: &mut dyn Write, + config: &AnsiConfig, + list_depth: usize, + indent_chars: usize, +) -> std::io::Result { + let fg_color = parse_color_attr(&div.attr, "color"); + let bg_color = parse_color_attr(&div.attr, "background-color"); + + // If colors are present, use DivContext for line-by-line styling + if fg_color.is_some() || bg_color.is_some() { + let mut ctx = DivContext::new(buf, fg_color, bg_color, config); + for block in &div.content { + write_block_with_depth(block, &mut ctx, config, list_depth, indent_chars)?; + } + ctx.flush()?; + } else { + // No colors, write blocks directly + for block in &div.content { + write_block_with_depth(block, buf, config, list_depth, indent_chars)?; + } + } + + Ok(LastBlockSpacing::Paragraph) +} + +/// Write a bullet list +fn write_bulletlist( + list: &BulletList, + buf: &mut dyn Write, + config: &AnsiConfig, + list_depth: usize, + indent_chars: usize, +) -> std::io::Result { + // Determine if list is tight (all first blocks are Plain) or loose + let is_tight = list + .content + .iter() + .all(|item| !item.is_empty() && matches!(item[0], Block::Plain(_))); + + for (i, item) in list.content.iter().enumerate() { + // Add blank line between items in loose lists + if i > 0 && !is_tight { + writeln!(buf)?; + } + + // Create context for this item (adds 3 characters: "*, ", "- ", or "+ ") + let mut ctx = BulletListContext::new(buf, list_depth, config); + + // Write all blocks in item through the context + for block in item { + write_block_with_depth(block, &mut ctx, config, list_depth + 1, indent_chars + 3)?; + } + + ctx.flush()?; + } + + Ok(LastBlockSpacing::Paragraph) +} + +/// Write an ordered list +fn write_orderedlist( + list: &OrderedList, + buf: &mut dyn Write, + config: &AnsiConfig, + list_depth: usize, + indent_chars: usize, +) -> std::io::Result { + // ListAttributes is (start_number, number_style, number_delim) + let start_number = list.attr.0; + + // Calculate indent width based on maximum item number + let indent_width = calculate_indent_width(start_number, list.content.len()); + + // Determine if list is tight (all first blocks are Plain) or loose + let is_tight = list + .content + .iter() + .all(|item| !item.is_empty() && matches!(item[0], Block::Plain(_))); + + for (i, item) in list.content.iter().enumerate() { + // Add blank line between items in loose lists + if i > 0 && !is_tight { + writeln!(buf)?; + } + + let item_num = start_number + i; + let mut ctx = OrderedListContext::new(buf, item_num, indent_width, config); + + // Write all blocks in item through the context + for block in item { + write_block_with_depth( + block, + &mut ctx, + config, + list_depth + 1, + indent_chars + indent_width, + )?; + } + + ctx.flush()?; + } + + Ok(LastBlockSpacing::Paragraph) +} + +/// Write a definition list +fn write_definitionlist( + list: &DefinitionList, + buf: &mut dyn Write, + config: &AnsiConfig, + list_depth: usize, + indent_chars: usize, +) -> std::io::Result { + let mut style_ctx = StyleContext::new(); + + for (i, (term, definitions)) in list.content.iter().enumerate() { + // Add blank line between definition list items (except before first) + if i > 0 { + writeln!(buf)?; + } + + // Write the term in bold + if config.colors { + let term_str = format_inlines(term, config, &mut style_ctx); + write!(buf, "{}", term_str.bold())?; + } else { + write_inlines(term, buf, config, &mut style_ctx)?; + } + writeln!(buf)?; + + // Write each definition with ": " prefix + for definition in definitions { + // Write the colon in muted color (dark grey) + if config.colors { + write!(buf, "{}", ":".dark_grey())?; + } else { + write!(buf, ":")?; + } + write!(buf, " ")?; + + // Write the definition blocks with proper spacing between them + let mut last_spacing = LastBlockSpacing::None; + for (j, block) in definition.iter().enumerate() { + // Write the block to a buffer first so we can control indentation + let mut def_buf = Vec::new(); + let block_spacing = write_block_with_depth( + block, + &mut def_buf, + config, + list_depth + 1, + indent_chars + 2, + )?; + + if j > 0 { + // Determine if we need a blank line before this block + let needs_blank = match (&last_spacing, block) { + (LastBlockSpacing::Plain, Block::Plain(_)) => false, + (LastBlockSpacing::None, _) => false, + _ => true, + }; + + if needs_blank { + writeln!(buf)?; // Extra newline for blank line + } + write!(buf, " ")?; // Indent continuation + } + + // Write the definition content, trimming trailing newline since we control spacing + let content = String::from_utf8_lossy(&def_buf); + let trimmed = content.trim_end(); + write!(buf, "{}", trimmed)?; + writeln!(buf)?; // Write the natural newline for this block + + last_spacing = block_spacing; + } + } + } + + Ok(LastBlockSpacing::Paragraph) +} + +/// Write a block quote with vertical line marker +fn write_blockquote( + blockquote: &BlockQuote, + buf: &mut dyn Write, + config: &AnsiConfig, + list_depth: usize, + indent_chars: usize, +) -> std::io::Result { + let mut ctx = BlockQuoteContext::new(buf, config); + let mut last_spacing = LastBlockSpacing::None; + + for (i, block) in blockquote.content.iter().enumerate() { + // Determine if we need a blank line before this block + if i > 0 { + let needs_blank = match (&last_spacing, block) { + (LastBlockSpacing::Plain, Block::Plain(_)) => false, + (LastBlockSpacing::None, _) => false, + _ => true, + }; + + if needs_blank { + writeln!(&mut ctx)?; // Extra newline for blank line + } + } + + // BlockQuoteContext adds 2 characters: "│ " + last_spacing = + write_block_with_depth(block, &mut ctx, config, list_depth, indent_chars + 2)?; + } + + ctx.flush()?; + Ok(LastBlockSpacing::Paragraph) +} + +fn write_inlines( + inlines: &[Inline], + buf: &mut T, + config: &AnsiConfig, + style_ctx: &mut StyleContext, +) -> std::io::Result<()> { + for inline in inlines { + write_inline(inline, buf, config, style_ctx)?; + } + Ok(()) +} + +/// Parse a color attribute from span attributes +fn parse_color_attr(attr: &Attr, attr_name: &str) -> Option { + let (_, _, attrs) = attr; + + for (key, value) in attrs { + if key == attr_name { + return parse_color_value(value); + } + } + None +} + +/// Parse a color value from various formats +fn parse_color_value(value: &str) -> Option { + let value = value.trim(); + + // Named basic colors (case-insensitive) + match value.to_lowercase().as_str() { + "black" => return Some(Color::Black), + "dark-grey" | "darkgrey" | "dark-gray" | "darkgray" => return Some(Color::DarkGrey), + "red" => return Some(Color::Red), + "dark-red" | "darkred" => return Some(Color::DarkRed), + "green" => return Some(Color::Green), + "dark-green" | "darkgreen" => return Some(Color::DarkGreen), + "yellow" => return Some(Color::Yellow), + "dark-yellow" | "darkyellow" => return Some(Color::DarkYellow), + "blue" => return Some(Color::Blue), + "dark-blue" | "darkblue" => return Some(Color::DarkBlue), + "magenta" => return Some(Color::Magenta), + "dark-magenta" | "darkmagenta" => return Some(Color::DarkMagenta), + "cyan" => return Some(Color::Cyan), + "dark-cyan" | "darkcyan" => return Some(Color::DarkCyan), + "white" => return Some(Color::White), + "grey" | "gray" => return Some(Color::Grey), + "reset" => return Some(Color::Reset), + _ => {} + } + + // Hex colors: #RRGGBB or #RGB + if value.starts_with('#') { + if value.len() == 7 { + // #RRGGBB format + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&value[1..3], 16), + u8::from_str_radix(&value[3..5], 16), + u8::from_str_radix(&value[5..7], 16), + ) { + return Some(Color::Rgb { r, g, b }); + } + } else if value.len() == 4 { + // #RGB format - expand to #RRGGBB + if let (Ok(r), Ok(g), Ok(b)) = ( + u8::from_str_radix(&value[1..2], 16), + u8::from_str_radix(&value[2..3], 16), + u8::from_str_radix(&value[3..4], 16), + ) { + return Some(Color::Rgb { + r: r * 17, // 0xF -> 0xFF + g: g * 17, + b: b * 17, + }); + } + } + } + + // RGB function: rgb(255, 128, 0) or rgb(255,128,0) + if value.starts_with("rgb(") && value.ends_with(')') { + let inner = &value[4..value.len() - 1]; + let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect(); + if parts.len() == 3 { + if let (Ok(r), Ok(g), Ok(b)) = ( + parts[0].parse::(), + parts[1].parse::(), + parts[2].parse::(), + ) { + return Some(Color::Rgb { r, g, b }); + } + } + } + + // ANSI palette: ansi(42) or ansi-42 + if value.starts_with("ansi(") && value.ends_with(')') { + let num_str = &value[5..value.len() - 1]; + if let Ok(ansi_value) = num_str.parse::() { + return Some(Color::AnsiValue(ansi_value)); + } + } else if value.starts_with("ansi-") { + let num_str = &value[5..]; + if let Ok(ansi_value) = num_str.parse::() { + return Some(Color::AnsiValue(ansi_value)); + } + } + + None +} + +/// Calculate the display width of a string by stripping ANSI escape sequences +/// This is useful for centering and alignment calculations +fn display_width(s: &str) -> usize { + let mut width = 0; + let mut chars = s.chars().peekable(); + + while let Some(ch) = chars.next() { + if ch == '\x1b' { + // Start of an ANSI escape sequence + // Skip until we find the final character (letter) + while let Some(&next_ch) = chars.peek() { + chars.next(); + if next_ch.is_ascii_alphabetic() { + break; + } + } + } else { + // Regular character - count it + width += 1; + } + } + + width +} + +fn write_inline( + inline: &Inline, + buf: &mut T, + config: &AnsiConfig, + style_ctx: &mut StyleContext, +) -> std::io::Result<()> { + match inline { + // Basic text elements + Inline::Str(s) => { + write!(buf, "{}", s.text)?; + } + Inline::Space(_) => { + write!(buf, " ")?; + } + Inline::SoftBreak(_) => { + write!(buf, "\n")?; + } + Inline::LineBreak(_) => { + write!(buf, "\n")?; + } + + // Styled text + Inline::Emph(emph) => { + if config.colors { + write!( + buf, + "{}", + format_inlines(&emph.content, config, style_ctx).italic() + )?; + // Restore parent colors after italic styling + style_ctx.restore_current_style(buf)?; + } else { + write_inlines(&emph.content, buf, config, style_ctx)?; + } + } + Inline::Underline(underline) => { + if config.colors { + write!( + buf, + "{}", + format_inlines(&underline.content, config, style_ctx).underlined() + )?; + // Restore parent colors after underline styling + style_ctx.restore_current_style(buf)?; + } else { + write_inlines(&underline.content, buf, config, style_ctx)?; + } + } + Inline::Strong(strong) => { + if config.colors { + write!( + buf, + "{}", + format_inlines(&strong.content, config, style_ctx).bold() + )?; + // Restore parent colors after bold styling + style_ctx.restore_current_style(buf)?; + } else { + write_inlines(&strong.content, buf, config, style_ctx)?; + } + } + Inline::Strikeout(strikeout) => { + if config.colors { + write!( + buf, + "{}", + format_inlines(&strikeout.content, config, style_ctx).crossed_out() + )?; + // Restore parent colors after strikeout styling + style_ctx.restore_current_style(buf)?; + } else { + write_inlines(&strikeout.content, buf, config, style_ctx)?; + } + } + Inline::Superscript(superscript) => { + // No direct superscript support in terminal, just render content + write!(buf, "^")?; + write_inlines(&superscript.content, buf, config, style_ctx)?; + } + Inline::Subscript(subscript) => { + // No direct subscript support in terminal, just render content + write!(buf, "_")?; + write_inlines(&subscript.content, buf, config, style_ctx)?; + } + Inline::SmallCaps(smallcaps) => { + // No small caps in terminal, just render as-is + write_inlines(&smallcaps.content, buf, config, style_ctx)?; + } + Inline::Quoted(quoted) => { + use crate::pandoc::QuoteType; + let (open, close) = match quoted.quote_type { + QuoteType::SingleQuote => ("'", "'"), + QuoteType::DoubleQuote => ("\"", "\""), + }; + write!(buf, "{}", open)?; + write_inlines("ed.content, buf, config, style_ctx)?; + write!(buf, "{}", close)?; + } + Inline::Cite(cite) => { + // Render citations as plain text for now + write_inlines(&cite.content, buf, config, style_ctx)?; + } + Inline::Code(code) => { + if config.colors { + write!(buf, "{}", code.text.as_str().on_dark_grey().white())?; + // Restore parent colors after code styling + style_ctx.restore_current_style(buf)?; + } else { + write!(buf, "`{}`", code.text)?; + } + } + Inline::Math(math) => { + if config.colors { + write!(buf, "{}", math.text.as_str().yellow())?; + // Restore parent colors after math styling + style_ctx.restore_current_style(buf)?; + } else { + write!(buf, "${}", math.text)?; + } + } + Inline::RawInline(raw) => { + // Only render raw content if format matches "ansi" + if raw.format == "ansi" { + write!(buf, "{}", raw.text)?; + } + // Otherwise skip - wrong format for this writer + } + Inline::Link(link) => { + let url = &link.target.0; + let link_text = format_inlines(&link.content, config, style_ctx); + + if config.hyperlinks && config.colors { + // OSC 8 hyperlink: \x1b]8;;URL\x1b\\TEXT\x1b]8;;\x1b\\ + write!(buf, "\x1b]8;;{}\x1b\\", url)?; + write!(buf, "{}", link_text.cyan().underlined())?; + write!(buf, "\x1b]8;;\x1b\\")?; + // Restore parent colors after link styling + style_ctx.restore_current_style(buf)?; + } else if config.colors { + // Styled but not clickable + write!(buf, "{}", link_text.cyan().underlined())?; + // Restore parent colors after link styling + style_ctx.restore_current_style(buf)?; + } else { + // No colors - show URL in parentheses + write_inlines(&link.content, buf, config, style_ctx)?; + write!(buf, " ({})", url)?; + } + } + Inline::Image(image) => { + // Render images as [Image: alt text] + write!(buf, "[Image: ")?; + write_inlines(&image.content, buf, config, style_ctx)?; + write!(buf, "]")?; + } + Inline::Note(note) => { + // Render footnotes as superscript marker for now + write!(buf, "^[{}]", note.content.len())?; + } + Inline::Span(span) => { + // Check for color attributes + let fg_color = parse_color_attr(&span.attr, "color"); + let bg_color = parse_color_attr(&span.attr, "background-color"); + + // Apply colors if enabled and present + if config.colors && (fg_color.is_some() || bg_color.is_some()) { + // Push colors onto stack + style_ctx.push_fg(fg_color); + style_ctx.push_bg(bg_color); + + // Apply the new colors + let content_str = format_inlines(&span.content, config, style_ctx); + + let styled = match (fg_color, bg_color) { + (Some(fg), Some(bg)) => content_str.with(fg).on(bg), + (Some(fg), None) => content_str.with(fg), + (None, Some(bg)) => content_str.on(bg), + (None, None) => unreachable!(), // We checked above + }; + + write!(buf, "{}", styled)?; + + // Pop colors from stack and restore parent + style_ctx.pop_bg(); + style_ctx.pop_fg(); + style_ctx.restore_current_style(buf)?; + } else { + // No color support or no color attrs, just render content + write_inlines(&span.content, buf, config, style_ctx)?; + } + } + + // Quarto extensions - minimal handling for now + Inline::Shortcode(_) => { + // Ignore shortcodes in ANSI output + } + Inline::NoteReference(_) => { + // Ignore note references + } + Inline::Attr(_, _) => { + // Ignore standalone attributes + } + Inline::Insert(insert) => { + // Render inserts as plain text + write_inlines(&insert.content, buf, config, style_ctx)?; + } + Inline::Delete(delete) => { + // Render deletes as strikethrough if colors enabled + if config.colors { + write!( + buf, + "{}", + format_inlines(&delete.content, config, style_ctx).crossed_out() + )?; + // Restore parent colors after strikeout styling + style_ctx.restore_current_style(buf)?; + } else { + write_inlines(&delete.content, buf, config, style_ctx)?; + } + } + Inline::Highlight(highlight) => { + // Render highlights with background color if enabled + if config.colors { + write!( + buf, + "{}", + format_inlines(&highlight.content, config, style_ctx) + .on_yellow() + .black() + )?; + // Restore parent colors after highlight styling + style_ctx.restore_current_style(buf)?; + } else { + write_inlines(&highlight.content, buf, config, style_ctx)?; + } + } + Inline::EditComment(_) => { + // Ignore edit comments in output + } + } + Ok(()) +} + +/// Helper to format inlines to a string for styling +fn format_inlines(inlines: &[Inline], config: &AnsiConfig, style_ctx: &mut StyleContext) -> String { + let mut buf = Vec::new(); + write_inlines(inlines, &mut buf, config, style_ctx).unwrap(); + String::from_utf8(buf).unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_defaults() { + let config = AnsiConfig::default(); + assert_eq!(config.colors, true); + // Width should match what detect_terminal_width returns + let expected_width = AnsiConfig::detect_terminal_width(); + assert_eq!(config.width, expected_width); + assert_eq!(config.indent, 2); + } + + #[test] + fn test_empty_document() { + let pandoc = Pandoc { + meta: Default::default(), + blocks: vec![], + }; + + let mut buf = Vec::new(); + write(&pandoc, &mut buf).unwrap(); + let output = String::from_utf8(buf).unwrap(); + + assert_eq!(output, ""); + } + + // Color parsing tests + #[test] + fn test_parse_basic_colors() { + assert!(matches!(parse_color_value("red"), Some(Color::Red))); + assert!(matches!(parse_color_value("blue"), Some(Color::Blue))); + assert!(matches!(parse_color_value("green"), Some(Color::Green))); + assert!(matches!(parse_color_value("yellow"), Some(Color::Yellow))); + assert!(matches!(parse_color_value("cyan"), Some(Color::Cyan))); + assert!(matches!(parse_color_value("magenta"), Some(Color::Magenta))); + assert!(matches!(parse_color_value("white"), Some(Color::White))); + assert!(matches!(parse_color_value("black"), Some(Color::Black))); + } + + #[test] + fn test_parse_dark_colors() { + assert!(matches!( + parse_color_value("dark-red"), + Some(Color::DarkRed) + )); + assert!(matches!(parse_color_value("darkred"), Some(Color::DarkRed))); + assert!(matches!( + parse_color_value("dark-blue"), + Some(Color::DarkBlue) + )); + assert!(matches!( + parse_color_value("dark-grey"), + Some(Color::DarkGrey) + )); + assert!(matches!( + parse_color_value("darkgray"), + Some(Color::DarkGrey) + )); + } + + #[test] + fn test_parse_hex_colors() { + // Full hex format #RRGGBB + assert!(matches!( + parse_color_value("#FF0000"), + Some(Color::Rgb { r: 255, g: 0, b: 0 }) + )); + assert!(matches!( + parse_color_value("#00FF00"), + Some(Color::Rgb { r: 0, g: 255, b: 0 }) + )); + assert!(matches!( + parse_color_value("#0000FF"), + Some(Color::Rgb { r: 0, g: 0, b: 255 }) + )); + + // Short hex format #RGB + assert!(matches!( + parse_color_value("#F00"), + Some(Color::Rgb { r: 255, g: 0, b: 0 }) + )); + assert!(matches!( + parse_color_value("#0F0"), + Some(Color::Rgb { r: 0, g: 255, b: 0 }) + )); + assert!(matches!( + parse_color_value("#00F"), + Some(Color::Rgb { r: 0, g: 0, b: 255 }) + )); + } + + #[test] + fn test_parse_rgb_function() { + assert!(matches!( + parse_color_value("rgb(255, 128, 0)"), + Some(Color::Rgb { + r: 255, + g: 128, + b: 0 + }) + )); + assert!(matches!( + parse_color_value("rgb(0,0,0)"), + Some(Color::Rgb { r: 0, g: 0, b: 0 }) + )); + assert!(matches!( + parse_color_value("rgb(255, 255, 255)"), + Some(Color::Rgb { + r: 255, + g: 255, + b: 255 + }) + )); + } + + #[test] + fn test_parse_ansi_colors() { + assert!(matches!( + parse_color_value("ansi(42)"), + Some(Color::AnsiValue(42)) + )); + assert!(matches!( + parse_color_value("ansi-196"), + Some(Color::AnsiValue(196)) + )); + assert!(matches!( + parse_color_value("ansi(0)"), + Some(Color::AnsiValue(0)) + )); + assert!(matches!( + parse_color_value("ansi(255)"), + Some(Color::AnsiValue(255)) + )); + } + + #[test] + fn test_parse_invalid_colors() { + assert!(parse_color_value("notacolor").is_none()); + assert!(parse_color_value("#ZZZ").is_none()); + assert!(parse_color_value("rgb(999, 0, 0)").is_none()); + assert!(parse_color_value("ansi(999)").is_none()); + assert!(parse_color_value("").is_none()); + } + + #[test] + fn test_parse_color_case_insensitive() { + assert!(matches!(parse_color_value("RED"), Some(Color::Red))); + assert!(matches!(parse_color_value("Blue"), Some(Color::Blue))); + assert!(matches!( + parse_color_value("DARK-RED"), + Some(Color::DarkRed) + )); + } + + // Note: More comprehensive tests should be integration tests that parse + // actual markdown and verify the ANSI output, rather than trying to + // manually construct the complex Pandoc AST structures. +} diff --git a/crates/quarto-markdown-pandoc/src/writers/mod.rs b/crates/quarto-markdown-pandoc/src/writers/mod.rs index 586f78a..981632e 100644 --- a/crates/quarto-markdown-pandoc/src/writers/mod.rs +++ b/crates/quarto-markdown-pandoc/src/writers/mod.rs @@ -3,6 +3,7 @@ * Copyright (c) 2025 Posit, PBC */ +pub mod ansi; pub mod html; pub mod json; pub mod native; diff --git a/crates/quarto-markdown-pandoc/test-fixtures/blockquote-complex.qmd b/crates/quarto-markdown-pandoc/test-fixtures/blockquote-complex.qmd new file mode 100644 index 0000000..fffc3e5 --- /dev/null +++ b/crates/quarto-markdown-pandoc/test-fixtures/blockquote-complex.qmd @@ -0,0 +1,7 @@ +> This is the first paragraph in a blockquote. +> +> This is the second paragraph. + +Regular paragraph. + +> Another blockquote with **bold** and *italic* text. diff --git a/crates/quarto-markdown-pandoc/test-fixtures/blockquote-simple.qmd b/crates/quarto-markdown-pandoc/test-fixtures/blockquote-simple.qmd new file mode 100644 index 0000000..a921b34 --- /dev/null +++ b/crates/quarto-markdown-pandoc/test-fixtures/blockquote-simple.qmd @@ -0,0 +1,4 @@ +> This is a simple blockquote. +> It has multiple lines. + +Regular paragraph after the quote. diff --git a/crates/quarto-markdown-pandoc/test-fixtures/horizontal-rule.qmd b/crates/quarto-markdown-pandoc/test-fixtures/horizontal-rule.qmd new file mode 100644 index 0000000..a7fd9ec --- /dev/null +++ b/crates/quarto-markdown-pandoc/test-fixtures/horizontal-rule.qmd @@ -0,0 +1,9 @@ +First paragraph before the rule. + +--- + +Second paragraph after the rule. + +--- + +Third paragraph. diff --git a/crates/quarto-markdown-pandoc/test-fixtures/links.qmd b/crates/quarto-markdown-pandoc/test-fixtures/links.qmd new file mode 100644 index 0000000..e1f23cd --- /dev/null +++ b/crates/quarto-markdown-pandoc/test-fixtures/links.qmd @@ -0,0 +1,3 @@ +Check out [this example](https://example.com) and [Quarto](https://quarto.org). + +You can also visit [the docs](https://docs.example.com/guide) for more info. diff --git a/crates/quarto-markdown-pandoc/test-fixtures/schemas/definitions.yml b/crates/quarto-markdown-pandoc/test-fixtures/schemas/definitions.yml new file mode 100644 index 0000000..1dd5f5e --- /dev/null +++ b/crates/quarto-markdown-pandoc/test-fixtures/schemas/definitions.yml @@ -0,0 +1,3405 @@ +- id: date + anyOf: + - string + - object: + properties: + value: string + format: string + required: [value] + +- id: date-format + schema: string + +- id: math-methods + enum: + values: [plain, webtex, gladtex, mathml, mathjax, katex] + +- id: pandoc-format-request-headers + arrayOf: + arrayOf: + schema: string + length: 2 + +- id: pandoc-format-output-file + anyOf: + - path + - enum: + values: [null] + hidden: true + +- id: pandoc-format-filters + arrayOf: + anyOf: + - path + - object: + properties: + type: string + path: path + required: [path] + - object: + properties: + type: string + path: path + at: + enum: + [ + pre-ast, + post-ast, + pre-quarto, + post-quarto, + pre-render, + post-render, + ] + required: [path, at] + - record: + type: + enum: [citeproc] + +- id: pandoc-shortcodes + arrayOf: path + +- id: page-column + enum: + [ + body, + body-outset, + body-outset-left, + body-outset-right, + page, + page-left, + page-right, + page-inset, + page-inset-left, + page-inset-right, + screen, + screen-left, + screen-right, + screen-inset, + screen-inset-shaded, + screen-inset-left, + screen-inset-right, + margin, + ] + +- id: contents-auto + object: + properties: + auto: + anyOf: + - boolean + - maybeArrayOf: string + description: + short: Automatically generate sidebar contents. + long: | + Automatically generate sidebar contents. Pass `true` to include all documents + in the site, a directory name to include only documents in that directory, + or a glob (or list of globs) to include documents based on a pattern. + + Subdirectories will create sections (use an `index.qmd` in the directory to + provide its title). Order will be alphabetical unless a numeric `order` field + is provided in document metadata. + +- id: navigation-item + anyOf: + - path + - ref: navigation-item-object + +- id: navigation-item-object + object: + closed: true + properties: + aria-label: + string: + description: "Accessible label for the item." + file: + hidden: true + string: + description: | + Alias for href + href: + string: + description: | + Link to file contained with the project or external URL + icon: + string: + description: + short: Name of bootstrap icon (e.g. `github`, `twitter`, `share`) + long: | + Name of bootstrap icon (e.g. `github`, `twitter`, `share`) + See for a list of available icons + id: + # "core identification" + # this field is only used in typescript + schema: string + hidden: true + menu: + arrayOf: + schema: + ref: navigation-item + text: + string: + description: | + Text to display for item (defaults to the + document title if not provided) + url: + hidden: true + string: + description: | + Alias for href + rel: + string: + description: | + Value for rel attribute. Multiple space-separated values are permitted. + See + for a details. + target: + string: + description: | + Value for target attribute. + See + for details. + +- id: giscus-themes + enum: + values: + [ + light, + light_high_contrast, + light_protanopia, + light_tritanopia, + dark, + dark_high_contrast, + dark_protanopia, + dark_tritanopia, + dark_dimmed, + transparent_dark, + cobalt, + purple_dark, + noborder_light, + noborder_dark, + noborder_gray, + preferred_color_scheme, + ] + +- id: giscus-configuration + object: + closed: true + properties: + repo: + string: + description: + short: The Github repo that will be used to store comments. + long: | + The Github repo that will be used to store comments. + + In order to work correctly, the repo must be public, with the giscus app installed, and + the discussions feature must be enabled. + repo-id: + string: + description: + short: The Github repository identifier. + long: | + The Github repository identifier. + + You can quickly find this by using the configuration tool at [https://giscus.app](https://giscus.app). + If this is not provided, Quarto will attempt to discover it at render time. + category: + string: + description: + short: The discussion category where new discussions will be created. + long: | + The discussion category where new discussions will be created. It is recommended + to use a category with the **Announcements** type so that new discussions + can only be created by maintainers and giscus. + category-id: + string: + description: + short: The Github category identifier. + long: | + The Github category identifier. + + You can quickly find this by using the configuration tool at [https://giscus.app](https://giscus.app). + If this is not provided, Quarto will attempt to discover it at render time. + mapping: + schema: + anyOf: + - string + - number + completions: + - pathname + - url + - title + - og:title + description: + short: The mapping between the page and the embedded discussion. + long: | + The mapping between the page and the embedded discussion. + + - `pathname`: The discussion title contains the page path + - `url`: The discussion title contains the page url + - `title`: The discussion title contains the page title + - `og:title`: The discussion title contains the `og:title` metadata value + - any other string or number: Any other strings will be passed through verbatim and a discussion title + containing that value will be used. Numbers will be treated + as a discussion number and automatic discussion creation is not supported. + reactions-enabled: + boolean: + description: Display reactions for the discussion's main post before the comments. + loading: + enum: [lazy] + description: "Specify `loading: lazy` to defer loading comments until the user scrolls near the comments container." + input-position: + enum: [top, bottom] + description: Place the comment input box above or below the comments. + theme: + anyOf: + - string + - ref: giscus-themes + - object: + closed: true + properties: + light: + anyOf: + - string + - ref: giscus-themes + description: The light theme name. + dark: + anyOf: + - string + - ref: giscus-themes + description: The dark theme name. + + description: + short: The giscus theme to use when displaying comments. + long: | + The giscus theme to use when displaying comments. Light and dark themes are supported. If a single theme is provided by name, it will be used as light and dark theme. To use different themes, use `light` and `dark` key: + + ```yaml + website: + comments: + giscus: + theme: + light: light # giscus theme used for light website theme + dark: dark_dimmed # giscus theme used for dark website theme + ``` + language: + string: + description: The language that should be used when displaying the commenting interface. + required: [repo] + +- id: document-comments-configuration + anyOf: + - enum: [false] + - object: + closed: true + properties: + utterances: + object: + closed: true + properties: + repo: + string: + description: The Github repo that will be used to store comments. + label: + string: + description: The label that will be assigned to issues created by Utterances. + theme: + string: + description: + short: The Github theme that should be used for Utterances. + long: | + The Github theme that should be used for Utterances + (`github-light`, `github-dark`, `github-dark-orange`, + `icy-dark`, `dark-blue`, `photon-dark`, `body-light`, + or `gruvbox-dark`) + completions: + - github-light + - github-dark + - github-dark-orange + - icy-dark + - dark-blue + - photon-dark + - body-light + - gruvbox-dark + issue-term: + string: + description: + short: How posts should be mapped to Github issues + long: | + How posts should be mapped to Github issues + (`pathname`, `url`, `title` or `og:title`) + completions: + - pathname + - url + - title + - og:title + required: [repo] + giscus: + ref: giscus-configuration + hypothesis: + anyOf: + - boolean + - object: + closed: true + properties: + client-url: + string: + description: Override the default hypothesis client url with a custom client url. + openSidebar: + boolean: + default: false + description: Controls whether the sidebar opens automatically on startup. + showHighlights: + anyOf: + - boolean + - enum: ["always", "whenSidebarOpen", "never"] + default: "always" + description: Controls whether the in-document highlights are shown by default (`always`, `whenSidebarOpen` or `never`) + theme: + enum: ["classic", "clean"] + default: classic + description: Controls the overall look of the sidebar (`classic` or `clean`) + enableExperimentalNewNoteButton: + boolean: + default: false + description: | + Controls whether the experimental New Note button + should be shown in the notes tab in the sidebar. + usernameUrl: + schema: string + description: | + Specify a URL to direct a user to, + in a new tab. when they click on the annotation author + link in the header of an annotation. + services: + arrayOf: + object: + properties: + apiUrl: + string: + description: The base URL of the service API. + authority: + string: + description: The domain name which the annotation service is associated with. + grantToken: + string: + description: An OAuth 2 grant token which the client can send to the service in order to get an access token for making authenticated requests to the service. + allowLeavingGroups: + boolean: + default: true + description: A flag indicating whether users should be able to leave groups of which they are a member. + enableShareLinks: + boolean: + default: true + description: A flag indicating whether annotation cards should show links that take the user to see an annotation in context. + groups: + anyOf: + - enum: ["$rpc:requestGroups"] + - arrayOf: string + description: An array of Group IDs or the literal string `$rpc:requestGroups` + icon: + string: + description: The URL to an image for the annotation service. This image will appear to the left of the name of the currently selected group. + required: [apiUrl, authority, grantToken] + description: | + Alternative annotation services which the client should + connect to instead of connecting to the public Hypothesis + service at hypothes.is. + branding: + object: + properties: + accentColor: + string: + description: Secondary color for elements of the commenting UI. + appBackgroundColor: + string: + description: The main background color of the commenting UI. + ctaBackgroundColor: + string: + description: The background color for call to action buttons. + selectionFontFamily: + string: + description: The font family for selection text in the annotation card. + annotationFontFamily: + string: + description: The font family for the actual annotation value that the user writes about the page or selection. + description: Settings to adjust the commenting sidebar's look and feel. + externalContainerSelector: + string: + description: A CSS selector specifying the containing element into which the sidebar iframe will be placed. + focus: + object: + properties: + user: + object: + properties: + username: + string: + description: The username of the user to focus on. + userid: + string: + description: The userid of the user to focus on. + displayName: + string: + description: The display name of the user to focus on. + required: [user] + description: Defines a focused filter set for the available annotations on a page. + requestConfigFromFrame: + object: + properties: + origin: + string: + description: Host url and port number of receiving iframe + ancestorLevel: + number: + description: Number of nested iframes deep the client is relative from the receiving iframe. + assetRoot: + string: + description: The root URL from which assets are loaded. + sidebarAppUrl: + string: + description: The URL for the sidebar application which displays annotations. + default: "https://hypothes.is/app.html" + +- id: social-metadata + object: + closed: true + properties: + title: + string: + description: + short: "The title of the page" + long: | + The title of the page. Note that by default Quarto will automatically + use the title metadata from the page. Specify this field if you’d like + to override the title for this provider. + description: + string: + description: + short: "A short description of the content." + long: | + A short description of the content. Note that by default Quarto will + automatically use the description metadata from the page. Specify this + field if you’d like to override the description for this provider. + image: + path: + description: + short: "The path to a preview image for the content." + long: | + The path to a preview image for the content. By default, Quarto will use + the `image` value from the format metadata. If you provide an + image, you may also optionally provide an `image-width` and `image-height`. + image-alt: + path: + description: + short: "The alt text for the preview image." + long: | + The alt text for the preview image. By default, Quarto will use + the `image-alt` value from the format metadata. If you provide an + image, you may also optionally provide an `image-width` and `image-height`. + image-width: + number: + description: "Image width (pixels)" + image-height: + number: + description: "Image height (pixels)" + +- id: page-footer-region + anyOf: + - string + - arrayOf: + ref: navigation-item + +- id: sidebar-contents + anyOf: + - string + - ref: contents-auto + - arrayOf: + anyOf: + - ref: navigation-item + - path + - object: + closed: true + properties: + section: + anyOf: + - string + - null + contents: + ref: sidebar-contents + - ref: contents-auto + +- id: project-preview + object: + closed: true + properties: + port: + number: + description: Port to listen on (defaults to random value between 3000 and 8000) + host: + string: + description: Hostname to bind to (defaults to 127.0.0.1) + serve: + description: Use an exernal application to preview the project. + schema: + ref: project-serve + browser: + boolean: + description: Open a web browser to view the preview (defaults to true) + watch-inputs: + boolean: + description: Re-render input files when they change (defaults to true) + navigate: + boolean: + description: Navigate the browser automatically when outputs are updated (defaults to true) + timeout: + number: + description: Time (in seconds) after which to exit if there are no active clients + +- id: project-serve + object: + closed: true + properties: + cmd: + string: + description: | + Serve project preview using the specified command. + Interpolate the `--port` into the command using `{port}`. + args: + string: + description: Additional command line arguments for preview command. + env: + object: + description: Environment variables to set for preview command. + ready: + string: + description: Regular expression for detecting when the server is ready. + required: [cmd, ready] + +- id: publish + description: Sites published from project + schema: + object: + closed: true + properties: + netlify: + arrayOf: + ref: publish-record + description: "Sites published to Netlify" + +- id: publish-record + object: + closed: true + properties: + id: + string: + description: "Unique identifier for site" + url: + string: + description: "Published URL for site" + +- id: twitter-card-config + object: + super: + resolveRef: social-metadata + closed: true + properties: + card-style: + enum: [summary, summary_large_image] + description: + short: "Card style" + long: | + Card style (`summary` or `summary_large_image`). + + If this is not provided, the best style will automatically + selected based upon other metadata. You can learn more about Twitter Card + styles [here](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards). + creator: + string: + description: "`@username` of the content creator (must be a quoted string)" + site: + string: + description: "`@username` of the website (must be a quoted string)" + +- id: open-graph-config + object: + super: + resolveRef: social-metadata + closed: true + properties: + locale: + string: + description: "Locale of open graph metadata" + site-name: + string: + description: + short: "Name that should be displayed for the overall site" + long: | + Name that should be displayed for the overall site. If not explicitly + provided in the `open-graph` metadata, Quarto will use the website or + book `title` by default. + +- id: page-footer + object: + properties: + left: + ref: page-footer-region + description: "Footer left content" + right: + ref: page-footer-region + description: "Footer right content" + center: + ref: page-footer-region + description: "Footer center content" + border: + anyOf: + - boolean + - string + description: "Footer border (`true`, `false`, or a border color)" + background: + schema: string + description: "Footer background color" + foreground: + schema: string + description: "Footer foreground color" + closed: true + +- id: base-website + object: + closed: true + properties: + title: + string: + description: "Website title" + description: + string: + description: "Website description" + favicon: + string: + description: "The path to the favicon for this website" + site-url: + string: + description: "Base URL for published website" + site-path: + string: + description: | + Path to site (defaults to `/`). Not required if you specify `site-url`. + repo-url: + string: + description: "Base URL for website source code repository" + repo-link-target: + string: + description: "The value of the target attribute for repo links" + repo-link-rel: + string: + description: "The value of the rel attribute for repo links" + repo-subdir: + string: + description: "Subdirectory of repository containing website" + repo-branch: + string: + description: "Branch of website source code (defaults to `main`)" + issue-url: + string: + description: "URL to use for the 'report an issue' repository action." + repo-actions: + maybeArrayOf: + enum: [none, edit, source, issue] + description: + short: "Links to source repository actions" + long: "Links to source repository actions (`none` or one or more of `edit`, `source`, `issue`)" + reader-mode: + boolean: + description: | + Displays a 'reader-mode' tool which allows users to hide the sidebar and table of contents when viewing a page. + google-analytics: + anyOf: + - string + - object: + properties: + tracking-id: + schema: string + description: "The Google tracking Id or measurement Id of this website." + storage: + enum: [cookies, none] + description: + short: "Storage options for Google Analytics data" + long: | + Storage option for Google Analytics data using on of these two values: + + `cookies`: Use cookies to store unique user and session identification (default). + + `none`: Do not use cookies to store unique user and session identification. + + For more about choosing storage options see [Storage](https://quarto.org/docs/websites/website-tools.html#storage). + anonymize-ip: + schema: boolean + description: + short: "Anonymize the user ip address." + long: | + Anonymize the user ip address. For more about this feature, see + [IP Anonymization (or IP masking) in Google Analytics](https://support.google.com/analytics/answer/2763052?hl=en). + version: + enum: [3, 4] + description: + short: "The version number of Google Analytics to use." + long: | + The version number of Google Analytics to use. + + - `3`: Use analytics.js + - `4`: use gtag. + + This is automatically detected based upon the `tracking-id`, but you may specify it. + description: "Enable Google Analytics for this website" + announcement: + anyOf: + - string + - object: + properties: + content: + schema: string + description: "The content of the announcement" + dismissable: + schema: boolean + description: "Whether this announcement may be dismissed by the user." + icon: + schema: string + description: + short: "The icon to display in the announcement" + long: | + Name of bootstrap icon (e.g. `github`, `twitter`, `share`) for the announcement. + See for a list of available icons + position: + schema: + enum: ["above-navbar", "below-navbar"] + description: + short: "The position of the announcement." + long: | + The position of the announcement. One of `above-navbar` (default) or `below-navbar`. + type: + schema: + enum: + [ + primary, + secondary, + success, + danger, + warning, + info, + light, + dark, + ] + description: + short: "The type of announcement. Affects the appearance of the announcement." + long: | + The type of announcement. One of `primary`, `secondary`, `success`, `danger`, `warning`, + `info`, `light` or `dark`. Affects the appearance of the announcement. + + description: Provides an announcement displayed at the top of the page. + cookie-consent: + anyOf: + - enum: [express, implied] + - boolean + - object: + properties: + type: + enum: [implied, express] + description: + short: "The type of consent that should be requested" + long: | + The type of consent that should be requested, using one of these two values: + + - `implied` (default): This will notify the user that the site uses cookies and permit them to change preferences, but not block cookies unless the user changes their preferences. + + - `express`: This will block cookies until the user expressly agrees to allow them (or continue blocking them if the user doesn’t agree). + style: + enum: [simple, headline, interstitial, standalone] + description: + short: "The style of the consent banner that is displayed" + long: | + The style of the consent banner that is displayed: + + - `simple` (default): A simple dialog in the lower right corner of the website. + + - `headline`: A full width banner across the top of the website. + + - `interstitial`: An semi-transparent overlay of the entire website. + + - `standalone`: An opaque overlay of the entire website. + palette: + enum: [light, dark] + description: "Whether to use a dark or light appearance for the consent banner (`light` or `dark`)." + policy-url: + schema: string + description: "The url to the website’s cookie or privacy policy." + language: + schema: string + description: + short: "The language to be used when diplaying the cookie consent prompt (defaults to document language)." + long: | + The language to be used when diplaying the cookie consent prompt specified using an IETF language tag. + + If not specified, the document language will be used. + prefs-text: + schema: string + description: + short: "The text to display for the cookie preferences link in the website footer." + description: + short: "Request cookie consent before enabling scripts that set cookies" + long: | + Quarto includes the ability to request cookie consent before enabling scripts that set cookies, using [Cookie Consent](https://www.cookieconsent.com/). + + The user’s cookie preferences will automatically control Google Analytics (if enabled) and can be used to control custom scripts you add as well. For more information see [Custom Scripts and Cookie Consent](https://quarto.org/docs/websites/website-tools.html#custom-scripts-and-cookie-consent). + search: + anyOf: + - boolean + - object: + properties: + location: + enum: [navbar, sidebar] + description: "Location for search widget (`navbar` or `sidebar`)" + type: + enum: [overlay, textbox] + description: "Type of search UI (`overlay` or `textbox`)" + limit: + schema: number + description: "Number of matches to display (defaults to 20)" + collapse-after: + schema: number + description: "Matches after which to collapse additional results" + copy-button: + schema: boolean + description: "Provide button for copying search link" + merge-navbar-crumbs: + schema: boolean + default: true + description: "When false, do not merge navbar crumbs into the crumbs in `search.json`." + keyboard-shortcut: + maybeArrayOf: + string: + description: "One or more keys that will act as a shortcut to launch search (single characters)" + show-item-context: + schema: + anyOf: + - enum: ["tree", "parent", "root"] + - boolean + description: "Whether to include search result parents when displaying items in search results (when possible)." + algolia: + object: + properties: + index-name: + schema: string + description: "The name of the index to use when performing a search" + application-id: + schema: string + description: "The unique ID used by Algolia to identify your application" + search-only-api-key: + schema: string + description: "The Search-Only API key to use to connect to Algolia" + analytics-events: + boolean: + description: "Enable tracking of Algolia analytics events" + show-logo: + boolean: + description: "Enable the display of the Algolia logo in the search results footer." + index-fields: + object: + properties: + href: + schema: string + description: "Field that contains the URL of index entries" + title: + schema: string + description: "Field that contains the title of index entries" + text: + schema: string + description: "Field that contains the text of index entries" + section: + schema: string + description: "Field that contains the section of index entries" + closed: true + params: + object: + description: "Additional parameters to pass when executing a search" + closed: true + description: "Use external Algolia search index" + closed: true + description: "Provide full text search for website" + + navbar: + anyOf: + - boolean + - object: + properties: + title: + anyOf: + - string + - boolean + description: "The navbar title. Uses the project title if none is specified." + logo: + ref: logo-light-dark-specifier + description: "Specification of image that will be displayed to the left of the title." + logo-alt: + string: + description: "Alternate text for the logo image." + logo-href: + string: + description: "Target href from navbar logo / title. By default, the logo and title link to the root page of the site (/index.html)." + background: + string: + description: "The navbar's background color (named or hex color)." + completions: + - primary + - secondary + - success + - danger + - warning + - info + - light + - dark + foreground: + string: + description: "The navbar's foreground color (named or hex color)." + completions: + - primary + - secondary + - success + - danger + - warning + - info + - light + - dark + search: + boolean: + description: "Include a search box in the navbar." + pinned: + boolean: + description: "Always show the navbar (keeping it pinned)." + default: false + collapse: + boolean: + description: "Collapse the navbar into a menu when the display becomes narrow." + default: true + collapse-below: + enum: [sm, md, lg, xl, xxl] + description: "The responsive breakpoint below which the navbar will collapse into a menu (`sm`, `md`, `lg` (default), `xl`, `xxl`)." + default: "lg" + left: + arrayOf: + ref: navigation-item + description: "List of items for the left side of the navbar." + right: + arrayOf: + ref: navigation-item + description: "List of items for the right side of the navbar." + toggle-position: + schema: + enum: [left, right] + description: "The position of the collapsed navbar toggle when in responsive mode" + default: "left" + tools-collapse: + boolean: + description: "Collapse tools into the navbar menu when the display becomes narrow." + default: false + + description: "Top navigation options" + + sidebar: + anyOf: + - boolean + - maybeArrayOf: + object: + properties: + id: + string: + description: "The identifier for this sidebar." + title: + anyOf: + - string + - boolean + description: "The sidebar title. Uses the project title if none is specified." + logo: + ref: logo-light-dark-specifier + description: "Specification of image that will be displayed in the sidebar." + logo-alt: + string: + description: "Alternate text for the logo image." + logo-href: + string: + description: "Target href from navbar logo / title. By default, the logo and title link to the root page of the site (/index.html)." + search: + boolean: + description: "Include a search control in the sidebar." + tools: + arrayOf: + ref: navigation-item-object + description: "List of sidebar tools" + contents: + ref: sidebar-contents + description: "List of items for the sidebar" + style: + enum: ["docked", "floating"] + description: "The style of sidebar (`docked` or `floating`)." + default: "floating" + background: + schema: string + completions: + - primary + - secondary + - success + - danger + - warning + - info + - light + - dark + description: "The sidebar's background color (named or hex color)." + foreground: + schema: string + completions: + - primary + - secondary + - success + - danger + - warning + - info + - light + - dark + description: "The sidebar's foreground color (named or hex color)." + border: + boolean: + description: "Whether to show a border on the sidebar (defaults to true for 'docked' sidebars)" + alignment: + enum: ["left", "right", "center"] + description: "Alignment of the items within the sidebar (`left`, `right`, or `center`)" + collapse-level: + number: + description: "The depth at which the sidebar contents should be collapsed by default." + default: 2 + pinned: + boolean: + description: "When collapsed, pin the collapsed sidebar to the top of the page." + header: + maybeArrayOf: string + description: "Markdown to place above sidebar content (text or file path)" + footer: + maybeArrayOf: string + description: "Markdown to place below sidebar content (text or file path)" + description: "Side navigation options" + body-header: + string: + description: "Markdown to insert at the beginning of each page’s body (below the title and author block)." + body-footer: + string: + description: "Markdown to insert below each page’s body." + margin-header: + maybeArrayOf: string + description: "Markdown to place above margin content (text or file path)" + margin-footer: + maybeArrayOf: string + description: "Markdown to place below margin content (text or file path)" + page-navigation: + boolean: + description: "Provide next and previous article links in footer" + back-to-top-navigation: + boolean: + description: "Provide a 'back to top' navigation button" + bread-crumbs: + boolean: + description: "Whether to show navigation breadcrumbs for pages more than 1 level deep" + page-footer: + anyOf: + - string + - ref: page-footer + description: "Shared page footer" + image: + path: + description: | + Default site thumbnail image for `twitter` /`open-graph` + image-alt: + path: + description: | + Default site thumbnail image alt text for `twitter` /`open-graph` + comments: + schema: + ref: document-comments-configuration + + open-graph: + anyOf: + - boolean + - ref: open-graph-config + description: "Publish open graph metadata" + twitter-card: + anyOf: + - boolean + - ref: twitter-card-config + description: "Publish twitter card metadata" + other-links: + schema: + ref: other-links + tags: + formats: [$html-doc] + description: "A list of other links to appear below the TOC." + code-links: + schema: + anyOf: + - boolean + - ref: code-links-schema + tags: + formats: [$html-doc] + description: "A list of code links to appear with this document." + drafts: + schema: + maybeArrayOf: path + description: "A list of input documents that should be treated as drafts" + draft-mode: + schema: + enum: ["visible", "unlinked", "gone"] + description: + short: "How to handle drafts that are encountered." + long: | + How to handle drafts that are encountered. + + `visible` - the draft will visible and fully available + `unlinked` - the draft will be rendered, but will not appear in navigation, search, or listings. + `gone` - the draft will have no content and will not be linked to (default). + +- id: book-schema + schema: + object: + closed: true + super: + resolveRef: base-website + properties: + title: + string: + description: "Book title" + subtitle: + string: + description: "Book subtitle" + author: + maybeArrayOf: + anyOf: [string, object] + description: "Author or authors of the book" + date: + string: + description: "Book publication date" + date-format: + string: + description: "Format string for dates in the book" + abstract: + string: + description: "Book abstract" + description: + string: + description: "Description metadata for HTML version of book" + chapters: + schema: + ref: chapter-list + description: "Book part and chapter files" + hidden: true + appendices: + schema: + ref: chapter-list + description: "Book appendix files" + hidden: true + references: + path: + description: "Book references file" + output-file: + path: + description: "Base name for single-file output (e.g. PDF, ePub, docx)" + cover-image: + path: + description: "Cover image (used in HTML and ePub formats)" + cover-image-alt: + string: + description: "Alternative text for cover image (used in HTML format)" + sharing: + maybeArrayOf: + enum: [twitter, facebook, linkedin] + description: | + Sharing buttons to include on navbar or sidebar + (one or more of `twitter`, `facebook`, `linkedin`) + downloads: + maybeArrayOf: + enum: [pdf, epub, docx] + description: | + Download buttons for other formats to include on navbar or sidebar + (one or more of `pdf`, `epub`, and `docx`) + tools: + arrayOf: + schema: + ref: navigation-item + description: "Custom tools for navbar or sidebar" + doi: + string: + tags: + formats: [$html-doc] + description: The Digital Object Identifier for this book. + +- id: chapter-item + anyOf: + - ref: navigation-item + - object: + properties: + part: + string: + description: "Part title or path to input file" + chapters: + arrayOf: + ref: navigation-item + description: "Path to chapter input file" + required: [part] + +- id: chapter-list + arrayOf: + ref: chapter-item + +- id: other-links + arrayOf: + object: + properties: + text: + string: + description: "The text for the link." + href: + string: + description: "The href for the link." + icon: + string: + description: "The bootstrap icon name for the link." + rel: + string: + description: "The rel attribute value for the link." + target: + string: + description: "The target attribute value for the link." + required: [text, href] + +- id: crossref-labels-schema + string: + completions: + - alpha + - arabic + - roman + +- id: epub-contributor + anyOf: + - string + - maybeArrayOf: + object: + closed: true + properties: + role: + string: + description: + short: The role of this creator or contributor. + long: | + The role of this creator or contributor using + [MARC relators](https://loc.gov/marc/relators/relaterm.html). Human readable + translations to commonly used relators (e.g. 'author', 'editor') will + attempt to be automatically translated. + file-as: + string: + description: An alternate version of the creator or contributor text used for alphabatizing. + text: + string: + description: The text describing the creator or contributor (for example, creator name). + +- id: format-language + object: + properties: + toc-title-document: string + toc-title-website: string + related-formats-title: string + related-notebooks-title: string + callout-tip-title: string + callout-note-title: string + callout-warning-title: string + callout-important-title: string + callout-caution-title: string + section-title-abstract: string + section-title-footnotes: string + section-title-appendices: string + code-summary: string + code-tools-menu-caption: string + code-tools-show-all-code: string + code-tools-hide-all-code: string + code-tools-view-source: string + code-tools-source-code: string + search-no-results-text: string + copy-button-tooltip: string + copy-button-tooltip-success: string + repo-action-links-edit: string + repo-action-links-source: string + repo-action-links-issue: string + search-matching-documents-text: string + search-copy-link-title: string + search-hide-matches-text: string + search-more-match-text: string + search-more-matches-text: string + search-clear-button-title: string + search-text-placeholder: string + search-detached-cancel-button-title: string + search-submit-button-title: string + crossref-fig-title: string + crossref-tbl-title: string + crossref-lst-title: string + crossref-thm-title: string + crossref-lem-title: string + crossref-cor-title: string + crossref-prp-title: string + crossref-cnj-title: string + crossref-def-title: string + crossref-exm-title: string + crossref-exr-title: string + crossref-fig-prefix: string + crossref-tbl-prefix: string + crossref-lst-prefix: string + crossref-ch-prefix: string + crossref-apx-prefix: string + crossref-sec-prefix: string + crossref-eq-prefix: string + crossref-thm-prefix: string + crossref-lem-prefix: string + crossref-cor-prefix: string + crossref-prp-prefix: string + crossref-cnj-prefix: string + crossref-def-prefix: string + crossref-exm-prefix: string + crossref-exr-prefix: string + crossref-lof-title: string + crossref-lot-title: string + crossref-lol-title: string + errorDescription: "be a format language description object" + +- id: website-about + object: + closed: true + required: [template] + properties: + id: + string: + description: + short: "The target id for the about page." + long: | + The target id of this about page. When the about page is rendered, it will + place read the contents of a `div` with this id into the about template that you + have selected (and replace the contents with the rendered about content). + + If no such `div` is defined on the page, a `div` with this id will be created + and appended to the end of the page. + template: + anyOf: + - enum: [jolla, trestles, solana, marquee, broadside] + - path + description: + short: "The template to use to layout this about page." + long: | + The template to use to layout this about page. Choose from: + + - `jolla` + - `trestles` + - `solana` + - `marquee` + - `broadside` + image: + path: + description: + short: "The path to the main image on the about page." + long: | + The path to the main image on the about page. If not specified, + the `image` provided for the document itself will be used. + image-alt: + path: + description: "The alt text for the main image on the about page." + image-title: + path: + description: "The title for the main image on the about page." + image-width: + string: + description: + short: "A valid CSS width for the about page image." + long: | + A valid CSS width for the about page image. + image-shape: + enum: [rectangle, round, rounded] + description: + short: "The shape of the image on the about page." + long: | + The shape of the image on the about page. + + - `rectangle` + - `round` + - `rounded` + links: + arrayOf: + ref: navigation-item + +- id: website-listing + object: + closed: true + properties: + id: + string: + description: + short: "The id of this listing." + long: | + The id of this listing. When the listing is rendered, it will + place the contents into a `div` with this id. If no such `div` is defined on the + page, a `div` with this id will be created and appended to the end of the page. + + If no `id` is provided for a listing, Quarto will synthesize one when rendering the page. + type: + enum: [default, table, grid, custom] + description: + short: "The type of listing to create." + long: | + The type of listing to create. Choose one of: + + - `default`: A blog style list of items + - `table`: A table of items + - `grid`: A grid of item cards + - `custom`: A custom template, provided by the `template` field + contents: + maybeArrayOf: + anyOf: + - string + - ref: website-listing-contents-object + description: "The files or path globs of Quarto documents or YAML files that should be included in the listing." + sort: + anyOf: + - boolean + - maybeArrayOf: string + description: + short: "Sort items in the listing by these fields." + long: | + Sort items in the listing by these fields. The sort key is made up of a + field name followed by a direction `asc` or `desc`. + + For example: + `date asc` + + Use `sort:false` to use the unsorted original order of items. + max-items: + number: + description: The maximum number of items to include in this listing. + page-size: + number: + description: The number of items to display on a page. + sort-ui: + anyOf: + - boolean + - arrayOf: string + description: + short: "Shows or hides the sorting control for the listing." + long: | + Shows or hides the sorting control for the listing. To control the + fields that will be displayed in the sorting control, provide a list + of field names. + filter-ui: + anyOf: + - boolean + - arrayOf: string + description: + short: "Shows or hides the filtering control for the listing." + long: | + Shows or hides the filtering control for the listing. To control the + fields that will be used to filter the listing, provide a list + of field names. By default all fields of the listing will be used + when filtering. + categories: + anyOf: + - boolean + - enum: [numbered, unnumbered, cloud] + description: + short: "Display item categories from this listing in the margin of the page." + long: | + Display item categories from this listing in the margin of the page. + + - `numbered`: Category list with number of items + - `unnumbered`: Category list + - `cloud`: Word cloud style categories + + feed: + anyOf: + - boolean + - object: + closed: true + properties: + items: + number: + description: | + The number of items to include in your feed. Defaults to 20. + type: + enum: [full, partial, metadata] + description: + short: Whether to include full or partial content in the feed. + long: | + Whether to include full or partial content in the feed. + + - `full` (default): Include the complete content of the document in the feed. + - `partial`: Include only the first paragraph of the document in the feed. + - `metadata`: Use only the title, description, and other document metadata in the feed. + title: + string: + description: + short: The title for this feed. + long: | + The title for this feed. Defaults to the site title provided the Quarto project. + image: + path: + description: + short: The path to an image for this feed. + long: | + The path to an image for this feed. If not specified, the image for the page the listing + appears on will be used, otherwise an image will be used if specified for the site + in the Quarto project. + description: + string: + description: + short: The description of this feed. + long: | + The description of this feed. If not specified, the description for the page the + listing appears on will be used, otherwise the description + of the site will be used if specified in the Quarto project. + language: + string: + description: + short: The language of the feed. + long: | + The language of the feed. Omitted if not specified. + See [https://www.rssboard.org/rss-language-codes](https://www.rssboard.org/rss-language-codes) + for a list of valid language codes. + categories: + maybeArrayOf: + string: + description: A list of categories for which to create separate RSS feeds containing only posts with that category + xml-stylesheet: + path: + description: The path to an XML stylesheet (XSL file) used to style the RSS feed. + description: Enables an RSS feed for the listing. + date-format: + string: + description: + short: "The date format to use when displaying dates (e.g. d-M-yyy)." + long: | + The date format to use when displaying dates (e.g. d-M-yyy). + Learn more about supported date formatting values [here](https://quarto.org/docs/reference/dates.html). + max-description-length: + number: + description: + short: "The maximum length (in characters) of the description displayed in the listing." + long: | + The maximum length (in characters) of the description displayed in the listing. + Defaults to 175. + image-placeholder: + string: + description: "The default image to use if an item in the listing doesn't have an image." + image-lazy-loading: + boolean: + description: "If false, images in the listing will be loaded immediately. If true, images will be loaded as they come into view." + default: true + image-align: + enum: [left, right] + description: In `default` type listings, whether to place the image on the right or left side of the post content (`left` or `right`). + image-height: + string: + description: + short: "The height of the image being displayed." + long: | + The height of the image being displayed (a CSS height string). + + The width is automatically determined and the image will fill the rectangle without scaling (cropped to fill). + grid-columns: + number: + description: + short: "In `grid` type listings, the number of columns in the grid display." + long: | + In grid type listings, the number of columns in the grid display. + Defaults to 3. + grid-item-border: + boolean: + description: + short: "In `grid` type listings, whether to display a border around the item card." + long: | + In grid type listings, whether to display a border around the item card. Defaults to `true`. + grid-item-align: + enum: [left, right, center] + description: + short: "In `grid` type listings, the alignment of the content within the card." + long: | + In grid type listings, the alignment of the content within the card (`left` (default), `right`, or `center`). + table-striped: + boolean: + description: + short: "In `table` type listings, display the table rows with alternating background colors." + long: | + In table type listings, display the table rows with alternating background colors. + Defaults to `false`. + table-hover: + boolean: + description: + short: "In `table` type listings, highlight rows of the table when the user hovers the mouse over them." + long: | + In table type listings, highlight rows of the table when the user hovers the mouse over them. + Defaults to false. + template: + path: + description: + short: "The path to a custom listing template." + long: | + The path to a custom listing template. + template-params: + schema: object + description: "Parameters that are passed to the custom template." + fields: + arrayOf: string + description: + short: "The list of fields to include in this listing" + long: | + The list of fields to include in this listing. + field-display-names: + object: + description: + short: "A mapping of display names for listing fields." + long: | + A mapping that provides display names for specific fields. For example, to display the title column as ‘Report’ in a table listing you would write: + + ```yaml + listing: + field-display-names: + title: "Report" + ``` + field-types: + object: + description: + short: "Provides the date type for the field of a listing item." + long: | + Provides the date type for the field of a listing item. Unknown fields are treated + as strings unless a type is provided. Valid types are `date`, `number`. + field-links: + arrayOf: string + description: + short: "This list of fields to display as links in a table listing." + long: | + The list of fields to display as hyperlinks to the source document + when the listing type is a table. By default, only the `title` or + `filename` is displayed as a link. + field-required: + arrayOf: string + description: + short: "Fields that items in this listing must have populated." + long: | + Fields that items in this listing must have populated. + If a listing is rendered and one more items in this listing + is missing a required field, an error will occur and the render will. + include: + maybeArrayOf: object + description: "Items with matching field values will be included in the listing." + exclude: + maybeArrayOf: object + description: "Items with matching field values will be excluded from the listing." + +- id: website-listing-contents-object + object: + properties: + author: + maybeArrayOf: string + date: string + title: string + subtitle: string + +- id: csl-date + anyOf: + - string + - maybeArrayOf: number + - object: + properties: + year: + number: + description: The year + month: + number: + description: The month + day: + number: + description: The day + +- id: csl-person + anyOf: + - maybeArrayOf: string + - maybeArrayOf: + object: + properties: + family-name: + string: + description: The family name. + given-name: + string: + description: The given name. + +- id: csl-number + anyOf: + - number + - string + +- id: csl-item-shared + object: + properties: + abstract-url: + string: + description: A url to the abstract for this item. + accessed: + ref: csl-date + description: Date the item has been accessed. + annote: + string: + description: + short: Short markup, decoration, or annotation to the item (e.g., to indicate items included in a review). + long: | + Short markup, decoration, or annotation to the item (e.g., to indicate items included in a review); + + For descriptive text (e.g., in an annotated bibliography), use `note` instead + archive: + string: + description: Archive storing the item + archive-collection: + string: + description: Collection the item is part of within an archive. + archive_collection: + schema: string + hidden: true + archive-location: + string: + description: Storage location within an archive (e.g. a box and folder number). + archive_location: + schema: string + hidden: true + archive-place: + string: + description: Geographic location of the archive. + authority: + string: + description: Issuing or judicial authority (e.g. "USPTO" for a patent, "Fairfax Circuit Court" for a legal case). + available-date: + ref: csl-date + description: + short: Date the item was initially available + long: | + Date the item was initially available (e.g. the online publication date of a journal + article before its formal publication date; the date a treaty was made available for signing). + call-number: + string: + description: Call number (to locate the item in a library). + chair: + ref: csl-person + description: The person leading the session containing a presentation (e.g. the organizer of the `container-title` of a `speech`). + chapter-number: + ref: csl-number + description: Chapter number (e.g. chapter number in a book; track number on an album). + citation-key: + string: + description: + short: Identifier of the item in the input data file (analogous to BiTeX entrykey). + long: | + Identifier of the item in the input data file (analogous to BiTeX entrykey); + + Use this variable to facilitate conversion between word-processor and plain-text writing systems; + For an identifer intended as formatted output label for a citation + (e.g. “Ferr78”), use `citation-label` instead + citation-label: + string: + description: + short: Label identifying the item in in-text citations of label styles (e.g. "Ferr78"). + long: | + Label identifying the item in in-text citations of label styles (e.g. "Ferr78"); + + May be assigned by the CSL processor based on item metadata; For the identifier of the item + in the input data file, use `citation-key` instead + citation-number: + schema: + ref: csl-number + description: Index (starting at 1) of the cited reference in the bibliography (generated by the CSL processor). + hidden: true + collection-editor: + ref: csl-person + description: Editor of the collection holding the item (e.g. the series editor for a book). + collection-number: + ref: csl-number + description: Number identifying the collection holding the item (e.g. the series number for a book) + collection-title: + string: + description: Title of the collection holding the item (e.g. the series title for a book; the lecture series title for a presentation). + compiler: + ref: csl-person + description: Person compiling or selecting material for an item from the works of various persons or bodies (e.g. for an anthology). + composer: + ref: csl-person + description: Composer (e.g. of a musical score). + container-author: + ref: csl-person + description: Author of the container holding the item (e.g. the book author for a book chapter). + container-title: + string: + description: + short: Title of the container holding the item. + long: | + Title of the container holding the item (e.g. the book title for a book chapter, + the journal title for a journal article; the album title for a recording; + the session title for multi-part presentation at a conference) + container-title-short: + string: + description: Short/abbreviated form of container-title; + hidden: true + contributor: + ref: csl-person + description: A minor contributor to the item; typically cited using “with” before the name when listed in a bibliography. + curator: + ref: csl-person + description: Curator of an exhibit or collection (e.g. in a museum). + dimensions: + string: + description: Physical (e.g. size) or temporal (e.g. running time) dimensions of the item. + director: + ref: csl-person + description: Director (e.g. of a film). + division: + string: + description: Minor subdivision of a court with a `jurisdiction` for a legal item + DOI: + schema: string + hidden: true + edition: + ref: csl-number + description: (Container) edition holding the item (e.g. "3" when citing a chapter in the third edition of a book). + editor: + ref: csl-person + description: The editor of the item. + editorial-director: + ref: csl-person + description: Managing editor ("Directeur de la Publication" in French). + editor-translator: + ref: csl-person + description: + short: Combined editor and translator of a work. + long: | + Combined editor and translator of a work. + + The citation processory must be automatically generate if editor and translator variables + are identical; May also be provided directly in item data. + event: + schema: string + hidden: true + event-date: + ref: csl-date + description: Date the event related to an item took place. + event-title: + string: + description: Name of the event related to the item (e.g. the conference name when citing a conference paper; the meeting where presentation was made). + event-place: + string: + description: Geographic location of the event related to the item (e.g. "Amsterdam, The Netherlands"). + executive-producer: + ref: csl-person + description: Executive producer of the item (e.g. of a television series). + first-reference-note-number: + schema: + ref: csl-number + description: + short: Number of a preceding note containing the first reference to the item. + long: | + Number of a preceding note containing the first reference to the item + + Assigned by the CSL processor; Empty in non-note-based styles or when the item hasn't + been cited in any preceding notes in a document + hidden: true + fulltext-url: + string: + description: A url to the full text for this item. + genre: + string: + description: + short: Type, class, or subtype of the item + long: | + Type, class, or subtype of the item (e.g. "Doctoral dissertation" for a PhD thesis; "NIH Publication" for an NIH technical report); + + Do not use for topical descriptions or categories (e.g. "adventure" for an adventure movie) + guest: + ref: csl-person + description: Guest (e.g. on a TV show or podcast). + host: + ref: csl-person + description: Host of the item (e.g. of a TV show or podcast). + id: + anyOf: + - string + - number + description: A value which uniquely identifies this item. + illustrator: + ref: csl-person + description: Illustrator (e.g. of a children’s book or graphic novel). + interviewer: + ref: csl-person + description: Interviewer (e.g. of an interview). + isbn: + string: + description: International Standard Book Number (e.g. "978-3-8474-1017-1"). + ISBN: + schema: string + hidden: true + issn: + string: + description: International Standard Serial Number. + ISSN: + schema: string + hidden: true + issue: + ref: csl-number + description: + short: Issue number of the item or container holding the item + long: | + Issue number of the item or container holding the item (e.g. "5" when citing a + journal article from journal volume 2, issue 5); + + Use `volume-title` for the title of the issue, if any. + issued: + ref: csl-date + description: Date the item was issued/published. + jurisdiction: + string: + description: Geographic scope of relevance (e.g. "US" for a US patent; the court hearing a legal case). + keyword: + string: + description: Keyword(s) or tag(s) attached to the item. + language: + string: + description: + short: The language of the item (used only for citation of the item). + long: | + The language of the item (used only for citation of the item). + + Should be entered as an ISO 639-1 two-letter language code (e.g. "en", "zh"), + optionally with a two-letter locale code (e.g. "de-DE", "de-AT"). + + This does not change the language of the item, instead it documents + what language the item uses (which may be used in citing the item). + license: + string: + description: + short: The license information applicable to an item. + long: | + The license information applicable to an item (e.g. the license an article + or software is released under; the copyright information for an item; + the classification status of a document) + locator: + ref: csl-number + description: + short: A cite-specific pinpointer within the item. + long: | + A cite-specific pinpointer within the item (e.g. a page number within a book, + or a volume in a multi-volume work). + + Must be accompanied in the input data by a label indicating the locator type + (see the Locators term list). + + medium: + string: + description: Description of the item’s format or medium (e.g. "CD", "DVD", "Album", etc.) + narrator: + ref: csl-person + description: Narrator (e.g. of an audio book). + note: + string: + description: Descriptive text or notes about an item (e.g. in an annotated bibliography). + number: + ref: csl-number + description: Number identifying the item (e.g. a report number). + number-of-pages: + ref: csl-number + description: Total number of pages of the cited item. + number-of-volumes: + ref: csl-number + description: Total number of volumes, used when citing multi-volume books and such. + organizer: + ref: csl-person + description: Organizer of an event (e.g. organizer of a workshop or conference). + original-author: + ref: csl-person + description: + short: The original creator of a work. + long: | + The original creator of a work (e.g. the form of the author name + listed on the original version of a book; the historical author of a work; + the original songwriter or performer for a musical piece; the original + developer or programmer for a piece of software; the original author of an + adapted work such as a book adapted into a screenplay) + original-date: + ref: csl-date + description: Issue date of the original version. + original-publisher: + string: + description: Original publisher, for items that have been republished by a different publisher. + original-publisher-place: + string: + description: Geographic location of the original publisher (e.g. "London, UK"). + original-title: + string: + description: Title of the original version (e.g. "Война и мир", the untranslated Russian title of "War and Peace"). + page: + ref: csl-number + description: Range of pages the item (e.g. a journal article) covers in a container (e.g. a journal issue). + page-first: + ref: csl-number + description: First page of the range of pages the item (e.g. a journal article) covers in a container (e.g. a journal issue). + page-last: + ref: csl-number + description: Last page of the range of pages the item (e.g. a journal article) covers in a container (e.g. a journal issue). + part-number: + ref: csl-number + description: + short: Number of the specific part of the item being cited (e.g. part 2 of a journal article). + long: | + Number of the specific part of the item being cited (e.g. part 2 of a journal article). + + Use `part-title` for the title of the part, if any. + part-title: + string: + description: Title of the specific part of an item being cited. + pdf-url: + string: + description: A url to the pdf for this item. + performer: + ref: csl-person + description: Performer of an item (e.g. an actor appearing in a film; a muscian performing a piece of music). + pmcid: + string: + description: PubMed Central reference number. + PMCID: + schema: string + hidden: true + pmid: + string: + description: PubMed reference number. + PMID: + schema: string + hidden: true + printing-number: + ref: csl-number + description: Printing number of the item or container holding the item. + producer: + ref: csl-person + description: Producer (e.g. of a television or radio broadcast). + public-url: + string: + description: A public url for this item. + publisher: + string: + description: The publisher of the item. + publisher-place: + string: + description: The geographic location of the publisher. + recipient: + ref: csl-person + description: Recipient (e.g. of a letter). + reviewed-author: + ref: csl-person + description: Author of the item reviewed by the current item. + reviewed-genre: + string: + description: Type of the item being reviewed by the current item (e.g. book, film). + reviewed-title: + string: + description: Title of the item reviewed by the current item. + scale: + string: + description: Scale of e.g. a map or model. + script-writer: + ref: csl-person + description: Writer of a script or screenplay (e.g. of a film). + section: + ref: csl-number + description: Section of the item or container holding the item (e.g. "§2.0.1" for a law; "politics" for a newspaper article). + series-creator: + ref: csl-person + description: Creator of a series (e.g. of a television series). + source: + string: + description: Source from whence the item originates (e.g. a library catalog or database). + status: + string: + description: Publication status of the item (e.g. "forthcoming"; "in press"; "advance online publication"; "retracted") + submitted: + ref: csl-date + description: Date the item (e.g. a manuscript) was submitted for publication. + supplement-number: + ref: csl-number + description: Supplement number of the item or container holding the item (e.g. for secondary legal items that are regularly updated between editions). + title-short: + string: + description: Short/abbreviated form of`title`. + hidden: true + translator: + ref: csl-person + description: Translator + type: + enum: + [ + "article", + "article-journal", + "article-magazine", + "article-newspaper", + "bill", + "book", + "broadcast", + "chapter", + "classic", + "collection", + "dataset", + "document", + "entry", + "entry-dictionary", + "entry-encyclopedia", + "event", + "figure", + "graphic", + "hearing", + "interview", + "legal_case", + "legislation", + "manuscript", + "map", + "motion_picture", + "musical_score", + "pamphlet", + "paper-conference", + "patent", + "performance", + "periodical", + "personal_communication", + "post", + "post-weblog", + "regulation", + "report", + "review", + "review-book", + "software", + "song", + "speech", + "standard", + "thesis", + "treaty", + "webpage", + ] + description: The [type](https://docs.citationstyles.org/en/stable/specification.html#appendix-iii-types) of the item. + url: + string: + description: Uniform Resource Locator (e.g. "https://aem.asm.org/cgi/content/full/74/9/2766") + URL: + schema: string + hidden: true + version: + ref: csl-number + description: Version of the item (e.g. "2.0.9" for a software program). + volume: + ref: csl-number + description: + short: Volume number of the item (e.g. “2” when citing volume 2 of a book) or the container holding the item. + long: | + Volume number of the item (e.g. "2" when citing volume 2 of a book) or the container holding the + item (e.g. "2" when citing a chapter from volume 2 of a book). + + Use `volume-title` for the title of the volume, if any. + volume-title: + string: + description: + short: Title of the volume of the item or container holding the item. + long: | + Title of the volume of the item or container holding the item. + + Also use for titles of periodical special issues, special sections, and the like. + year-suffix: + string: + description: Disambiguating year suffix in author-date styles (e.g. "a" in "Doe, 1999a"). + +- id: csl-item + object: + super: + resolveRef: csl-item-shared + closed: true + properties: + abstract: + string: + description: Abstract of the item (e.g. the abstract of a journal article) + author: + ref: csl-person + description: The author(s) of the item. + doi: + string: + description: Digital Object Identifier (e.g. "10.1128/AEM.02591-07") + references: + string: + description: + short: Resources related to the procedural history of a legal case or legislation. + long: | + Resources related to the procedural history of a legal case or legislation; + + Can also be used to refer to the procedural history of other items (e.g. + "Conference canceled" for a presentation accepted as a conference that was subsequently + canceled; details of a retraction or correction notice) + title: + string: + description: The primary title of the item. + id: + anyOf: + - string + - number + description: Citation identifier for the item (e.g. "item1"). Will be autogenerated if not provided. + +- id: citation-item + object: + super: + resolveRef: csl-item + closed: true + properties: + article-id: + maybeArrayOf: + anyOf: + - string + - object: + properties: + type: + string: + description: The type of identifier + value: + string: + description: The value for the identifier + description: The unique identifier for this article. + elocation-id: + string: + description: Bibliographic identifier for a document that does not have traditional printed page numbers. + eissn: + string: + description: Electronic International Standard Serial Number. + pissn: + string: + description: Print International Standard Serial Number. + art-access-id: + string: + description: Generic article accession identifier. + publisher-location: + string: + description: The location of the publisher of this item. + subject: + string: + description: The name of a subject or topic describing the article. + categories: + maybeArrayOf: + string: + description: A list of subjects or topics describing the article. + container-id: + maybeArrayOf: + anyOf: + - string + - object: + properties: + type: + string: + description: The type of identifier (e.g. `nlm-ta` or `pmc`). + value: + string: + description: The value for the identifier + description: + short: External identifier of a publication or journal. + long: | + External identifier, typically assigned to a journal by + a publisher, archive, or library to provide a unique identifier for + the journal or publication. + jats-type: + string: + description: The type used for the JATS `article` tag. + +- id: smart-include + anyOf: + - record: + text: + string: + description: Textual content to add to includes + - record: + file: + string: + description: Name of file with content to add to includes + +- id: semver + string: + # from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + pattern: "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + description: Version number according to Semantic Versioning + +- id: quarto-date + anyOf: + - string + - object: + closed: true + properties: + format: string + value: string + required: [value] + +- id: project-profile + schema: + object: + closed: true + properties: + default: + maybeArrayOf: string + description: | + Default profile to apply if QUARTO_PROFILE is not defined. + group: + maybeArrayOf: + arrayOf: string + description: | + Define a profile group for which at least one profile is always active. + description: Specify a default profile and profile groups + +- id: bad-parse-schema + schema: + object: + propertyNames: + string: + pattern: "^[^\\s]+$" + +- id: quarto-dev-schema + schema: + object: + properties: + _quarto: + hidden: true + object: + properties: + trace-filters: string + tests: object + +- id: notebook-view-schema + schema: + object: + properties: + notebook: + string: + description: "The path to the locally referenced notebook." + title: + description: "The title of the notebook when viewed." + anyOf: + - string + - boolean + url: + string: + description: "The url to use when viewing this notebook." + download-url: + string: + description: "The url to use when downloading the notebook from the preview" + required: [notebook] + +- id: code-links-schema + schema: + anyOf: + - boolean + - maybeArrayOf: + anyOf: + - object: + properties: + icon: + string: + description: The bootstrap icon for this code link. + text: + string: + description: The text for this code link. + href: + string: + description: The href for this code link. + rel: + string: + description: The rel used in the `a` tag for this code link. + target: + string: + description: The target used in the `a` tag for this code link. + - enum: ["repo", "binder", "devcontainer"] + +- id: manuscript-schema + schema: + object: + closed: true + properties: + "article": + path: + description: "The input document that will serve as the root document for this manuscript" + "code-links": + schema: + ref: code-links-schema + description: "Code links to display for this manuscript." + "manuscript-url": + string: + description: "The deployed url for this manuscript" + "meca-bundle": + anyOf: + - boolean + - string + description: "Whether to generate a MECA bundle for this manuscript" + "notebooks": + arrayOf: + anyOf: + - string + - ref: notebook-view-schema + "resources": + maybeArrayOf: + schema: path + description: "Additional file resources to be copied to output directory" + "environment": + maybeArrayOf: + schema: path + description: "Files that specify the execution environment (e.g. renv.lock, requirements.text, etc...)" + +- id: brand-meta + description: > + Metadata for a brand, including the brand name and important links. + object: + closed: false + properties: + name: + description: The brand name. + anyOf: + - string + - object: + properties: + full: + string: + description: The full, official or legal name of the company or brand. + short: + string: + description: The short, informal, or common name of the company or brand. + link: + description: > + Important links for the brand, including social media links. + If a single string, it is the brand's home page or website. + Additional fields are allowed for internal use. + anyOf: + - string + - object: + properties: + home: + string: + description: The brand's home page or website. + mastodon: + string: + description: The brand's Mastodon URL. + bluesky: + string: + description: The brand's Bluesky URL. + github: + string: + description: The brand's GitHub URL. + linkedin: + string: + description: The brand's LinkedIn URL. + twitter: + string: + description: The brand's Twitter URL. + facebook: + string: + description: The brand's Facebook URL. + +- id: brand-string-light-dark + anyOf: + - string + - object: + closed: true + properties: + light: + schema: string + description: > + A link or path to the brand's light-colored logo or icon. + dark: + schema: string + description: > + A link or path to the brand's dark-colored logo or icon. + +- id: brand-logo-explicit-resource + object: + closed: true + properties: + path: path + alt: + schema: string + description: > + Alternative text for the logo, used for accessibility. + required: [path] + +- id: brand-logo-resource + anyOf: + - string + - ref: brand-logo-explicit-resource + +- id: brand-logo-single + description: > + Provide definitions and defaults for brand's logo in various formats and sizes. + object: + closed: true + properties: + images: + description: A dictionary of named logo resources. + schema: + object: + additionalProperties: + schema: { ref: brand-logo-resource } + small: + description: > + A link or path to the brand's small-sized logo or icon. + schema: string + medium: + description: > + A link or path to the brand's medium-sized logo. + schema: string + large: + description: > + A link or path to the brand's large- or full-sized logo. + schema: string + +- id: brand-logo-unified + description: > + Provide definitions and defaults for brand's logo in various formats and sizes. + object: + closed: true + properties: + images: + description: A dictionary of named logo resources. + schema: + object: + additionalProperties: + schema: { ref: brand-logo-resource } + small: + description: > + A link or path to the brand's small-sized logo or icon, or a link or path + to both the light and dark versions. + schema: + ref: brand-string-light-dark + medium: + description: > + A link or path to the brand's medium-sized logo, or a link or path + to both the light and dark versions. + schema: + ref: brand-string-light-dark + large: + description: > + A link or path to the brand's large- or full-sized logo, or a link or path + to both the light and dark versions. + schema: + ref: brand-string-light-dark + +- id: brand-named-logo + description: Names of customizeable logos + enum: [small, medium, large] + +- id: logo-options + object: + closed: false # e.g. to allow typst location, padding, padding-*, width + properties: + path: + schema: path + description: > + Path or brand.yml logo resource name. + alt: + schema: string + description: > + Alternative text for the logo, used for accessibility. + required: [path] + +- id: logo-specifier + anyOf: + - string + - schema: + ref: logo-options + +- id: logo-options-path-optional + object: + closed: false # e.g. to allow typst location, padding, padding-*, width + properties: + path: + schema: path + description: > + Path or brand.yml logo resource name. + alt: + schema: string + description: > + Alternative text for the logo, used for accessibility. + +- id: logo-specifier-path-optional + anyOf: + - string + - schema: + ref: logo-options-path-optional + +- id: logo-light-dark-specifier + description: > + Any of the ways a logo can be specified: string, object, or light/dark object of + string or object + anyOf: + - ref: logo-specifier + - object: + closed: true + properties: + light: + schema: + ref: logo-specifier + description: > + Specification of a light logo + dark: + schema: + ref: logo-specifier + description: > + Specification of a dark logo + +- id: logo-light-dark-specifier-path-optional + description: > + Any of the ways a logo can be specified: string, object, or light/dark object of + string or object + anyOf: + - ref: logo-specifier-path-optional + - object: + closed: true + properties: + light: + schema: + ref: logo-specifier-path-optional + description: > + Specification of a light logo + dark: + schema: + ref: logo-specifier-path-optional + description: > + Specification of a dark logo + +# normalized version of logo-light-dark-specifier +- id: normalized-logo-light-dark-specifier + description: > + Any of the ways a logo can be specified: string, object, or light/dark object of + string or object + object: + closed: true + properties: + light: + schema: + ref: logo-options + description: > + Options for a light logo + dark: + schema: + ref: logo-options + description: > + Options for a dark logo + +- id: brand-color-value + schema: string + +- id: brand-color-single + description: > + The brand's custom color palette and theme. + object: + closed: true + properties: + palette: + description: > + The brand's custom color palette. Any number of colors can be defined, + each color having a custom name. + object: + additionalProperties: + schema: + ref: brand-color-value + foreground: + description: The foreground color, used for text. + schema: + ref: brand-color-value + default: black + background: + description: The background color, used for the page background. + schema: + ref: brand-color-value + default: white + primary: + description: > + The primary accent color, i.e. the main theme color. Typically used for + hyperlinks, active states, primary action buttons, etc. + schema: + ref: brand-color-value + secondary: + description: > + The secondary accent color. Typically used for lighter text or disabled states. + schema: + ref: brand-color-value + tertiary: + description: > + The tertiary accent color. Typically an even lighter color, used for hover states, + accents, and wells. + schema: + ref: brand-color-value + success: + description: The color used for positive or successful actions and information. + schema: + ref: brand-color-value + info: + description: The color used for neutral or informational actions and information. + schema: + ref: brand-color-value + warning: + description: The color used for warning or cautionary actions and information. + schema: + ref: brand-color-value + danger: + description: The color used for errors, dangerous actions, or negative information. + schema: + ref: brand-color-value + light: + description: > + A bright color, used as a high-contrast foreground color on dark elements + or low-contrast background color on light elements. + schema: + ref: brand-color-value + dark: + description: > + A dark color, used as a high-contrast foreground color on light elements + or high-contrast background color on light elements. + schema: + ref: brand-color-value + link: + description: > + The color used for hyperlinks. If not defined, the `primary` color is used. + schema: + ref: brand-color-value + +- id: brand-color-light-dark + anyOf: + - ref: brand-color-value + - object: + closed: true + properties: + light: + schema: + ref: brand-color-value + description: > + A link or path to the brand's light-colored logo or icon. + dark: + schema: + ref: brand-color-value + description: > + A link or path to the brand's dark-colored logo or icon. + +- id: brand-color-unified + description: > + The brand's custom color palette and theme. + object: + closed: true + properties: + palette: + description: > + The brand's custom color palette. Any number of colors can be defined, + each color having a custom name. + object: + additionalProperties: + schema: + ref: brand-color-value + foreground: + description: The foreground color, used for text. + schema: + ref: brand-color-light-dark + default: black + background: + description: The background color, used for the page background. + schema: + ref: brand-color-light-dark + default: white + primary: + description: > + The primary accent color, i.e. the main theme color. Typically used for + hyperlinks, active states, primary action buttons, etc. + schema: + ref: brand-color-light-dark + secondary: + description: > + The secondary accent color. Typically used for lighter text or disabled states. + schema: + ref: brand-color-light-dark + tertiary: + description: > + The tertiary accent color. Typically an even lighter color, used for hover states, + accents, and wells. + schema: + ref: brand-color-light-dark + success: + description: The color used for positive or successful actions and information. + schema: + ref: brand-color-light-dark + info: + description: The color used for neutral or informational actions and information. + schema: + ref: brand-color-light-dark + warning: + description: The color used for warning or cautionary actions and information. + schema: + ref: brand-color-light-dark + danger: + description: The color used for errors, dangerous actions, or negative information. + schema: + ref: brand-color-light-dark + light: + description: > + A bright color, used as a high-contrast foreground color on dark elements + or low-contrast background color on light elements. + schema: + ref: brand-color-light-dark + dark: + description: > + A dark color, used as a high-contrast foreground color on light elements + or high-contrast background color on light elements. + schema: + ref: brand-color-light-dark + link: + description: > + The color used for hyperlinks. If not defined, the `primary` color is used. + schema: + ref: brand-color-light-dark + +- id: brand-maybe-named-color + description: > + A color, which may be a named brand color. + anyOf: + - ref: brand-named-theme-color + - schema: string + +- id: brand-maybe-named-color-light-dark + anyOf: + - ref: brand-maybe-named-color + - object: + closed: true + properties: + light: + schema: + ref: brand-maybe-named-color + description: > + A link or path to the brand's light-colored logo or icon. + dark: + schema: + ref: brand-maybe-named-color + description: > + A link or path to the brand's dark-colored logo or icon. + +- id: brand-named-theme-color + description: > + A named brand color, taken either from `color.theme` or `color.palette` (in that order). + enum: + [ + foreground, + background, + primary, + secondary, + tertiary, + success, + info, + warning, + danger, + light, + dark, + link, + ] + +- id: brand-typography-single + description: Typography definitions for the brand. + object: + closed: true + properties: + fonts: + description: Font files and definitions for the brand. + arrayOf: + ref: brand-font + base: + description: > + The base font settings for the brand. These are used as the default for + all text. + ref: brand-typography-options-base + headings: + description: Settings for headings, or a string specifying the font family only. + ref: brand-typography-options-headings-single + monospace: + description: Settings for monospace text, or a string specifying the font family only. + ref: brand-typography-options-monospace-single + monospace-inline: + description: Settings for inline code, or a string specifying the font family only. + ref: brand-typography-options-monospace-inline-single + monospace-block: + description: Settings for code blocks, or a string specifying the font family only. + ref: brand-typography-options-monospace-block-single + link: + description: Settings for links. + ref: brand-typography-options-link-single + +- id: brand-typography-unified + description: Typography definitions for the brand. + object: + closed: true + properties: + fonts: + description: Font files and definitions for the brand. + arrayOf: + ref: brand-font + base: + description: > + The base font settings for the brand. These are used as the default for + all text. + ref: brand-typography-options-base + headings: + description: Settings for headings, or a string specifying the font family only. + ref: brand-typography-options-headings-unified + monospace: + description: Settings for monospace text, or a string specifying the font family only. + ref: brand-typography-options-monospace-unified + monospace-inline: + description: Settings for inline code, or a string specifying the font family only. + ref: brand-typography-options-monospace-inline-unified + monospace-block: + description: Settings for code blocks, or a string specifying the font family only. + ref: brand-typography-options-monospace-block-unified + link: + description: Settings for links. + ref: brand-typography-options-link-unified + +- id: brand-typography-options-base + description: Base typographic options. + anyOf: + - string + - object: + closed: true + properties: + family: string + size: string + weight: + ref: brand-font-weight + line-height: + ref: line-height-number-string + +- id: brand-typography-options-headings-single + description: Typographic options for headings. + anyOf: + - string + - object: + closed: true + properties: + family: string + weight: + ref: brand-font-weight + style: + ref: brand-font-style + color: + ref: brand-maybe-named-color + line-height: + ref: line-height-number-string + +- id: brand-typography-options-headings-unified + description: Typographic options for headings. + anyOf: + - string + - object: + closed: true + properties: + family: string + weight: + ref: brand-font-weight + style: + ref: brand-font-style + color: + ref: brand-maybe-named-color-light-dark + line-height: + ref: line-height-number-string + +- id: brand-typography-options-monospace-single + description: Typographic options for monospace elements. + anyOf: + - string + - object: + closed: true + properties: + family: string + size: string + weight: + ref: brand-font-weight + color: + ref: brand-maybe-named-color + background-color: + ref: brand-maybe-named-color + +- id: brand-typography-options-monospace-unified + description: Typographic options for monospace elements. + anyOf: + - string + - object: + closed: true + properties: + family: string + size: string + weight: + ref: brand-font-weight + color: + ref: brand-maybe-named-color-light-dark + background-color: + ref: brand-maybe-named-color-light-dark + +- id: brand-typography-options-monospace-inline-single + description: Typographic options for inline monospace elements. + anyOf: + - string + - object: + closed: true + properties: + family: string + size: string + weight: + ref: brand-font-weight + color: + ref: brand-maybe-named-color + background-color: + ref: brand-maybe-named-color + +- id: brand-typography-options-monospace-inline-unified + description: Typographic options for inline monospace elements. + anyOf: + - string + - object: + closed: true + properties: + family: string + size: string + weight: + ref: brand-font-weight + color: + ref: brand-maybe-named-color-light-dark + background-color: + ref: brand-maybe-named-color-light-dark + +- id: line-height-number-string + description: Line height + anyOf: [number, string] + +- id: brand-typography-options-monospace-block-single + description: Typographic options for block monospace elements. + anyOf: + - string + - object: + closed: true + properties: + family: string + size: string + weight: + ref: brand-font-weight + color: + ref: brand-maybe-named-color + background-color: + ref: brand-maybe-named-color + line-height: + ref: line-height-number-string + +- id: brand-typography-options-monospace-block-unified + description: Typographic options for block monospace elements. + anyOf: + - string + - object: + closed: true + properties: + family: string + size: string + weight: + ref: brand-font-weight + color: + ref: brand-maybe-named-color-light-dark + background-color: + ref: brand-maybe-named-color-light-dark + line-height: + ref: line-height-number-string + +- id: brand-typography-options-link-single + description: Typographic options for inline monospace elements. + anyOf: + - string + - object: + closed: true + properties: + weight: + ref: brand-font-weight + color: + ref: brand-maybe-named-color + background-color: + ref: brand-maybe-named-color + decoration: string + +- id: brand-typography-options-link-unified + description: Typographic options for inline monospace elements. + anyOf: + - string + - object: + closed: true + properties: + weight: + ref: brand-font-weight + color: + ref: brand-maybe-named-color-light-dark + background-color: + ref: brand-maybe-named-color-light-dark + decoration: string + +- id: brand-named-typography-elements + description: Names of customizeable typography elements + enum: [base, headings, monospace, monospace-inline, monospace-block, link] + +- id: brand-font + description: Font files and definitions for the brand. + anyOf: + - ref: brand-font-google + - ref: brand-font-bunny + - ref: brand-font-file + - ref: brand-font-system + # a font definition missing source information, + # from which we will assume a default source + # + # in Quarto, the default source for typst is `google` + # and the default source for html formats is `bunny` + +- id: brand-font-weight + description: A font weight. + enum: + [ + 100, + 200, + 300, + 400, + 500, + 600, + 700, + 800, + 900, + thin, + extra-light, + ultra-light, + light, + normal, + regular, + medium, + semi-bold, + demi-bold, + bold, + extra-bold, + ultra-bold, + black, + ] + default: 400 + +- id: brand-font-style + description: A font style. + enum: [normal, italic, oblique] + default: normal + +- id: brand-font-common + schema: + object: + closed: true + properties: + family: + description: The font family name, which must match the name of the font on the foundry website. + schema: string + weight: + description: The font weights to include. + maybeArrayOf: + ref: brand-font-weight + default: [400, 700] + style: + description: The font styles to include. + maybeArrayOf: + ref: brand-font-style + default: [normal, italic] + display: + description: > + The font display method, determines how a font face is font face is shown + depending on its download status and readiness for use. + enum: [auto, block, swap, fallback, optional] + default: swap + +- id: brand-font-system + description: A system font definition. + object: + super: + resolveRef: brand-font-common + closed: true + properties: + source: + enum: [system] + required: [source] + +- id: brand-font-google + description: A font definition from Google Fonts. + object: + super: + resolveRef: brand-font-common + closed: true + properties: + source: + enum: [google] + required: [source] + +- id: brand-font-bunny + description: A font definition from fonts.bunny.net. + object: + super: + resolveRef: brand-font-common + closed: true + properties: + source: + enum: [bunny] + required: [source] + +- id: brand-font-file + description: A method for providing font files directly, either locally or from an online location. + object: + closed: true + properties: + source: + enum: [file] + family: + description: The font family name. + schema: string + files: + arrayOf: + anyOf: + - path + - schema: + object: + properties: + path: + schema: path + description: > + The path to the font file. This can be a local path or a URL. + weight: + ref: brand-font-weight + style: + ref: brand-font-style + required: + [path] + # We might want to eventually support these but not on 1.0 + # + # display: + # description: > + # The font display method, determines how a font face is font face is shown + # depending on its download status and readiness for use. + # enum: [auto, block, swap, fallback, optional] + # default: swap + # unicode-range: + # description: > + # The range of unicode characters included in the font. Examples: + # - `U+0025-00FF` includes all characters from `U+0025` to `U+00FF`. + # - `U+0131,U+0152-0153` includes the characters `U+0131`, `U+0152`, and `U+0153`. + # schema: + # string: + # pattern: '^U\+[0-9A-F]{4}(?:-[0-9A-F]{4})?(?:,U\+[0-9A-F]{4}(?:-[0-9A-F]{4})?)*$' + description: > + The font files to include. These can be local or online. + Local file paths should be relative to the `brand.yml` file. + Online paths should be complete URLs. + required: [files, family, source] + +- id: brand-font-family + description: > + A locally-installed font family name. When used, the end-user is responsible + for ensuring that the font is installed on their system. + schema: string + +- id: brand-single + object: + closed: true + properties: + meta: + ref: brand-meta + logo: + ref: brand-logo-single + color: + ref: brand-color-single + typography: + ref: brand-typography-single + defaults: + ref: brand-defaults + +- id: brand-unified + object: + closed: true + properties: + meta: + ref: brand-meta + logo: + ref: brand-logo-unified + color: + ref: brand-color-unified + typography: + ref: brand-typography-unified + defaults: + ref: brand-defaults + +- id: brand-path-only-light-dark + anyOf: + - string + - object: + closed: true + properties: + light: string + dark: string + description: | + A path to a brand.yml file, or an object with light and dark paths to brand.yml + +- id: brand-path-bool-light-dark + anyOf: + - string # a file path + - boolean # if false, don't use branding on this document + - object: + closed: true + properties: + light: + anyOf: + - string + - ref: brand-single + description: > + The path to a light brand file or an inline light brand definition. + dark: + anyOf: + - string + - ref: brand-single + description: > + The path to a dark brand file or an inline dark brand definition. + - ref: brand-unified + description: | + Branding information to use for this document. If a string, the path to a brand file. + If false, don't use branding on this document. If an object, an inline (unified) brand + definition, or an object with light and dark brand paths or definitions. + +- id: brand-defaults + object: + properties: + bootstrap: + ref: brand-defaults-bootstrap + quarto: + schema: object + +- id: brand-defaults-bootstrap + object: + properties: + defaults: + schema: + object: + additionalProperties: + schema: + anyOf: + - string + - boolean + - number +# - id: quarto-extension diff --git a/crates/quarto-markdown-pandoc/test-fixtures/schemas/document-render.yml b/crates/quarto-markdown-pandoc/test-fixtures/schemas/document-render.yml new file mode 100644 index 0000000..dd7fc9f --- /dev/null +++ b/crates/quarto-markdown-pandoc/test-fixtures/schemas/document-render.yml @@ -0,0 +1,234 @@ +- name: from + alias: reader + schema: string + default: markdown + description: + short: "Format to read from" + long: | + Format to read from. Extensions can be individually enabled or disabled by appending +EXTENSION or -EXTENSION to the format name (e.g. markdown+emoji). + +- name: output-file + schema: + ref: pandoc-format-output-file + default: "Input filename with output extension (e.g. .pdf, .html, etc.)" + description: "Output file to write to" + +- name: output-ext + schema: string + description: | + Extension to use for generated output file + +- name: template + disabled: [$office-all, ipynb] + schema: path + description: | + Use the specified file as a custom template for the generated document. + +- name: template-partials + disabled: [$office-all, ipynb] + schema: + maybeArrayOf: path + description: | + Include the specified files as partials accessible to the template for the generated content. + +- name: embed-resources + tags: + formats: [$html-files] + schema: boolean + default: false + description: + short: "Produce a standalone HTML file with no external dependencies" + long: | + Produce a standalone HTML file with no external dependencies, using + `data:` URIs to incorporate the contents of linked scripts, stylesheets, + images, and videos. The resulting file should be "self-contained," in the + sense that it needs no external files and no net access to be displayed + properly by a browser. This option works only with HTML output formats, + including `html4`, `html5`, `html+lhs`, `html5+lhs`, `s5`, `slidy`, + `slideous`, `dzslides`, and `revealjs`. Scripts, images, and stylesheets at + absolute URLs will be downloaded; those at relative URLs will be sought + relative to the working directory (if the first source + file is local) or relative to the base URL (if the first source + file is remote). Elements with the attribute + `data-external="1"` will be left alone; the documents they + link to will not be incorporated in the document. + Limitation: resources that are loaded dynamically through + JavaScript cannot be incorporated; as a result, some + advanced features (e.g. zoom or speaker notes) may not work + in an offline "self-contained" `reveal.js` slide show. + +- name: self-contained + tags: + formats: [$html-files] + schema: boolean + default: false + hidden: true + description: + short: "Produce a standalone HTML file with no external dependencies" + long: | + Produce a standalone HTML file with no external dependencies. Note that + this option has been deprecated in favor of `embed-resources`. + +- name: self-contained-math + tags: + formats: [$html-files] + schema: boolean + default: false + description: + short: "Embed math libraries (e.g. MathJax) within `self-contained` output." + long: | + Embed math libraries (e.g. MathJax) within `self-contained` output. + Note that math libraries are not embedded by default because they are + quite large and often time consuming to download. + +- name: filters + schema: + ref: pandoc-format-filters + description: | + Specify executables or Lua scripts to be used as a filter transforming + the pandoc AST after the input is parsed and before the output is written. + +- name: shortcodes + schema: + ref: pandoc-shortcodes + description: | + Specify Lua scripts that implement shortcode handlers + +- name: keep-md + schema: boolean + default: false + tags: + contexts: [document-execute] + description: "Keep the markdown file generated by executing code" + +- name: keep-ipynb + schema: boolean + default: false + tags: + contexts: [document-execute] + description: "Keep the notebook file generated from executing code." + +- name: ipynb-filters + schema: + arrayOf: "string" + tags: + contexts: [document-execute] + description: "Filters to pre-process ipynb files before rendering to markdown" + +- name: ipynb-shell-interactivity + schema: + enum: [null, all, last, last_expr, none, last_expr_or_assign] + tags: + contexts: [document-execute] + engine: jupyter + description: | + Specify which nodes should be run interactively (displaying output from expressions) + +- name: plotly-connected + schema: boolean + default: false + tags: + contexts: [document-execute] + engine: jupyter + description: | + If true, use the "notebook_connected" plotly renderer, which downloads + its dependencies from a CDN and requires an internet connection to view. + +- name: keep-typ + tags: + formats: [typst] + schema: boolean + default: false + description: "Keep the intermediate typst file used during render." + +- name: keep-tex + tags: + formats: [pdf, beamer] + schema: boolean + default: false + description: "Keep the intermediate tex file used during render." + +- name: extract-media + schema: path + description: + short: | + Extract images and other media contained in or linked from the source document to the + path DIR. + long: | + Extract images and other media contained in or linked from the source document to the + path DIR, creating it if necessary, and adjust the images references in the document + so they point to the extracted files. Media are downloaded, read from the file + system, or extracted from a binary container (e.g. docx), as needed. The original + file paths are used if they are relative paths not containing ... Otherwise filenames + are constructed from the SHA1 hash of the contents. + +- name: resource-path + schema: + arrayOf: path + default: ["."] + description: | + List of paths to search for images and other resources. + +- name: default-image-extension + schema: string + description: + short: | + Specify a default extension to use when image paths/URLs have no extension. + long: | + Specify a default extension to use when image paths/URLs have no + extension. This allows you to use the same source for formats that + require different kinds of images. Currently this option only affects + the Markdown and LaTeX readers. + +- name: abbreviations + schema: string + description: + short: | + Specifies a custom abbreviations file, with abbreviations one to a line. + long: | + Specifies a custom abbreviations file, with abbreviations one to a line. + This list is used when reading Markdown input: strings found in this list + will be followed by a nonbreaking space, and the period will not produce sentence-ending space in formats like LaTeX. The strings may not contain + spaces. + +- name: dpi + schema: number + default: 96 + description: + short: | + Specify the default dpi (dots per inch) value for conversion from pixels to inch/ + centimeters and vice versa. + long: | + Specify the default dpi (dots per inch) value for conversion from pixels to inch/ + centimeters and vice versa. (Technically, the correct term would be ppi: pixels per + inch.) The default is `96`. When images contain information about dpi internally, the + encoded value is used instead of the default specified by this option. + +- name: html-table-processing + schema: + enum: [none] + description: If `none`, do not process tables in HTML input. + +- name: html-pre-tag-processing + tags: + formats: [typst] + schema: + enum: [none, parse] + description: If `none`, ignore any divs with `html-pre-tag-processing=parse` enabled. + +- name: css-property-processing + tags: + formats: [typst] + schema: + enum: [none, translate] + default: translate + description: + short: CSS property translation + long: If `translate`, translate CSS properties into output format properties. If `none`, do not process css properties. + +- name: use-rsvg-convert + schema: boolean + default: true + tags: + formats: [$pdf-all] + description: If `true`, attempt to use `rsvg-convert` to convert SVG images to PDF. diff --git a/crates/quarto-markdown-pandoc/test-fixtures/schemas/document-text.yml b/crates/quarto-markdown-pandoc/test-fixtures/schemas/document-text.yml new file mode 100644 index 0000000..9fd1205 --- /dev/null +++ b/crates/quarto-markdown-pandoc/test-fixtures/schemas/document-text.yml @@ -0,0 +1,109 @@ +- name: wrap + tags: + formats: + ["!$pdf-all", "!$office-all", "!$odt-all", "!$html-all", "!$docbook-all"] + schema: + enum: [auto, none, preserve] + default: auto + description: + short: "Determine how text is wrapped in the output (`auto`, `none`, or `preserve`)." + long: | + Determine how text is wrapped in the output (the source code, not the rendered + version). + + - `auto` (default): Pandoc will attempt to wrap lines to the column width specified by `columns` (default 72). + - `none`: Pandoc will not wrap lines at all. + - `preserve`: Pandoc will attempt to preserve the wrapping from the source + document. Where there are nonsemantic newlines in the source, there will be + nonsemantic newlines in the output as well. + +- name: columns + tags: + formats: + [ + "!$pdf-all", + "!$office-all", + "!$odt-all", + "!$html-all", + "!$docbook-all", + "typst", + ] + schema: number + description: + short: For text formats, specify length of lines in characters. For `typst`, number of columns for body text. + long: | + Specify length of lines in characters. This affects text wrapping in generated source + code (see `wrap`). It also affects calculation of column widths for plain text + tables. + + For `typst`, number of columns for body text. + +- name: tab-stop + tags: + formats: + ["!$pdf-all", "!$office-all", "!$odt-all", "!$html-all", "!$docbook-all"] + schema: number + description: + short: "Specify the number of spaces per tab (default is 4)." + long: | + Specify the number of spaces per tab (default is 4). Note that tabs + within normal textual input are always converted to spaces. Tabs + within code are also converted, however this can be disabled with + `preserve-tabs: false`. + +- name: preserve-tabs + tags: + formats: + ["!$pdf-all", "!$office-all", "!$odt-all", "!$html-all", "!$docbook-all"] + schema: boolean + default: false + description: + short: | + Preserve tabs within code instead of converting them to spaces. + long: | + Preserve tabs within code instead of converting them to spaces. + (By default, pandoc converts tabs to spaces before parsing its input.) + Note that this will only affect tabs in literal code spans and code blocks. + Tabs in regular text are always treated as spaces. + +- name: eol + tags: + formats: + ["!$pdf-all", "!$office-all", "!$odt-all", "!$html-all", "!$docbook-all"] + schema: + enum: [lf, crlf, native] + description: + short: "Manually specify line endings (`lf`, `crlf`, or `native`)." + long: | + Manually specify line endings: + + - `crlf`: Use Windows line endings + - `lf`: Use macOS/Linux/UNIX line endings + - `native` (default): Use line endings appropriate to the OS on which pandoc is being run). + +- name: strip-comments + schema: boolean + tags: + formats: [$markdown-all, textile, $html-files] + description: + short: "Strip out HTML comments in source, rather than passing them on to output." + long: | + Strip out HTML comments in the Markdown source, + rather than passing them on to Markdown, Textile or HTML + output as raw HTML. This does not apply to HTML comments + inside raw HTML blocks when the `markdown_in_html_blocks` + extension is not set. + +- name: ascii + tags: + formats: [$html-all, $pdf-all, $markdown-all, ms] + schema: boolean + description: + short: "Use only ASCII characters in output." + long: | + Use only ASCII characters in output. Currently supported for XML + and HTML formats (which use entities instead of UTF-8 when this + option is selected), CommonMark, gfm, and Markdown (which use + entities), roff ms (which use hexadecimal escapes), and to a + limited degree LaTeX (which uses standard commands for accented + characters when possible). roff man output uses ASCII by default. diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/json/horizontal-rules-vs-metadata.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/json/horizontal-rules-vs-metadata.qmd new file mode 100644 index 0000000..4a9d875 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/json/horizontal-rules-vs-metadata.qmd @@ -0,0 +1,10 @@ +--- +title: Test Document +author: Test Author +--- + +Content paragraph after metadata. + +--- + +Second paragraph after horizontal rule. diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/json/horizontal-rules.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/json/horizontal-rules.qmd new file mode 100644 index 0000000..a7fd9ec --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/json/horizontal-rules.qmd @@ -0,0 +1,9 @@ +First paragraph before the rule. + +--- + +Second paragraph after the rule. + +--- + +Third paragraph. diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/horizontal-rules-vs-metadata.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/native/horizontal-rules-vs-metadata.qmd new file mode 100644 index 0000000..4a9d875 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/horizontal-rules-vs-metadata.qmd @@ -0,0 +1,10 @@ +--- +title: Test Document +author: Test Author +--- + +Content paragraph after metadata. + +--- + +Second paragraph after horizontal rule. diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/native/horizontal-rules.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/native/horizontal-rules.qmd new file mode 100644 index 0000000..a7fd9ec --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/native/horizontal-rules.qmd @@ -0,0 +1,9 @@ +First paragraph before the rule. + +--- + +Second paragraph after the rule. + +--- + +Third paragraph. diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/qmd/horizontal-rules-vs-metadata.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/qmd/horizontal-rules-vs-metadata.qmd new file mode 100644 index 0000000..4a9d875 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/qmd/horizontal-rules-vs-metadata.qmd @@ -0,0 +1,10 @@ +--- +title: Test Document +author: Test Author +--- + +Content paragraph after metadata. + +--- + +Second paragraph after horizontal rule. diff --git a/crates/quarto-markdown-pandoc/tests/snapshots/qmd/horizontal-rules.qmd b/crates/quarto-markdown-pandoc/tests/snapshots/qmd/horizontal-rules.qmd new file mode 100644 index 0000000..a7fd9ec --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/snapshots/qmd/horizontal-rules.qmd @@ -0,0 +1,9 @@ +First paragraph before the rule. + +--- + +Second paragraph after the rule. + +--- + +Third paragraph. diff --git a/crates/quarto-markdown-pandoc/tests/test.rs b/crates/quarto-markdown-pandoc/tests/test.rs index 26fad61..a6e8323 100644 --- a/crates/quarto-markdown-pandoc/tests/test.rs +++ b/crates/quarto-markdown-pandoc/tests/test.rs @@ -686,6 +686,56 @@ fn test_qmd_roundtrip_consistency() { } } +#[test] +fn test_ansi_writer_smoke() { + // Smoke test: read markdown, produce AST, write ANSI output + // Just verifying that the code runs without panicking + let mut file_count = 0; + for entry in glob("tests/writers/ansi/*.qmd").expect("Failed to read glob pattern") { + match entry { + Ok(path) => { + eprintln!("Testing ANSI writer on: {}", path.display()); + let markdown = std::fs::read_to_string(&path).expect("Failed to read file"); + + // Parse with our qmd reader to get AST + let doc_result = readers::qmd::read( + markdown.as_bytes(), + false, + path.to_str().unwrap(), + &mut std::io::sink(), + ); + + match doc_result { + Ok((doc, _context, _warnings)) => { + // Write it out using the ANSI writer + let mut buf = Vec::new(); + writers::ansi::write(&doc, &mut buf).expect("Failed to write ANSI"); + + // Convert to string to ensure it's valid UTF-8 + let output = + String::from_utf8(buf).expect("Invalid UTF-8 in ANSI writer output"); + + // Verify output contains ANSI escape codes or plain text + // (depends on whether colors are enabled) + assert!(!output.is_empty(), "ANSI output should not be empty"); + } + Err(_) => { + // Skip files that have parse errors - they may be testing error cases + eprintln!("Skipping {} due to parse error", path.display()); + } + } + + file_count += 1; + } + Err(e) => panic!("Error reading glob entry: {}", e), + } + } + assert!( + file_count > 0, + "No files found in tests/writers/ansi directory" + ); +} + #[test] fn test_empty_blockquote_roundtrip() { // Specific test for empty blockquote roundtrip consistency diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/ansi-palette.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/ansi-palette.qmd new file mode 100644 index 0000000..bb32d5d --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/ansi-palette.qmd @@ -0,0 +1 @@ +ANSI palette: [bright red]{color="ansi(196)"} and [purple]{color="ansi-129"}. diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/basic-colors.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/basic-colors.qmd new file mode 100644 index 0000000..0288475 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/basic-colors.qmd @@ -0,0 +1,3 @@ +This is [red text]{color="red"} and [blue text]{color="blue"}. + +Here is [white on red]{color="white" background-color="red"} background. diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/bullet-lists.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/bullet-lists.qmd new file mode 100644 index 0000000..ce129da --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/bullet-lists.qmd @@ -0,0 +1,12 @@ +* First item +* Second item +* Third item + +Nested list: + +* Outer one + - Inner one + - Inner two + + Deep one + + Deep two +* Outer two diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/code-in-colored-span.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/code-in-colored-span.qmd new file mode 100644 index 0000000..3fd5b6b --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/code-in-colored-span.qmd @@ -0,0 +1,5 @@ +[Colored `code` text]{color="green"} and `normal code`. + +[Text with `code` and **bold** and *italic*]{color="cyan"} after. + +[Multiple `code blocks` in `one span`]{color="magenta"} works. diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/colors-with-formatting.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/colors-with-formatting.qmd new file mode 100644 index 0000000..7b201e8 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/colors-with-formatting.qmd @@ -0,0 +1,7 @@ +Regular text with **[bold red]{color="red"}** and *[italic blue]{color="blue"}*. + +[Colored `code` text]{color="green"} and `normal code`. + +Nested: **bold with [red part]{color="red"} inside**. + +[Multiple styles]{color="yellow" background-color="dark-blue"} work together. diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/definition-lists-complex.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/definition-lists-complex.json new file mode 100644 index 0000000..f0cd3e3 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/definition-lists-complex.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"DefinitionList","c":[[[{"t":"Str","c":"Term"},{"t":"Space"},{"t":"Str","c":"with"},{"t":"Space"},{"t":"Strong","c":[{"t":"Str","c":"formatting"}]}],[[{"t":"Plain","c":[{"t":"Str","c":"Simple"},{"t":"Space"},{"t":"Str","c":"definition"}]}],[{"t":"Para","c":[{"t":"Str","c":"Definition"},{"t":"Space"},{"t":"Str","c":"with"},{"t":"Space"},{"t":"Str","c":"multiple"},{"t":"Space"},{"t":"Str","c":"blocks."}]},{"t":"Para","c":[{"t":"Str","c":"Second"},{"t":"Space"},{"t":"Str","c":"paragraph."}]}]]]]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/definition-lists.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/definition-lists.json new file mode 100644 index 0000000..d99f4b9 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/definition-lists.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"DefinitionList","c":[[[{"t":"Str","c":"Term"},{"t":"Space"},{"t":"Str","c":"1"}],[[{"t":"Plain","c":[{"t":"Str","c":"Definition"},{"t":"Space"},{"t":"Str","c":"1a"}]}],[{"t":"Plain","c":[{"t":"Str","c":"Definition"},{"t":"Space"},{"t":"Str","c":"1b"}]}]]],[[{"t":"Str","c":"Term"},{"t":"Space"},{"t":"Str","c":"2"}],[[{"t":"Plain","c":[{"t":"Str","c":"Definition"},{"t":"Space"},{"t":"Str","c":"2"}]}]]]]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/div-colors.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/div-colors.qmd new file mode 100644 index 0000000..4d3f3b0 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/div-colors.qmd @@ -0,0 +1,13 @@ +::: {color="red"} +Red text in div +::: + +::: {color="blue" background-color="yellow"} +Blue text with yellow background +::: + +::: {color="red"} +Multiple paragraphs in red div. + +Second paragraph still red. +::: diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/header-indented.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-indented.json new file mode 100644 index 0000000..8dd1409 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-indented.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Para","c":[{"t":"Str","c":"Before"},{"t":"Space"},{"t":"Str","c":"blockquote."}]},{"t":"BlockQuote","c":[{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"Quoted"},{"t":"Space"},{"t":"Str","c":"Title"}]]},{"t":"Para","c":[{"t":"Str","c":"Some"},{"t":"Space"},{"t":"Str","c":"quoted"},{"t":"Space"},{"t":"Str","c":"content."}]}]},{"t":"Para","c":[{"t":"Str","c":"After"},{"t":"Space"},{"t":"Str","c":"blockquote."}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/header-long.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-long.json new file mode 100644 index 0000000..7dc8c69 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-long.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"This"},{"t":"Space"},{"t":"Str","c":"is"},{"t":"Space"},{"t":"Str","c":"a"},{"t":"Space"},{"t":"Str","c":"very"},{"t":"Space"},{"t":"Str","c":"long"},{"t":"Space"},{"t":"Str","c":"header"},{"t":"Space"},{"t":"Str","c":"title"},{"t":"Space"},{"t":"Str","c":"that"},{"t":"Space"},{"t":"Str","c":"exceeds"},{"t":"Space"},{"t":"Str","c":"the"},{"t":"Space"},{"t":"Str","c":"terminal"},{"t":"Space"},{"t":"Str","c":"width"},{"t":"Space"},{"t":"Str","c":"significantly"}]]},{"t":"Para","c":[{"t":"Str","c":"Some"},{"t":"Space"},{"t":"Str","c":"content."}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/header-overflow-indented.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-overflow-indented.json new file mode 100644 index 0000000..b471401 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-overflow-indented.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"BlockQuote","c":[{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"Short"}]]},{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}]]},{"t":"Para","c":[{"t":"Str","c":"Content."}]}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/header-overflow.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-overflow.json new file mode 100644 index 0000000..7460b2a --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-overflow.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"Short"}]]},{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"}]]},{"t":"Para","c":[{"t":"Str","c":"Content."}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/header-very-long.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-very-long.json new file mode 100644 index 0000000..f6bfaff --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/header-very-long.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"X"}]]},{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"This"},{"t":"Space"},{"t":"Str","c":"is"},{"t":"Space"},{"t":"Str","c":"exactly"},{"t":"Space"},{"t":"Str","c":"eighty"},{"t":"Space"},{"t":"Str","c":"characters"},{"t":"Space"},{"t":"Str","c":"long"},{"t":"Space"},{"t":"Str","c":"XXXXXXXXXXXXXXXXXXXXXXXXX"}]]},{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"This"},{"t":"Space"},{"t":"Str","c":"is"},{"t":"Space"},{"t":"Str","c":"exactly"},{"t":"Space"},{"t":"Str","c":"eighty"},{"t":"Space"},{"t":"Str","c":"characters"},{"t":"Space"},{"t":"Str","c":"long"},{"t":"Space"},{"t":"Str","c":"XXXXXXXXXXXXXXXXXXXXXXXXXX"}]]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/headers.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/headers.json new file mode 100644 index 0000000..f9e8ac8 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/headers.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Header","c":[1,["",[""],[]],[{"t":"Str","c":"Main"},{"t":"Space"},{"t":"Str","c":"Title"}]]},{"t":"Para","c":[{"t":"Str","c":"Some"},{"t":"Space"},{"t":"Str","c":"introductory"},{"t":"Space"},{"t":"Str","c":"text."}]},{"t":"Header","c":[2,["",[""],[]],[{"t":"Str","c":"Section"},{"t":"Space"},{"t":"Str","c":"Heading"}]]},{"t":"Para","c":[{"t":"Str","c":"Content"},{"t":"Space"},{"t":"Str","c":"here."}]},{"t":"Header","c":[3,["",[""],[]],[{"t":"Str","c":"Subsection"}]]},{"t":"Para","c":[{"t":"Str","c":"More"},{"t":"Space"},{"t":"Str","c":"content."}]},{"t":"Header","c":[4,["",[""],[]],[{"t":"Str","c":"Minor"},{"t":"Space"},{"t":"Str","c":"Heading"}]]},{"t":"Para","c":[{"t":"Str","c":"Details."}]},{"t":"Header","c":[5,["",[""],[]],[{"t":"Str","c":"Small"},{"t":"Space"},{"t":"Str","c":"Heading"}]]},{"t":"Para","c":[{"t":"Str","c":"Fine"},{"t":"Space"},{"t":"Str","c":"print."}]},{"t":"Header","c":[6,["",[""],[]],[{"t":"Str","c":"Tiny"},{"t":"Space"},{"t":"Str","c":"Heading"}]]},{"t":"Para","c":[{"t":"Str","c":"Even"},{"t":"Space"},{"t":"Str","c":"smaller."}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/horizontal-rule-indented.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/horizontal-rule-indented.json new file mode 100644 index 0000000..449ccbc --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/horizontal-rule-indented.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Para","c":[{"t":"Str","c":"Before"},{"t":"Space"},{"t":"Str","c":"blockquote."}]},{"t":"BlockQuote","c":[{"t":"Para","c":[{"t":"Str","c":"Quoted"},{"t":"Space"},{"t":"Str","c":"paragraph."}]},{"t":"HorizontalRule"},{"t":"Para","c":[{"t":"Str","c":"Another"},{"t":"Space"},{"t":"Str","c":"quoted"},{"t":"Space"},{"t":"Str","c":"paragraph."}]}]},{"t":"Para","c":[{"t":"Str","c":"After"},{"t":"Space"},{"t":"Str","c":"blockquote."}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/horizontal-rule.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/horizontal-rule.json new file mode 100644 index 0000000..9f00f40 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/horizontal-rule.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Para","c":[{"t":"Str","c":"First"},{"t":"Space"},{"t":"Str","c":"paragraph"},{"t":"Space"},{"t":"Str","c":"before"},{"t":"Space"},{"t":"Str","c":"the"},{"t":"Space"},{"t":"Str","c":"rule."}]},{"t":"HorizontalRule"},{"t":"Para","c":[{"t":"Str","c":"Second"},{"t":"Space"},{"t":"Str","c":"paragraph"},{"t":"Space"},{"t":"Str","c":"after"},{"t":"Space"},{"t":"Str","c":"the"},{"t":"Space"},{"t":"Str","c":"rule."}]},{"t":"HorizontalRule"},{"t":"Para","c":[{"t":"Str","c":"Third"},{"t":"Space"},{"t":"Str","c":"paragraph."}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/mixed-blocks.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/mixed-blocks.qmd new file mode 100644 index 0000000..2c6fb92 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/mixed-blocks.qmd @@ -0,0 +1,11 @@ +A paragraph. + +* List item one +* List item two + +Another paragraph. + +1. Ordered one +2. Ordered two + +Final paragraph. diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/nested-colored-spans.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/nested-colored-spans.qmd new file mode 100644 index 0000000..f674c3f --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/nested-colored-spans.qmd @@ -0,0 +1,3 @@ +[Outer green [inner red]{color="red"} back to green]{color="green"} + +[Outer with **bold** and [colored]{color="blue"} parts]{color="yellow"} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/ordered-lists.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/ordered-lists.qmd new file mode 100644 index 0000000..42ece21 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/ordered-lists.qmd @@ -0,0 +1,16 @@ +1. First item +2. Second item +3. Third item + +Wide numbers: + +100. Item one hundred +101. Item one hundred one +102. Item one hundred two + +Nested: + +1. Outer one + 1. Inner one + 2. Inner two +2. Outer two diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/paragraph-spacing.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/paragraph-spacing.qmd new file mode 100644 index 0000000..8f28367 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/paragraph-spacing.qmd @@ -0,0 +1,5 @@ +First paragraph. + +Second paragraph. + +Third paragraph with some text. diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/plain-consecutive.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/plain-consecutive.qmd new file mode 100644 index 0000000..5e1606c --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/plain-consecutive.qmd @@ -0,0 +1,5 @@ +::: columns +Plain text one +Plain text two +Plain text three +::: diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/raw-block.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/raw-block.json new file mode 100644 index 0000000..edb4903 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/raw-block.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Para","c":[{"t":"Str","c":"Before"}]},{"t":"RawBlock","c":["ansi","\u001b[32mGreen raw block\u001b[0m"]},{"t":"Para","c":[{"t":"Str","c":"Middle"}]},{"t":"RawBlock","c":["html","
HTML block
"]},{"t":"Para","c":[{"t":"Str","c":"After"}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/raw-inline.json b/crates/quarto-markdown-pandoc/tests/writers/ansi/raw-inline.json new file mode 100644 index 0000000..0504987 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/raw-inline.json @@ -0,0 +1 @@ +{"pandoc-api-version":[1,23,1],"meta":{},"blocks":[{"t":"Para","c":[{"t":"Str","c":"This"},{"t":"Space"},{"t":"Str","c":"has"},{"t":"Space"},{"t":"RawInline","c":["ansi","\u001b[31mred text\u001b[0m"]},{"t":"Space"},{"t":"Str","c":"and"},{"t":"Space"},{"t":"RawInline","c":["html","html"]},{"t":"Space"},{"t":"Str","c":"content."}]}]} diff --git a/crates/quarto-markdown-pandoc/tests/writers/ansi/rgb-colors.qmd b/crates/quarto-markdown-pandoc/tests/writers/ansi/rgb-colors.qmd new file mode 100644 index 0000000..1aba959 --- /dev/null +++ b/crates/quarto-markdown-pandoc/tests/writers/ansi/rgb-colors.qmd @@ -0,0 +1,3 @@ +RGB colors: [orange]{color="#FF6B35"} and [teal]{color="rgb(0, 128, 128)"}. + +Short hex: [lime]{color="#0F0"} green. diff --git a/crates/quarto-source-map/src/context.rs b/crates/quarto-source-map/src/context.rs index 2486771..657c945 100644 --- a/crates/quarto-source-map/src/context.rs +++ b/crates/quarto-source-map/src/context.rs @@ -4,10 +4,16 @@ use crate::file_info::FileInformation; use crate::types::FileId; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + /// Context for managing source files #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SourceContext { files: Vec, + /// Sparse mapping for non-sequential file IDs (e.g., from hash-based IDs) + /// Only populated when add_file_with_id is used + #[serde(skip_serializing_if = "HashMap::is_empty", default)] + file_id_map: HashMap, // Maps FileId.0 -> index in files vec } /// A source file with content and metadata @@ -37,7 +43,10 @@ pub struct FileMetadata { impl SourceContext { /// Create a new empty source context pub fn new() -> Self { - SourceContext { files: Vec::new() } + SourceContext { + files: Vec::new(), + file_id_map: HashMap::new(), + } } /// Add a file to the context and return its ID @@ -92,8 +101,56 @@ impl SourceContext { id } + /// Add a file with a specific FileId + /// + /// This is useful when interfacing with systems that use hash-based or non-sequential + /// FileIds (like quarto-yaml). The FileId must not already exist in the context. + /// + /// # Panics + /// + /// Panics if the FileId already exists in the context. + pub fn add_file_with_id( + &mut self, + id: FileId, + path: String, + content: Option, + ) -> FileId { + // Check if ID already exists + if self.get_file(id).is_some() { + panic!("FileId {:?} already exists in SourceContext", id); + } + + // Process content same as add_file + let (stored_content, content_for_info) = match content { + Some(c) => (Some(c.clone()), Some(c)), + None => (None, std::fs::read_to_string(&path).ok()), + }; + + let file_info = content_for_info.as_ref().map(|c| FileInformation::new(c)); + + // Add to files vec and create mapping + let index = self.files.len(); + self.files.push(SourceFile { + path, + content: stored_content, + file_info, + metadata: FileMetadata { file_type: None }, + }); + + // Store mapping from FileId to index + self.file_id_map.insert(id.0, index); + + id + } + /// Get a file by ID pub fn get_file(&self, id: FileId) -> Option<&SourceFile> { + // First check if this is a mapped ID + if let Some(&index) = self.file_id_map.get(&id.0) { + return self.files.get(index); + } + + // Otherwise use direct indexing (for sequential IDs from add_file) self.files.get(id.0) } @@ -114,6 +171,7 @@ impl SourceContext { metadata: f.metadata.clone(), }) .collect(), + file_id_map: self.file_id_map.clone(), // Preserve mapping } } } diff --git a/crates/quarto-yaml/src/parser.rs b/crates/quarto-yaml/src/parser.rs index 3d1ae55..65fddd2 100644 --- a/crates/quarto-yaml/src/parser.rs +++ b/crates/quarto-yaml/src/parser.rs @@ -289,6 +289,38 @@ impl<'a> YamlBuilder<'a> { } } + fn make_source_info_at_offset(&self, start_offset: usize, len: usize) -> SourceInfo { + let end_offset = start_offset + len; + + if let Some(ref parent) = self.parent { + // We're parsing a substring - create a Substring mapping + SourceInfo::substring(parent.clone(), start_offset, end_offset) + } else { + // We're parsing an original file - create an Original mapping + // We don't have row/column info without a marker, so we need to compute it + // from the content + use quarto_source_map::{Location, Range}; + + // For now, create a minimal SourceInfo without accurate row/column + // This should still work correctly because SourceContext can map offsets + SourceInfo::from_range( + quarto_source_map::FileId(0), + Range { + start: Location { + offset: start_offset, + row: 0, // Will be computed from offset by SourceContext + column: 0, + }, + end: Location { + offset: end_offset, + row: 0, + column: 0, + }, + }, + ) + } + } + fn compute_scalar_len(&self, _marker: &Marker, value: &str) -> usize { // For now, use the value length // TODO: This should be computed more accurately from the source @@ -443,10 +475,6 @@ impl<'a> MarkedEventReceiver for YamlBuilder<'a> { entries, } = build_node { - // Compute the length from start to current marker - let len = marker.index().saturating_sub(start_marker.index()); - let source_info = self.make_source_info(&start_marker, len); - // Build the hash entries let mut hash_entries = Vec::new(); let mut yaml_pairs = Vec::new(); @@ -473,6 +501,22 @@ impl<'a> MarkedEventReceiver for YamlBuilder<'a> { yaml_pairs.push((key.yaml.clone(), value.yaml.clone())); } + // Compute source_info for the entire object + // If we have entries, use the first key's start and the current marker's end + // Otherwise, use start_marker to current marker + let source_info = if let Some(first_entry) = hash_entries.first() { + // Get the start offset from the first key + let first_key_start = first_entry.key.source_info.start_offset(); + // Compute length from first key start to current marker + let len = marker.index().saturating_sub(first_key_start); + // Create SourceInfo starting from first key + self.make_source_info_at_offset(first_key_start, len) + } else { + // Empty object: use start_marker to current marker + let len = marker.index().saturating_sub(start_marker.index()); + self.make_source_info(&start_marker, len) + }; + // Build the Yaml::Hash let yaml = Yaml::Hash(yaml_pairs.into_iter().collect()); @@ -1081,8 +1125,8 @@ We used the following approach... yaml_end ); - // Extract and verify the exact string - yaml-rust2 reports the first value, not the first key - let yaml_root_expected = ": \"My Research Paper\""; // Colon and first value + // Extract and verify the exact string - the YAML root should start at the first key + let yaml_root_expected = "title: \"My Research P"; // First key and start of value let resolved_yaml_str = &qmd_content[resolved_yaml_offset..resolved_yaml_offset + yaml_root_expected.len()]; assert_eq!( @@ -1149,4 +1193,25 @@ We used the following approach... // All tests passed - offset resolution works correctly through the double-substring chain! } + + #[test] + fn test_object_source_range_starts_at_first_key() { + let yaml_content = "title: \"My Research Paper\"\nauthor: \"John Doe\"\n"; + let parsed = parse_file(yaml_content, "test.yaml").expect("parse failed"); + + // The root should be an object + assert!(parsed.is_hash()); + + // Check the SourceInfo of the object + let source_info = &parsed.source_info; + + // The object should span from offset 0 (start of "title") to the end + // NOT from offset 5 (the colon) + assert_eq!(source_info.start_offset(), 0, + "Object should start at offset 0 (beginning of first key), not at the colon"); + + // The end should be at the end of the content + assert_eq!(source_info.end_offset(), yaml_content.len(), + "Object should end at end of content"); + } } diff --git a/crates/tree-sitter-qmd/tree-sitter-markdown/log.html b/crates/tree-sitter-qmd/tree-sitter-markdown/log.html new file mode 100644 index 0000000..f68e92f --- /dev/null +++ b/crates/tree-sitter-qmd/tree-sitter-markdown/log.html @@ -0,0 +1,2420 @@ + + + + + + + + + + + + +new_parse + + + + + + + + + +process version:0, version_count:1, state:1, row:0, col:0 + + + + + + + + + +lex_external state:2, row:0, column:0 + + + + + + + + + +lexed_lookahead sym:minus_metadata, size:20 + + + + + + + + + +shift state:46 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004941e0 + + +46 + + + + + +node_head_0->node_0x6000004941e0 + + +0 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +process version:0, version_count:1, state:46, row:3, col:0 + + + + + + + + + +lex_external state:2, row:3, column:0 + + + + + + + + + +lexed_lookahead sym:_blank_line_start, size:0 + + + + + + + + + +shift state:849 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004942d0 + + +849 + + + + + +node_head_0->node_0x6000004942d0 + + +0 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004942d0->node_0x6000004941e0 + + +_blank_line_start + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +process version:0, version_count:1, state:849, row:3, col:0 + + + + + + + + + +lex_external state:38, row:3, column:0 + + + + + + + + + +lexed_lookahead sym:_line_ending, size:1 + + + + + + + + + +shift state:201 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004943c0 + + +201 + + + + + +node_head_0->node_0x6000004943c0 + + +0 + + + + + +node_0x6000004942d0 + + +849 + + + + + +node_0x6000004943c0->node_0x6000004942d0 + + +_line_ending + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004942d0->node_0x6000004941e0 + + +_blank_line_start + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +process version:0, version_count:1, state:201, row:4, col:0 + + + + + + + + + +lex_external state:4, row:4, column:0 + + + + + + + + + +lex_internal state:12, row:4, column:0 + + + + + + + + + +lexed_lookahead sym:_word, size:7 + + + + + + + + + +reduce sym:_newline, child_count:1 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004944b0 + + +298 + + + + + +node_head_0->node_0x6000004944b0 + + +0 + + + + + +node_0x6000004942d0 + + +849 + + + + + +node_0x6000004944b0->node_0x6000004942d0 + + +_newline + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004942d0->node_0x6000004941e0 + + +_blank_line_start + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:_blank_line, child_count:2 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004943c0 + + +281 + + + + + +node_head_0->node_0x6000004943c0 + + +0 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +_blank_line + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:_block_not_section, child_count:1 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004942d0 + + +453 + + + + + +node_head_0->node_0x6000004942d0 + + +0 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004942d0->node_0x6000004941e0 + + +_block_not_section + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:document_repeat1, child_count:1 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004943c0 + + +61 + + + + + +node_head_0->node_0x6000004943c0 + + +0 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +shift state:595 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004942d0 + + +595 + + + + + +node_head_0->node_0x6000004942d0 + + +0 + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004942d0->node_0x6000004943c0 + + +_word + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +process version:0, version_count:1, state:595, row:4, col:7 + + + + + + + + + +lex_external state:20, row:4, column:7 + + + + + + + + + +lex_internal state:12, row:4, column:7 + + + + + + + + + +lexed_lookahead sym:., size:1 + + + + + + + + + +shift state:592 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004944b0 + + +592 + + + + + +node_head_0->node_0x6000004944b0 + + +0 + + + + + +node_0x6000004942d0 + + +595 + + + + + +node_0x6000004944b0->node_0x6000004942d0 + + +'.' + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004942d0->node_0x6000004943c0 + + +_word + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +process version:0, version_count:1, state:592, row:4, col:8 + + + + + + + + + +lex_external state:20, row:4, column:8 + + + + + + + + + +lexed_lookahead sym:_line_ending, size:1 + + + + + + + + + +reduce sym:_line, child_count:2 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004945a0 + + +650 + + + + + +node_head_0->node_0x6000004945a0 + + +0 + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004945a0->node_0x6000004943c0 + + +_line + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:paragraph_repeat1, child_count:1 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004942d0 + + +553 + + + + + +node_head_0->node_0x6000004942d0 + + +0 + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004942d0->node_0x6000004943c0 + + +paragraph_repeat1 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +shift state:145 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004945a0 + + +145 + + + + + +node_head_0->node_0x6000004945a0 + + +0 + + + + + +node_0x6000004942d0 + + +553 + + + + + +node_0x6000004945a0->node_0x6000004942d0 + + +_line_ending + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004942d0->node_0x6000004943c0 + + +paragraph_repeat1 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +process version:0, version_count:1, state:145, row:5, col:0 + + + + + + + + + +lex_external state:8, row:5, column:0 + + + + + + + + + +lex_internal state:12, row:5, column:0 + + + + + + + + + +lexed_lookahead sym:end, size:0 + + + + + + + + + +reduce sym:_newline, child_count:1 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004944b0 + + +175 + + + + + +node_head_0->node_0x6000004944b0 + + +0 + + + + + +node_0x6000004942d0 + + +553 + + + + + +node_0x6000004944b0->node_0x6000004942d0 + + +_newline + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004942d0->node_0x6000004943c0 + + +paragraph_repeat1 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:paragraph, child_count:2 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004945a0 + + +197 + + + + + +node_head_0->node_0x6000004945a0 + + +0 + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004945a0->node_0x6000004943c0 + + +paragraph + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:_block_not_section, child_count:1 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004942d0 + + +453 + + + + + +node_head_0->node_0x6000004942d0 + + +0 + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004942d0->node_0x6000004943c0 + + +_block_not_section + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:document_repeat1, child_count:1 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004945a0 + + +121 + + + + + +node_head_0->node_0x6000004945a0 + + +0 + + + + + +node_0x6000004943c0 + + +61 + + + + + +node_0x6000004945a0->node_0x6000004943c0 + + +document_repeat1 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004943c0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:document_repeat1, child_count:2 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004942d0 + + +61 + + + + + +node_head_0->node_0x6000004942d0 + + +0 + + + + + +node_0x6000004941e0 + + +46 + + + + + +node_0x6000004942d0->node_0x6000004941e0 + + +document_repeat1 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004941e0->node_0x6000004940f0 + + +minus_metadata + + + + + + + + + + + + +reduce sym:document, child_count:2 + + + + + + + + +stack + + + +node_head_0 + + + +node_0x6000004943c0 + + +1036 + + + + + +node_head_0->node_0x6000004943c0 + + +0 + + + + + +node_0x6000004940f0 + + +1 + + + + + +node_0x6000004943c0->node_0x6000004940f0 + + +document + + + + + + + + + + + + +accept + + + + + + + + +stack + + + + + + + + + + +done + + + + + + + + +tree + + + +tree_0x16dc39020 + + +document + + + + + +tree_0x6000010951f0 + +minus_metadata + + + + + +tree_0x16dc39020->tree_0x6000010951f0 + + + + + + + +tree_0x6000010951f8 + + +section + + + + + +tree_0x16dc39020->tree_0x6000010951f8 + + + + + + + +tree_0x600001095200 + +end + + + + + +tree_0x16dc39020->tree_0x600001095200 + + + + + + + +tree_0x600001f95800 + + +document_repeat1 + + + + + +tree_0x6000010951f8->tree_0x600001f95800 + + + + + + + +tree_0x600001f95808 + + +document_repeat1 + + + + + +tree_0x6000010951f8->tree_0x600001f95808 + + + + + + + +tree_0x600001f95b00 + + +_block_not_section + + + + + +tree_0x600001f95800->tree_0x600001f95b00 + + + + + + + +tree_0x600001f95aa0 + + +_blank_line + + + + + +tree_0x600001f95b00->tree_0x600001f95aa0 + + + + + + + +tree_0x600001f95b60 + +_blank_line_start + + + + + +tree_0x600001f95aa0->tree_0x600001f95b60 + + + + + + + +tree_0x600001f95b68 + + +_newline + + + + + +tree_0x600001f95aa0->tree_0x600001f95b68 + + + + + + + +tree_0x600001f95c20 + +_line_ending + + + + + +tree_0x600001f95b68->tree_0x600001f95c20 + + + + + + + +tree_0x600001f958c0 + + +_block_not_section + + + + + +tree_0x600001f95808->tree_0x600001f958c0 + + + + + + + +tree_0x600001f95860 + + +paragraph + + + + + +tree_0x600001f958c0->tree_0x600001f95860 + + + + + + + +tree_0x600001f95920 + + +inline + + + + + +tree_0x600001f95860->tree_0x600001f95920 + + + + + + + +tree_0x600001f95928 + + +_newline + + + + + +tree_0x600001f95860->tree_0x600001f95928 + + + + + + + +tree_0x600001f95980 + + +_line + + + + + +tree_0x600001f95920->tree_0x600001f95980 + + + + + + + +tree_0x600001f95a40 + +_word + + + + + +tree_0x600001f95980->tree_0x600001f95a40 + + + + + + + +tree_0x600001f95a48 + +. + + + + + +tree_0x600001f95980->tree_0x600001f95a48 + + + + + + + +tree_0x600001f959e0 + +_line_ending + + + + + +tree_0x600001f95928->tree_0x600001f959e0 + + + + + + + diff --git a/crates/tree-sitter-qmd/tree-sitter-markdown/src/scanner.c b/crates/tree-sitter-qmd/tree-sitter-markdown/src/scanner.c index 386e733..2c48b35 100644 --- a/crates/tree-sitter-qmd/tree-sitter-markdown/src/scanner.c +++ b/crates/tree-sitter-qmd/tree-sitter-markdown/src/scanner.c @@ -1100,16 +1100,52 @@ static bool parse_minus(Scanner *s, TSLexer *lexer, const bool *valid_symbols) { } if (minus_count == 3 && (!minus_after_whitespace) && line_end && valid_symbols[MINUS_METADATA]) { - for (;;) { - // advance over newline - if (lexer->lookahead == '\r') { + // Before we start scanning for metadata, peek ahead to check if there's + // a blank line after the opening ---. If so, this is a horizontal rule. + // We need to do this without consuming input. + + // Current position: right after the three minuses, at the newline + // We need to check: is the character after this newline another newline? + // We can do this by advancing, checking, then either continuing or bailing + + // Advance over the newline to peek at next line + if (lexer->lookahead == '\r') { + advance(s, lexer); + if (lexer->lookahead == '\n') { advance(s, lexer); - if (lexer->lookahead == '\n') { + } + } else if (lexer->lookahead == '\n') { + advance(s, lexer); + } + + // Check if we're at another newline (blank line) + bool is_blank_line = (lexer->lookahead == '\r' || lexer->lookahead == '\n'); + + if (is_blank_line) { + // This is a horizontal rule, not metadata + // Don't try to parse as metadata. + // The THEMATIC_BREAK handler should have already been tried. + // Don't return false here - instead, skip the metadata parsing + // and let the normal flow continue (which will check 'success' variable) + } else { + + // Not a blank line, continue with metadata scanning + // Note: we've already advanced past the first newline above + bool first_iteration = true; + for (;;) { + // On subsequent iterations, advance over the newline + if (!first_iteration) { + if (lexer->lookahead == '\r') { + advance(s, lexer); + if (lexer->lookahead == '\n') { + advance(s, lexer); + } + } else { advance(s, lexer); } - } else { - advance(s, lexer); } + first_iteration = false; + // check for minuses minus_count = 0; while (lexer->lookahead == '-') { @@ -1148,6 +1184,7 @@ static bool parse_minus(Scanner *s, TSLexer *lexer, const bool *valid_symbols) { break; } } + } // end of else block for metadata scanning } if (success) { return true; diff --git a/crates/tree-sitter-qmd/tree-sitter-markdown/test/corpus/qmd.txt b/crates/tree-sitter-qmd/tree-sitter-markdown/test/corpus/qmd.txt index 15109cc..c0cf283 100644 --- a/crates/tree-sitter-qmd/tree-sitter-markdown/test/corpus/qmd.txt +++ b/crates/tree-sitter-qmd/tree-sitter-markdown/test/corpus/qmd.txt @@ -325,3 +325,41 @@ Some text in between. (list_marker_example) (paragraph (inline)))))) +================================================================================ +Horizontal rules with blank lines should not be parsed as metadata +================================================================================ +First paragraph. + +--- + +Second paragraph. + +--- + +Third paragraph. +-------------------------------------------------------------------------------- +(document + (section + (paragraph + (inline)) + (thematic_break) + (paragraph + (inline)) + (thematic_break) + (paragraph + (inline)))) +================================================================================ +YAML metadata block should still work +================================================================================ +--- +title: Test +author: Someone +--- + +Content paragraph. +-------------------------------------------------------------------------------- +(document + (minus_metadata) + (section + (paragraph + (inline)))) diff --git a/ts-packages/annotated-qmd/examples/table.json b/ts-packages/annotated-qmd/examples/table.json index 507cca4..b2ef675 100644 --- a/ts-packages/annotated-qmd/examples/table.json +++ b/ts-packages/annotated-qmd/examples/table.json @@ -1 +1 @@ -{"astContext":{"files":[{"line_breaks":[3,24,28,29,38,39,74,109,144,179,180,211],"name":"../../ts-packages/annotated-qmd/examples/table.qmd","total_length":212}],"metaTopLevelKeySources":{"title":58},"sourceInfoPool":[{"d":0,"r":[0,5],"t":0},{"d":0,"r":[5,6],"t":0},{"d":0,"r":[6,13],"t":0},{"d":0,"r":[0,29],"t":0},{"d":3,"r":[4,24],"t":1},{"d":4,"r":[7,20],"t":1},{"d":0,"r":[32,38],"t":0},{"d":0,"r":[30,39],"t":0},{"d":0,"r":[183,190],"t":0},{"d":0,"r":[190,191],"t":0},{"d":0,"r":[191,196],"t":0},{"d":0,"r":[196,197],"t":0},{"d":0,"r":[180,212],"t":0},{"d":0,"r":[42,48],"t":0},{"d":0,"r":[48,49],"t":0},{"d":0,"r":[49,50],"t":0},{"d":0,"r":[42,51],"t":0},{"d":0,"r":[53,59],"t":0},{"d":0,"r":[59,60],"t":0},{"d":0,"r":[60,61],"t":0},{"d":0,"r":[53,62],"t":0},{"d":0,"r":[64,70],"t":0},{"d":0,"r":[70,71],"t":0},{"d":0,"r":[71,72],"t":0},{"d":0,"r":[64,73],"t":0},{"d":0,"r":[112,113],"t":0},{"d":0,"r":[112,121],"t":0},{"d":0,"r":[123,124],"t":0},{"d":0,"r":[123,132],"t":0},{"d":0,"r":[134,135],"t":0},{"d":0,"r":[134,143],"t":0},{"d":0,"r":[147,148],"t":0},{"d":0,"r":[147,156],"t":0},{"d":0,"r":[158,159],"t":0},{"d":0,"r":[158,167],"t":0},{"d":0,"r":[169,170],"t":0},{"d":0,"r":[169,178],"t":0},{"d":0,"r":[40,180],"t":0},{"d":0,"r":[180,212],"t":0},{"d":[[37,0,140],[38,140,32]],"r":[0,172],"t":2},{"d":0,"r":[198,210],"t":0},{"d":0,"r":[180,212],"t":0},{"d":0,"r":[40,180],"t":0},{"d":0,"r":[40,74],"t":0},{"d":0,"r":[42,51],"t":0},{"d":0,"r":[53,62],"t":0},{"d":0,"r":[64,73],"t":0},{"d":0,"r":[40,180],"t":0},{"d":0,"r":[110,144],"t":0},{"d":0,"r":[112,121],"t":0},{"d":0,"r":[123,132],"t":0},{"d":0,"r":[134,143],"t":0},{"d":0,"r":[145,179],"t":0},{"d":0,"r":[147,156],"t":0},{"d":0,"r":[158,167],"t":0},{"d":0,"r":[169,178],"t":0},{"d":0,"r":[40,180],"t":0},{"d":3,"r":[4,24],"t":1},{"d":57,"r":[0,5],"t":1}]},"blocks":[{"attrS":{"classes":[],"id":null,"kvs":[]},"c":[1,["tables",[],[]],[{"c":"Tables","s":6,"t":"Str"}]],"s":7,"t":"Header"},{"attrS":{"classes":[],"id":40,"kvs":[]},"bodiesS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"bodyS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"cellsS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"s":49},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":50},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":51}],"s":48},{"attrS":{"classes":[],"id":null,"kvs":[]},"cellsS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"s":53},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":54},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":55}],"s":52}],"headS":[],"s":47}],"c":[["tbl-example",[],[]],[null,[{"c":[{"c":"Example","s":8,"t":"Str"},{"s":9,"t":"Space"},{"c":"table","s":10,"t":"Str"},{"s":11,"t":"Space"}],"s":12,"t":"Plain"}]],[[{"t":"AlignDefault"},{"t":"ColWidthDefault"}],[{"t":"AlignDefault"},{"t":"ColWidthDefault"}],[{"t":"AlignDefault"},{"t":"ColWidthDefault"}]],[["",[],[]],[[["",[],[]],[[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"Column","s":13,"t":"Str"},{"s":14,"t":"Space"},{"c":"1","s":15,"t":"Str"}],"s":16,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"Column","s":17,"t":"Str"},{"s":18,"t":"Space"},{"c":"2","s":19,"t":"Str"}],"s":20,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"Column","s":21,"t":"Str"},{"s":22,"t":"Space"},{"c":"3","s":23,"t":"Str"}],"s":24,"t":"Plain"}]]]]]],[[["",[],[]],0,[],[[["",[],[]],[[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"A","s":25,"t":"Str"}],"s":26,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"B","s":27,"t":"Str"}],"s":28,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"C","s":29,"t":"Str"}],"s":30,"t":"Plain"}]]]],[["",[],[]],[[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"D","s":31,"t":"Str"}],"s":32,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"E","s":33,"t":"Str"}],"s":34,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"F","s":35,"t":"Str"}],"s":36,"t":"Plain"}]]]]]]],[["",[],[]],[]]],"captionS":41,"footS":{"attrS":{"classes":[],"id":null,"kvs":[]},"rowsS":[],"s":56},"headS":{"attrS":{"classes":[],"id":null,"kvs":[]},"rowsS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"cellsS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"s":44},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":45},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":46}],"s":43}],"s":42},"s":39,"t":"Table"}],"meta":{"title":{"c":[{"c":"Table","s":0,"t":"Str"},{"s":1,"t":"Space"},{"c":"Example","s":2,"t":"Str"}],"s":5,"t":"MetaInlines"}},"pandoc-api-version":[1,23,1]} +{"astContext":{"files":[{"line_breaks":[3,24,28,29,38,39,74,109,144,179,180,211],"name":"../../ts-packages/annotated-qmd/examples/table.qmd","total_length":212}],"metaTopLevelKeySources":{"title":58},"sourceInfoPool":[{"d":0,"r":[0,5],"t":0},{"d":0,"r":[5,6],"t":0},{"d":0,"r":[6,13],"t":0},{"d":0,"r":[0,29],"t":0},{"d":3,"r":[4,24],"t":1},{"d":4,"r":[7,20],"t":1},{"d":0,"r":[32,38],"t":0},{"d":0,"r":[30,39],"t":0},{"d":0,"r":[183,190],"t":0},{"d":0,"r":[190,191],"t":0},{"d":0,"r":[191,196],"t":0},{"d":0,"r":[196,197],"t":0},{"d":0,"r":[180,212],"t":0},{"d":0,"r":[42,48],"t":0},{"d":0,"r":[48,49],"t":0},{"d":0,"r":[49,50],"t":0},{"d":0,"r":[42,51],"t":0},{"d":0,"r":[53,59],"t":0},{"d":0,"r":[59,60],"t":0},{"d":0,"r":[60,61],"t":0},{"d":0,"r":[53,62],"t":0},{"d":0,"r":[64,70],"t":0},{"d":0,"r":[70,71],"t":0},{"d":0,"r":[71,72],"t":0},{"d":0,"r":[64,73],"t":0},{"d":0,"r":[112,113],"t":0},{"d":0,"r":[112,121],"t":0},{"d":0,"r":[123,124],"t":0},{"d":0,"r":[123,132],"t":0},{"d":0,"r":[134,135],"t":0},{"d":0,"r":[134,143],"t":0},{"d":0,"r":[147,148],"t":0},{"d":0,"r":[147,156],"t":0},{"d":0,"r":[158,159],"t":0},{"d":0,"r":[158,167],"t":0},{"d":0,"r":[169,170],"t":0},{"d":0,"r":[169,178],"t":0},{"d":0,"r":[40,180],"t":0},{"d":0,"r":[180,212],"t":0},{"d":[[37,0,140],[38,140,32]],"r":[0,172],"t":2},{"d":0,"r":[198,210],"t":0},{"d":0,"r":[180,212],"t":0},{"d":0,"r":[40,180],"t":0},{"d":0,"r":[40,74],"t":0},{"d":0,"r":[42,51],"t":0},{"d":0,"r":[53,62],"t":0},{"d":0,"r":[64,73],"t":0},{"d":0,"r":[40,180],"t":0},{"d":0,"r":[110,144],"t":0},{"d":0,"r":[112,121],"t":0},{"d":0,"r":[123,132],"t":0},{"d":0,"r":[134,143],"t":0},{"d":0,"r":[145,179],"t":0},{"d":0,"r":[147,156],"t":0},{"d":0,"r":[158,167],"t":0},{"d":0,"r":[169,178],"t":0},{"d":0,"r":[40,180],"t":0},{"d":3,"r":[4,24],"t":1},{"d":57,"r":[0,5],"t":1}]},"blocks":[{"attrS":{"classes":[],"id":null,"kvs":[]},"c":[1,["tables",[],[]],[{"c":"Tables","s":6,"t":"Str"}]],"s":7,"t":"Header"},{"attrS":{"classes":[],"id":40,"kvs":[]},"bodiesS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"bodyS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"cellsS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"s":49},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":50},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":51}],"s":48},{"attrS":{"classes":[],"id":null,"kvs":[]},"cellsS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"s":53},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":54},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":55}],"s":52}],"headS":[],"s":47}],"c":[["tbl-example",[],[]],[null,[{"c":[{"c":"Example","s":8,"t":"Str"},{"s":9,"t":"Space"},{"c":"table","s":10,"t":"Str"},{"s":11,"t":"Space"}],"s":12,"t":"Plain"}]],[[{"t":"AlignDefault"},{"t":"ColWidthDefault"}],[{"t":"AlignDefault"},{"t":"ColWidthDefault"}],[{"t":"AlignDefault"},{"t":"ColWidthDefault"}]],[["",[],[]],[[["",[],[]],[[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"Column","s":13,"t":"Str"},{"s":14,"t":"Space"},{"c":"1","s":15,"t":"Str"}],"s":16,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"Column","s":17,"t":"Str"},{"s":18,"t":"Space"},{"c":"2","s":19,"t":"Str"}],"s":20,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"Column","s":21,"t":"Str"},{"s":22,"t":"Space"},{"c":"3","s":23,"t":"Str"}],"s":24,"t":"Plain"}]]]]]],[[["",[],[]],0,[],[[["",[],[]],[[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"A","s":25,"t":"Str"}],"s":26,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"B","s":27,"t":"Str"}],"s":28,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"C","s":29,"t":"Str"}],"s":30,"t":"Plain"}]]]],[["",[],[]],[[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"D","s":31,"t":"Str"}],"s":32,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"E","s":33,"t":"Str"}],"s":34,"t":"Plain"}]],[["",[],[]],{"t":"AlignDefault"},1,1,[{"c":[{"c":"F","s":35,"t":"Str"}],"s":36,"t":"Plain"}]]]]]]],[["",[],[]],[]]],"captionS":41,"footS":{"attrS":{"classes":[],"id":null,"kvs":[]},"rowsS":[],"s":56},"headS":{"attrS":{"classes":[],"id":null,"kvs":[]},"rowsS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"cellsS":[{"attrS":{"classes":[],"id":null,"kvs":[]},"s":44},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":45},{"attrS":{"classes":[],"id":null,"kvs":[]},"s":46}],"s":43}],"s":42},"s":39,"t":"Table"}],"meta":{"title":{"c":[{"c":"Table","s":0,"t":"Str"},{"s":1,"t":"Space"},{"c":"Example","s":2,"t":"Str"}],"s":5,"t":"MetaInlines"}},"pandoc-api-version":[1,23,1]} \ No newline at end of file