diff --git a/.github/workflows/test-smokes.yml b/.github/workflows/test-smokes.yml index a82b8520ad..02f3b7dceb 100644 --- a/.github/workflows/test-smokes.yml +++ b/.github/workflows/test-smokes.yml @@ -115,7 +115,7 @@ jobs: run: | if (!requireNamespace('renv', quietly = TRUE)) install.packages('renv') renv::restore() - # Install dev versions for our testing + # Install dev versions for our testing # Use r-universe to avoid github api calls try(install.packages('knitr', repos = 'https://yihui.r-universe.dev')) try(install.packages('rmarkdown', repos = 'https://rstudio.r-universe.dev')) @@ -164,7 +164,7 @@ jobs: run: | # Setup IJulia with the jupyter from the Python environment # https://julialang.github.io/IJulia.jl/stable/manual/installation/ - export JUPYTER=$(find $(dirname $(pipenv run which jupyter))/ -type f -name "jupyter.exe" -o -name "jupyter") + export JUPYTER=$(find $(dirname $(pipenv run which jupyter))/ -type f -name "jupyter.exe" -o -name "jupyter") pipenv run julia --color=yes --project=. -e "import Pkg; Pkg.instantiate(); Pkg.build(\"IJulia\"); Pkg.precompile()" echo "Julia Jupyter:" julia --project=. -e "import IJulia;println(IJulia.JUPYTER);println(IJulia.find_jupyter_subcommand(\"notebook\"))" @@ -174,6 +174,9 @@ jobs: run: | echo "QUARTO_TEST_TIMING=timing-for-ci.txt" >> "$GITHUB_ENV" + # - name: Setup tmate session + # uses: mxschmitt/action-tmate@v3 + - name: Run all Smoke Tests Windows if: ${{ runner.os == 'Windows' && format('{0}', inputs.buckets) == '' && matrix.time-test == false }} env: @@ -197,6 +200,7 @@ jobs: env: # Useful as TinyTeX latest release is checked in run-test.sh GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + QUARTO_LOG_LEVEL: DEBUG run: | haserror=0 readarray -t my_array < <(echo '${{ inputs.buckets }}' | jq -rc '.[]') diff --git a/src/execute/engine.ts b/src/execute/engine.ts index 5c7bc72f9b..e17a41479a 100644 --- a/src/execute/engine.ts +++ b/src/execute/engine.ts @@ -29,6 +29,7 @@ import { mergeConfigs } from "../core/config.ts"; import { ProjectContext } from "../project/types.ts"; import { pandocBuiltInFormats } from "../core/pandoc/pandoc-formats.ts"; import { gitignoreEntries } from "../project/project-gitignore.ts"; +import { juliaEngine } from "./julia.ts"; const kEngines: Map = new Map(); @@ -40,7 +41,9 @@ export function executionEngine(name: string) { return kEngines.get(name); } -for (const engine of [knitrEngine, jupyterEngine, markdownEngine]) { +for ( + const engine of [knitrEngine, jupyterEngine, markdownEngine, juliaEngine] +) { registerExecutionEngine(engine); } diff --git a/src/execute/julia.ts b/src/execute/julia.ts new file mode 100644 index 0000000000..9e8b63cf89 --- /dev/null +++ b/src/execute/julia.ts @@ -0,0 +1,654 @@ +import { error, info } from "log/mod.ts"; +import { join } from "path/mod.ts"; +import { MappedString, mappedStringFromFile } from "../core/mapped-text.ts"; +import { partitionMarkdown } from "../core/pandoc/pandoc-partition.ts"; +import { readYamlFromMarkdown } from "../core/yaml.ts"; +import { ProjectContext } from "../project/types.ts"; +import { + DependenciesOptions, + ExecuteOptions, + ExecuteResult, + ExecutionEngine, + ExecutionTarget, + kJuliaEngine, + PandocIncludes, + PostProcessOptions, +} from "./types.ts"; +import { jupyterAssets, jupyterToMarkdown } from "../core/jupyter/jupyter.ts"; +import { + kExecuteDaemon, + kExecuteDaemonRestart, + kExecuteDebug, + kFigDpi, + kFigFormat, + kFigPos, + kIpynbProduceSourceNotebook, + kKeepHidden, +} from "../config/constants.ts"; +import { + isHtmlCompatible, + isIpynbOutput, + isLatexOutput, + isMarkdownOutput, + isPresentationOutput, +} from "../config/format.ts"; +import { resourcePath } from "../core/resources.ts"; +import { quartoRuntimeDir } from "../core/appdirs.ts"; +import { normalizePath } from "../core/path.ts"; +import { isInteractiveSession } from "../core/platform.ts"; +import { runningInCI } from "../core/ci-info.ts"; +import { sleep } from "../core/async.ts"; +import { JupyterNotebook } from "../core/jupyter/types.ts"; +import { existsSync } from "fs/mod.ts"; +import { encodeBase64 } from "encoding/base64.ts"; + +export interface JuliaExecuteOptions extends ExecuteOptions { + julia_cmd: string; + oneShot: boolean; // if true, the file's worker process is closed before and after running + supervisor_pid?: number; +} + +export const juliaEngine: ExecutionEngine = { + name: kJuliaEngine, + + defaultExt: ".qmd", + + defaultYaml: () => [], + + defaultContent: () => [ + "```{julia}", + "1 + 1", + "```", + ], + + validExtensions: () => [], + + claimsFile: (file: string, ext: string) => false, + + claimsLanguage: (language: string) => { + // we don't claim `julia` so the old behavior of using the jupyter + // backend by default stays intact + return false; // language.toLowerCase() === "julia"; + }, + + partitionedMarkdown: async (file: string) => { + return partitionMarkdown(Deno.readTextFileSync(file)); + }, + + // TODO: ask dragonstyle what to do here + executeTargetSkipped: () => false, + + // TODO: just return dependencies from execute and this can do nothing + dependencies: (_options: DependenciesOptions) => { + const includes: PandocIncludes = {}; + return Promise.resolve({ + includes, + }); + }, + + // TODO: this can also probably do nothing + postprocess: (_options: PostProcessOptions) => { + return Promise.resolve(); + }, + + canFreeze: true, + + generatesFigures: true, + + ignoreDirs: () => { + return []; + }, + + canKeepSource: (_target: ExecutionTarget) => { + return true; + }, + + markdownForFile(file: string): Promise { + return Promise.resolve(mappedStringFromFile(file)); + }, + + execute: async (options: ExecuteOptions): Promise => { + options.target.source; + + // use daemon by default if we are in an interactive session (terminal + // or rstudio) and not running in a CI system. + let executeDaemon = options.format.execute[kExecuteDaemon]; + if (executeDaemon === null || executeDaemon === undefined) { + executeDaemon = isInteractiveSession() && !runningInCI(); + } + + const execOptions = { + ...options, + target: { + ...options.target, + input: normalizePath(options.target.input), + }, + }; + + const juliaExecOptions: JuliaExecuteOptions = { + julia_cmd: Deno.env.get("QUARTO_JULIA") ?? "julia", + oneShot: !executeDaemon, + supervisor_pid: options.previewServer ? Deno.pid : undefined, + ...execOptions, + }; + + // TODO: executeDaemon can take a number for timeout of kernels, but + // QuartoNotebookRunner currently doesn't support that + const nb = await executeJulia(juliaExecOptions); + + if (!nb) { + error("Execution of notebook returned undefined"); + return Promise.reject(); + } + + // NOTE: the following is all mostly copied from the jupyter kernel file + + // there isn't really a "kernel" as we don't execute via Jupyter + // but this seems to be needed later to assign the correct language markers to code cells etc.) + nb.metadata.kernelspec = { + display_name: "Julia", + name: "julia", + language: "julia", + }; + + const assets = jupyterAssets( + options.target.input, + options.format.pandoc.to, + ); + + // NOTE: for perforance reasons the 'nb' is mutated in place + // by jupyterToMarkdown (we don't want to make a copy of a + // potentially very large notebook) so should not be relied + // on subseuqent to this call + + const result = await jupyterToMarkdown( + nb, + { + executeOptions: options, + language: nb.metadata.kernelspec.language.toLowerCase(), + assets, + execute: options.format.execute, + keepHidden: options.format.render[kKeepHidden], + toHtml: isHtmlCompatible(options.format), + toLatex: isLatexOutput(options.format.pandoc), + toMarkdown: isMarkdownOutput(options.format), + toIpynb: isIpynbOutput(options.format.pandoc), + toPresentation: isPresentationOutput(options.format.pandoc), + figFormat: options.format.execute[kFigFormat], + figDpi: options.format.execute[kFigDpi], + figPos: options.format.render[kFigPos], + // preserveCellMetadata, + preserveCodeCellYaml: + options.format.render[kIpynbProduceSourceNotebook] === true, + }, + ); + + // Create markdown from the result + const outputs = result.cellOutputs.map((output) => output.markdown); + if (result.notebookOutputs) { + if (result.notebookOutputs.prefix) { + outputs.unshift(result.notebookOutputs.prefix); + } + if (result.notebookOutputs.suffix) { + outputs.push(result.notebookOutputs.suffix); + } + } + const markdown = outputs.join(""); + + // return results + return { + engine: kJuliaEngine, + markdown: markdown, + supporting: [join(assets.base_dir, assets.supporting_dir)], + filters: [], + pandoc: result.pandoc, + // includes, + // engineDependencies, + preserve: result.htmlPreserve, + postProcess: result.htmlPreserve && + (Object.keys(result.htmlPreserve).length > 0), + }; + }, + + target: async ( + file: string, + _quiet?: boolean, + markdown?: MappedString, + _project?: ProjectContext, + ): Promise => { + if (markdown === undefined) { + markdown = mappedStringFromFile(file); + } + const target: ExecutionTarget = { + source: file, + input: file, + markdown, + metadata: readYamlFromMarkdown(markdown.value), + }; + return Promise.resolve(target); + }, +}; + +async function startOrReuseJuliaServer( + options: JuliaExecuteOptions, +): Promise<{ reused: boolean }> { + const transportFile = juliaTransportFile(); + if (!existsSync(transportFile)) { + trace( + options, + `Transport file ${transportFile} doesn't exist`, + ); + info("Starting julia control server process. This might take a while..."); + await ensureQuartoNotebookRunnerEnvironment(options); + + // We need to spawn the julia server in its own process that can outlive quarto. + // Apparently, `run(detach(cmd))` in julia does not do that reliably on Windows, + // at least deno never seems to recognize that the spawning julia process has finished, + // presumably because it waits for the active child process to exit. This makes the + // tests on windows hang forever if we use the same launching mechanism as for Unix systems. + // So we utilize powershell instead which can start completely detached processes with + // the Start-Process commandlet. + if (Deno.build.os === "windows") { + const command = new Deno.Command( + "PowerShell", + { + args: [ + "-Command", + "Start-Process", + options.julia_cmd, + "-ArgumentList", + // string array argument list, each element but the last must have a "," element after + "--startup-file=no", + ",", + `--project=${juliaRuntimeDir()}`, + ",", + resourcePath("julia/quartonotebookrunner.jl"), + ",", + transportFile, + // end of string array + "-WindowStyle", + "Hidden", + ], + }, + ); + trace( + options, + "Starting detached julia server through powershell, once transport file exists, server should be running.", + ); + const result = command.outputSync(); + if (!result.success) { + throw new Error(new TextDecoder().decode(result.stderr)); + } + } else { + const command = new Deno.Command(options.julia_cmd, { + args: [ + "--startup-file=no", + resourcePath("julia/start_quartonotebookrunner_detached.jl"), + options.julia_cmd, + juliaRuntimeDir(), + resourcePath("julia/quartonotebookrunner.jl"), + transportFile, + ], + }); + trace( + options, + "Starting detached julia server through julia, once transport file exists, server should be running.", + ); + const result = command.outputSync(); + if (!result.success) { + throw new Error(new TextDecoder().decode(result.stderr)); + } + } + } else { + trace( + options, + `Transport file ${transportFile} exists, reusing server.`, + ); + return { reused: true }; + } + return { reused: false }; +} + +async function ensureQuartoNotebookRunnerEnvironment( + options: JuliaExecuteOptions, +) { + const projectTomlTemplate = juliaResourcePath("Project.toml"); + const projectToml = join(juliaRuntimeDir(), "Project.toml"); + Deno.copyFileSync(projectTomlTemplate, projectToml); + const command = new Deno.Command(options.julia_cmd, { + args: [ + "--startup-file=no", + `--project=${juliaRuntimeDir()}`, + juliaResourcePath("ensure_environment.jl"), + ], + }); + const proc = command.spawn(); + const { success } = await proc.output(); + if (!success) { + throw (new Error("Ensuring an updated julia server environment failed")); + } + return Promise.resolve(); +} + +function juliaResourcePath(...parts: string[]) { + return join(resourcePath("julia"), ...parts); +} + +interface JuliaTransportFile { + port: number; + pid: number; + key: string; +} + +async function pollTransportFile( + options: JuliaExecuteOptions, +): Promise { + const transportFile = juliaTransportFile(); + + for (let i = 0; i < 20; i++) { + if (existsSync(transportFile)) { + const content = Deno.readTextFileSync(transportFile); + trace(options, "Transport file read successfully."); + return JSON.parse(content) as JuliaTransportFile; + } + trace(options, "Transport file did not exist, yet."); + await sleep(i * 100); + } + return Promise.reject(); +} + +async function getJuliaServerConnection( + options: JuliaExecuteOptions, +): Promise { + const { reused } = await startOrReuseJuliaServer(options); + const transportOptions = await pollTransportFile(options); + + if (!reused) { + info("Julia server process started."); + } + + trace( + options, + `Connecting to server at port ${transportOptions.port}, pid ${transportOptions.pid}`, + ); + + try { + const conn = await Deno.connect({ + port: transportOptions.port, + }); + const isready = writeJuliaCommand( + conn, + "isready", + transportOptions.key, + options, + ) as Promise; + const timeoutMilliseconds = 10000; + const timeout = new Promise((accept, _) => + setTimeout(() => { + accept( + `Timed out after getting no response for ${timeoutMilliseconds} milliseconds.`, + ); + }, timeoutMilliseconds) + ); + const result = await Promise.race([isready, timeout]); + if (typeof result === "string") { + // timed out + throw new Error(result); + } else if (result !== true) { + error( + `Expected isready command to return true, returned ${isready} instead. Closing connection.`, + ); + conn.close(); + return Promise.reject(); + } + return conn; + } catch (e) { + if (reused) { + trace( + options, + "Connecting to server failed, a transport file was reused so it might be stale. Delete transport file and retry.", + ); + Deno.removeSync(juliaTransportFile()); + return await getJuliaServerConnection(options); + } else { + error( + "Connecting to server failed. A transport file was successfully created by the server process, so something in the server process might be broken.", + ); + throw e; + } + } +} + +function firstSignificantLine(str: string, n: number): string { + const lines = str.split("\n"); + + for (const line of lines) { + const trimmedLine = line.trim(); + // Check if the line is significant + if (!trimmedLine.startsWith("#|") && trimmedLine !== "") { + // Return line as is if its length is less than or equal to n + if (trimmedLine.length <= n) { + return trimmedLine; + } else { + // Return substring up to n characters with an ellipsis + return trimmedLine.substring(0, n - 1) + "…"; + } + } + } + + // Return empty string if no significant line found + return ""; +} + +// from cliffy, MIT licensed +function getConsoleColumns(): number | null { + try { + // Catch error in none tty mode: Inappropriate ioctl for device (os error 25) + return Deno.consoleSize().columns ?? null; + } catch (_error) { + return null; + } +} + +async function executeJulia( + options: JuliaExecuteOptions, +): Promise { + const conn = await getJuliaServerConnection(options); + const transportOptions = await pollTransportFile(options); + if (options.oneShot || options.format.execute[kExecuteDaemonRestart]) { + const isopen = await writeJuliaCommand( + conn, + "isopen", + transportOptions.key, + options, + ) as boolean; + if (isopen) { + await writeJuliaCommand(conn, "close", transportOptions.key, options); + } + } + const response = await writeJuliaCommand( + conn, + "run", + transportOptions.key, + options, + (update: ProgressUpdate) => { + const n = update.nChunks.toString(); + const i = update.chunkIndex.toString(); + const i_padded = `${" ".repeat(n.length - i.length)}${i}`; + const ncols = getConsoleColumns() ?? 80; + const firstPart = `Running [${i_padded}/${n}] at line ${update.line}: `; + const firstPartLength = firstPart.length; + const sigLine = firstSignificantLine( + update.source, + Math.max(0, ncols - firstPartLength), + ); + info(`${firstPart}${sigLine}`); + }, + ); + + if (options.oneShot) { + await writeJuliaCommand(conn, "close", transportOptions.key, options); + } + + if (response.error !== undefined) { + throw new Error("Running notebook failed:\n" + response.juliaError); + } + + return response.notebook as JupyterNotebook; +} + +interface ProgressUpdate { + type: "progress_update"; + chunkIndex: number; + nChunks: number; + source: string; + line: number; +} + +async function writeJuliaCommand( + conn: Deno.Conn, + command: "run" | "close" | "stop" | "isready" | "isopen", + secret: string, + options: JuliaExecuteOptions, + onProgressUpdate?: (update: ProgressUpdate) => void, +) { + // send the options along with the "run" command + const content = command === "run" + ? { file: options.target.input, options } + : command === "stop" || command === "isready" + ? {} + : options.target.input; + + const commandData = { + type: command, + content, + }; + + const payload = JSON.stringify(commandData); + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + true, + ["sign"], + ); + const canonicalRequestBytes = new TextEncoder().encode( + JSON.stringify(commandData), + ); + const signatureArrayBuffer = await crypto.subtle.sign( + "HMAC", + key, + canonicalRequestBytes, + ); + const signatureBytes = new Uint8Array(signatureArrayBuffer); + const hmac = encodeBase64(signatureBytes); + + const message = JSON.stringify({ hmac, payload }) + "\n"; + + const messageBytes = new TextEncoder().encode(message); + + trace(options, `write command "${command}" to socket server`); + const bytesWritten = await conn.write(messageBytes); + if (bytesWritten !== messageBytes.length) { + throw new Error("Internal Error"); + } + + // a string of bytes received from the server could start with a + // partial message, contain multiple complete messages (separated by newlines) after that + // but they could also end in a partial one. + // so to read and process them all correctly, we read in a fixed number of bytes, if there's a newline, we process + // the string up to that part and save the rest for the next round. + let restOfPreviousResponse = ""; + while (true) { + let response = restOfPreviousResponse; + const newlineAt = response.indexOf("\n"); + // if we already have a newline, we don't need to read from conn + if (newlineAt !== -1) { + restOfPreviousResponse = response.substring(newlineAt + 1); + response = response.substring(0, newlineAt); + } // but if we don't have a newline, we read in more until we get one + else { + while (true) { + const buffer = new Uint8Array(512); + const bytesRead = await conn.read(buffer); + if (bytesRead === null) { + break; + } + + if (bytesRead > 0) { + const payload = new TextDecoder().decode( + buffer.slice(0, bytesRead), + ); + const payloadNewlineAt = payload.indexOf("\n"); + if (payloadNewlineAt === -1) { + response += payload; + restOfPreviousResponse = ""; + } else { + response += payload.substring(0, payloadNewlineAt); + restOfPreviousResponse = payload.substring(payloadNewlineAt + 1); + // when we have found a newline in a payload, we can stop reading in more data and continue + // with the response first + break; + } + } + } + } + trace(options, "received server response"); + // one command should be sent, ended by a newline, currently just throwing away anything else because we don't + // expect multiple commmands at once + const json = response.split("\n")[0]; + const data = JSON.parse(json); + + if (data.type === "progress_update") { + trace( + options, + "received progress update response, listening for further responses", + ); + if (onProgressUpdate !== undefined) { + onProgressUpdate(data as ProgressUpdate); + } + continue; // wait for the next message + } + + const err = data.error; + if (err !== undefined) { + const juliaError = data.juliaError ?? "No julia error message available."; + error( + `Julia server returned error after receiving "${command}" command:\n` + + err, + ); + error(juliaError); + throw new Error("Internal julia server error"); + } + + return data; + } +} + +function juliaRuntimeDir(): string { + try { + return quartoRuntimeDir("julia"); + } catch (e) { + error("Could not create julia runtime directory."); + error( + "This is possibly a permission issue in the environment Quarto is running in.", + ); + error( + "Please consult the following documentation for more information:", + ); + error( + "https://github.com/quarto-dev/quarto-cli/issues/4594#issuecomment-1619177667", + ); + throw e; + } +} + +function juliaTransportFile() { + return join(juliaRuntimeDir(), "julia_transport.txt"); +} + +function trace(options: ExecuteOptions, msg: string) { + if (options.format.execute[kExecuteDebug]) { + info("- " + msg, { bold: true }); + } +} diff --git a/src/execute/types.ts b/src/execute/types.ts index 865d63dce8..bb85ca46a7 100644 --- a/src/execute/types.ts +++ b/src/execute/types.ts @@ -21,6 +21,7 @@ export const kQmdExtensions = [".qmd"]; export const kMarkdownEngine = "markdown"; export const kKnitrEngine = "knitr"; export const kJupyterEngine = "jupyter"; +export const kJuliaEngine = "julia"; export interface ExecutionEngine { name: string; diff --git a/src/resources/julia/Project.toml b/src/resources/julia/Project.toml new file mode 100644 index 0000000000..2b021f07ce --- /dev/null +++ b/src/resources/julia/Project.toml @@ -0,0 +1,5 @@ +[deps] +QuartoNotebookRunner = "4c0109c6-14e9-4c88-93f0-2b974d3468f4" + +[compat] +QuartoNotebookRunner = "=0.9.0" diff --git a/src/resources/julia/ensure_environment.jl b/src/resources/julia/ensure_environment.jl new file mode 100644 index 0000000000..11a431a22d --- /dev/null +++ b/src/resources/julia/ensure_environment.jl @@ -0,0 +1,26 @@ +using Pkg +using TOML + +# If the manifest was resolved with the exact Project.toml that we have copied +# over from quarto's resource folder, then we just ensure that it is instantiated, +# all packages are downloaded correctly, etc. +# Otherwise, we update to get the newest packages fulfilling the compat bounds set in +# the Project.toml. + +function manifest_has_correct_julia_version() + project_file = Base.active_project() + manifest_file = joinpath(dirname(project_file), "Manifest.toml") + version = VersionNumber(TOML.parsefile(manifest_file)["julia_version"]) + return version.major == VERSION.major && version.minor == VERSION.minor +end + +manifest_matches_project_toml = Pkg.is_manifest_current() === true # this returns nothing if there's no manifest + +if manifest_matches_project_toml && manifest_has_correct_julia_version() + Pkg.instantiate() +else + Pkg.update() +end +# not strictly necessary, but in case of precompilation errors this will +# actually print them out explicitly +Pkg.precompile() diff --git a/src/resources/julia/quartonotebookrunner.jl b/src/resources/julia/quartonotebookrunner.jl new file mode 100644 index 0000000000..ee36de4138 --- /dev/null +++ b/src/resources/julia/quartonotebookrunner.jl @@ -0,0 +1,19 @@ +using QuartoNotebookRunner +using Sockets + + +transport_file = ARGS[1] +transport_dir = dirname(transport_file) + +atexit() do + rm(transport_file; force=true) +end + +server = QuartoNotebookRunner.serve(; timeout = 300) +port = server.port + +open(transport_file, "w") do io + println(io, """{"port": $port, "pid": $(Base.Libc.getpid()), "key": "$(server.key)"}""") +end + +wait(server) diff --git a/src/resources/julia/start_quartonotebookrunner_detached.jl b/src/resources/julia/start_quartonotebookrunner_detached.jl new file mode 100644 index 0000000000..ce371b0e71 --- /dev/null +++ b/src/resources/julia/start_quartonotebookrunner_detached.jl @@ -0,0 +1,13 @@ +# it appears that deno cannot launch detached processes https://github.com/denoland/deno/issues/5501 +# so we use an indirection where we start the detached julia process using julia itself +julia_bin = ARGS[1] +project = ARGS[2] +julia_file = ARGS[3] +transport_file = ARGS[4] + +if length(ARGS) > 4 + error("Too many arguments") +end + +cmd = `$julia_bin --startup-file=no --project=$project $julia_file $transport_file` +run(detach(cmd), wait = false) diff --git a/tests/docs/crossrefs/julianative-subfig.qmd b/tests/docs/crossrefs/julianative-subfig.qmd new file mode 100644 index 0000000000..52d2964d61 --- /dev/null +++ b/tests/docs/crossrefs/julianative-subfig.qmd @@ -0,0 +1,28 @@ +--- +title: Julia Subfig Test +engine: julia +julia: + exeflags: ["--project=../.."] +--- + +```{julia} +Pkg.instantiate() +``` + +## Julia Crossref Figure + +```{julia} +#| label: fig-plots +#| fig-cap: "Plots" +#| fig-subcap: +#| - "Plot 1" +#| - "Plot 2" +#| layout-ncol: 2 + +using Plots +plot([1,23,2,4]) |> display + +plot([8,65,23,90]) |> display +``` + +See @fig-plots for examples. In particular, @fig-plots-2. diff --git a/tests/docs/crossrefs/julianative.qmd b/tests/docs/crossrefs/julianative.qmd new file mode 100644 index 0000000000..9c0a6e0038 --- /dev/null +++ b/tests/docs/crossrefs/julianative.qmd @@ -0,0 +1,23 @@ +--- +title: Julia Crossref Test +engine: julia +julia: + exeflags: ["--project=../.."] +--- + +```{julia} +Pkg.instantiate() +``` + +## Julia Crossref Figure + +```{julia} +#| label: fig-plot +#| fig-cap: "Plot" + +using Plots +Plots.gr(format="png") +plot([1,23,2,4]) +``` + +For example, see @fig-plot. diff --git a/tests/docs/markdown/commonmark-julianative.qmd b/tests/docs/markdown/commonmark-julianative.qmd new file mode 100644 index 0000000000..0e7247d7a7 --- /dev/null +++ b/tests/docs/markdown/commonmark-julianative.qmd @@ -0,0 +1,9 @@ +--- +title: test +format: commonmark +engine: julia +--- + +```{julia} +1 + 1 +``` diff --git a/tests/smoke/crossref/figures.test.ts b/tests/smoke/crossref/figures.test.ts index c5dcfe80ec..5a23b41b7c 100644 --- a/tests/smoke/crossref/figures.test.ts +++ b/tests/smoke/crossref/figures.test.ts @@ -62,36 +62,40 @@ testRender(pythonSubfigQmd.input, "html", false, [ ]), ]); -const juliaQmd = crossref("julia.qmd", "html"); -testRender(juliaQmd.input, "html", false, [ - ensureHtmlElements(juliaQmd.output.outputPath, [ - "section#julia-crossref-figure div#fig-plot > figure img.figure-img", - "section#julia-crossref-figure div#fig-plot > figure > figcaption", - ]), - ensureFileRegexMatches(juliaQmd.output.outputPath, [ - /Figure 1: Plot/, - /Figure 1/, - ], [ - /\?@fig-/, - ]), -]); +for (const file of ["julia.qmd", "julianative.qmd"]) { + const juliaQmd = crossref(file, "html"); + testRender(juliaQmd.input, "html", false, [ + ensureHtmlElements(juliaQmd.output.outputPath, [ + "section#julia-crossref-figure div#fig-plot > figure img.figure-img", + "section#julia-crossref-figure div#fig-plot > figure > figcaption", + ]), + ensureFileRegexMatches(juliaQmd.output.outputPath, [ + /Figure 1: Plot/, + /Figure 1/, + ], [ + /\?@fig-/, + ]), + ]); +} -const juliaSubfigQmd = crossref("julia-subfig.qmd", "html"); -testRender(juliaSubfigQmd.input, "html", false, [ - ensureHtmlElements(juliaSubfigQmd.output.outputPath, [ - "section#julia-crossref-figure div.quarto-layout-panel > figure div.quarto-layout-row", - "section#julia-crossref-figure div.quarto-layout-panel > figure > figcaption.quarto-float-fig.quarto-float-caption", - ]), - ensureFileRegexMatches(juliaSubfigQmd.output.outputPath, [ - /Figure 1: Plots/, - /Figure 1/, - /Figure 1 \(b\)/, - /\(a\) Plot 1/, - /\(b\) Plot 2/, - ], [ - /\?@fig-/, - ]), -]); +for (const file of ["julia-subfig.qmd", "julianative-subfig.qmd"]) { + const juliaSubfigQmd = crossref(file, "html"); + testRender(juliaSubfigQmd.input, "html", false, [ + ensureHtmlElements(juliaSubfigQmd.output.outputPath, [ + "section#julia-crossref-figure div.quarto-layout-panel > figure div.quarto-layout-row", + "section#julia-crossref-figure div.quarto-layout-panel > figure > figcaption.quarto-float-fig.quarto-float-caption", + ]), + ensureFileRegexMatches(juliaSubfigQmd.output.outputPath, [ + /Figure 1: Plots/, + /Figure 1/, + /Figure 1 \(b\)/, + /\(a\) Plot 1/, + /\(b\) Plot 2/, + ], [ + /\?@fig-/, + ]), + ]); +} const knitrQmd = crossref("knitr.qmd", "html"); testRender(knitrQmd.input, "html", false, [ diff --git a/tests/smoke/render/render-commonmark.test.ts b/tests/smoke/render/render-commonmark.test.ts index 961b4c16fc..372dde8192 100644 --- a/tests/smoke/render/render-commonmark.test.ts +++ b/tests/smoke/render/render-commonmark.test.ts @@ -10,10 +10,11 @@ import { ensureFileRegexMatches } from "../../verify.ts"; import { testRender } from "./render.ts"; const tests = [ - { file: "commonmark-plain.qmd", python: false }, - { file: "commonmark-r.qmd", python: false }, - { file: "commonmark-python.qmd", python: true }, - { file: "commonmark-julia.qmd", python: false }, + { file: "commonmark-plain.qmd" }, + { file: "commonmark-r.qmd" }, + { file: "commonmark-python.qmd"}, + { file: "commonmark-julia.qmd" }, + { file: "commonmark-julianative.qmd" }, ]; tests.forEach((test) => { const input = docs(join("markdown", test.file));