diff --git a/docs/.gitignore b/docs/.gitignore
new file mode 100644
index 00000000..0587d740
--- /dev/null
+++ b/docs/.gitignore
@@ -0,0 +1,4 @@
+build/
+node_modules/
+package-lock.json
+Manifest.toml
\ No newline at end of file
diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md
new file mode 100644
index 00000000..a7239d36
--- /dev/null
+++ b/docs/MIGRATION.md
@@ -0,0 +1,236 @@
+# Migration from Documenter to DocumenterVitepress
+
+## Migration Steps
+
+### 1. Update dependencies
+
+File **docs/Project.toml**
+
+- Add `DocumenterVitepress` (UUID: `4710194d-e776-4893-9690-8d956a29c365`)
+- Add `LiveServer` for local preview
+- Keep `Documenter` as a dependency
+
+```toml
+[deps]
+Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
+
+[compat]
+Documenter = "1"
+DocumenterVitepress = "0.3"
+LiveServer = "1"
+```
+
+### 2. Modify make.jl
+
+File **docs/make.jl**
+
+- Add `using DocumenterVitepress`
+- Replace `format=Documenter.HTML(...)` with `format=DocumenterVitepress.MarkdownVitepress(...)`
+- Replace `deploydocs` with `DocumenterVitepress.deploydocs`
+
+```julia
+using Documenter
+using DocumenterVitepress
+
+makedocs(;
+ # ... other arguments ...
+ format=DocumenterVitepress.MarkdownVitepress(;
+ repo="https://github.com/control-toolbox/CTBase.jl",
+ devbranch="main",
+ devurl="dev",
+ sidebar_drawer=true,
+ ),
+ # ...
+)
+
+DocumenterVitepress.deploydocs(;
+ repo="github.com/control-toolbox/CTBase.jl.git",
+ devbranch="main",
+ push_preview=true,
+)
+```
+
+### 3. Install Julia dependencies
+
+After editing `docs/Project.toml`, resolve and instantiate (the Manifest must be regenerated to include the new packages):
+
+```bash
+julia --project=docs -e 'using Pkg; Pkg.resolve(); Pkg.instantiate()'
+```
+
+### 4. Generate Vitepress configuration files
+
+`generate_template` requires `DocumenterVitepress` to be installed (step 3 must be done first).
+
+```bash
+julia --project=docs -e 'using DocumenterVitepress; DocumenterVitepress.generate_template("docs", "CTBase")'
+```
+
+This creates the following files (do not create them manually):
+
+In `docs/src/`:
+
+- `.vitepress/config.mts` - Main Vitepress configuration
+- `.vitepress/theme/index.ts` - Theme customization
+- `.vitepress/theme/style.css` - Custom CSS styles
+- `.vitepress/theme/docstrings.css` - Docstring block styles
+- `.vitepress/mathjax-plugin.ts` - MathJax plugin
+- `.vitepress/julia-repl-transformer.ts` - Julia REPL transformer
+- `components/VersionPicker.vue` - Version picker navbar component
+- `components/SidebarDrawerToggle.vue` - Sidebar collapse toggle
+- `components/AuthorBadge.vue` - Author badge component
+- `components/Authors.vue` - Authors list component
+
+At the root of `docs/`:
+
+- `package.json` - npm dependencies
+- `.gitignore` - ignores `build/`, `node_modules/`, `package-lock.json`, `Manifest.toml`
+
+### 5. Patch config.mts for remote assets
+
+The generated `docs/src/.vitepress/config.mts` does not include the control-toolbox CSS/JS assets. Edit the `head` section to add them.
+
+Replace the generated `head` block:
+
+```typescript
+head: [
+ ['link', { rel: 'icon', href: 'REPLACE_ME_DOCUMENTER_VITEPRESS_FAVICON' }],
+ ['script', {src: `${getBaseRepository(baseTemp.base)}versions.js`}],
+ ['script', {src: `${baseTemp.base}siteinfo.js`}]
+],
+```
+
+With the remote assets version (Option B):
+
+```typescript
+head: [
+ ['link', { rel: 'icon', href: 'REPLACE_ME_DOCUMENTER_VITEPRESS_FAVICON' }],
+ ['link', { rel: 'stylesheet', href: 'https://control-toolbox.org/assets/css/vitepress-documentation.css' }],
+ ['script', {src: `${getBaseRepository(baseTemp.base)}versions.js`}],
+ ['script', {src: 'https://control-toolbox.org/assets/js/vitepress-documentation.js'}],
+ ['script', {src: `${baseTemp.base}siteinfo.js`}]
+],
+```
+
+#### Option A: Local assets (for development only)
+
+If assets are not yet published remotely, use local files placed in `docs/src/assets/`. Add a Vite plugin in the `vite.plugins` section of `config.mts` to copy them at build time:
+
+```typescript
+import { copyFileSync, mkdirSync } from 'fs'
+
+let ctOutDir = ''
+
+// inside vite.plugins:
+{
+ name: 'ct-static-assets',
+ apply: 'build' as const,
+ configResolved(config: any) {
+ if (!config.build.ssr) ctOutDir = config.build.outDir
+ },
+ closeBundle() {
+ if (!ctOutDir) return
+ const assetsDir = path.join(ctOutDir, 'assets')
+ mkdirSync(assetsDir, { recursive: true })
+ for (const file of [
+ 'vitepress-documentation.css',
+ 'vitepress-documentation.js',
+ ]) {
+ try { copyFileSync(path.resolve(__dirname, '../assets', file), path.join(assetsDir, file)) } catch (_) {}
+ }
+ }
+},
+```
+
+And reference them in `head` using `${baseTemp.base}assets/...` instead of the remote URLs.
+
+### 6. Install npm dependencies
+
+```bash
+cd docs && npm install
+```
+
+### 7. Local build and preview
+
+```bash
+# Generate documentation
+julia --project=docs docs/make.jl
+
+# Local preview (output is in docs/build/1/, not docs/build/)
+julia --project=docs -e 'using LiveServer; LiveServer.serve(dir="docs/build/1")'
+```
+
+## Important notes
+
+- **ANSI color codes in @repl blocks**: DocumenterVitepress does not automatically convert ANSI escape codes to HTML in `@repl` blocks (unlike `@example` blocks which are converted to `ansi` code blocks). To avoid raw ANSI codes appearing in the generated markdown, wrap `showerror` calls with `IOContext(stdout, :color => false)`:
+
+ ```julia
+ try
+ throw(CTBase.Exceptions.IncorrectArgument("n must be positive"; got="-1"))
+ catch e
+ showerror(IOContext(stdout, :color => false), e)
+ end
+ ```
+
+ This is a known limitation tracked in [LuxDL/DocumenterVitepress.jl#321](https://github.com/LuxDL/DocumenterVitepress.jl/issues/321).
+
+- **Color-aware display functions**: If your package has custom display functions that use ANSI codes (e.g., error display helpers), make them color-aware by checking `get(io, :color, false)`. For example, in CTBase we implemented a helper function and updated all ANSI styling primitives:
+
+ ```julia
+ # src/Exceptions/display.jl
+ _apply_ansi(s, code, io::IO) = get(io, :color, false) ? "\033[$(code)m$(s)\033[0m" : s
+
+ _dim(s, io::IO) = _apply_ansi(s, "2", io)
+ _bold(s, io::IO) = _apply_ansi(s, "1", io)
+ _red(s, io::IO) = _apply_ansi(s, "1;31", io)
+ _yellow(s, io::IO) = _apply_ansi(s, "33", io)
+ _green(s, io::IO) = _apply_ansi(s, "32", io)
+ ```
+
+ Then propagate the `io` parameter to all call sites in display functions:
+
+ ```julia
+ function _format_user_friendly_error(io::IO, e::CTException)
+ # ...
+ print(io, _red(type_name, io)) # Pass io to the helper
+ # ...
+ end
+ ```
+
+ This ensures:
+ - REPL / GitHub Actions → colors enabled (`:color => true` by default)
+ - Documenter / VitePress → plain text when wrapped with `IOContext(stdout, :color => false)`)
+
+- **Git repository required**: DocumenterVitepress requires a git repository to function
+- **Build output**: Documentation is generated in `docs/build/1/` (not `docs/build/`)
+- **Do not create Vitepress files manually**: always use `generate_template` (step 4) — it generates all config, theme, components, and npm files
+- **Symlinks**: Before deployment, remove symlinks on the `gh-pages` branch (stable, v1, etc.)
+
+ Documenter.jl uses symlinks on the `gh-pages` branch to manage documentation versions:
+
+ - `stable` → points to the current stable version (e.g., `v0.5.0`)
+ - `v1` → points to the latest major version
+ - `v0.1`, `v0.2`, etc. → point to specific versions
+
+ DocumenterVitepress cannot write to symlinks. If you are migrating from an existing Documenter documentation, your `gh-pages` branch likely contains these symlinks. They must be manually removed before the first deployment with DocumenterVitepress.
+
+ **How to remove symlinks:**
+
+ 1. Go to GitHub: `https://github.com/control-toolbox/CTBase.jl/tree/gh-pages`
+ 2. Symlinks are identifiable by a small arrow ↗
+ 3. Click on each symlink (stable, v1, etc.)
+ 4. Delete them via the context menu
+
+ DocumenterVitepress handles versions differently, without using symlinks.
+- **Vitepress configuration**: The `REPLACE_ME_DOCUMENTER_VITEPRESS` strings are automatically replaced during the build
+- **TypeScript errors**: TypeScript errors in the IDE regarding `sidebar` and missing `node_modules` are normal before `npm install` — DocumenterVitepress replaces these values during the build
+
+## Deployment
+
+Deployment is done automatically via CI with `DocumenterVitepress.deploydocs`. Ensure that:
+
+- The GitHub repository exists
+- The `gh-pages` branch does not contain symlinks
+- CI workflows are configured for DocumenterVitepress
diff --git a/docs/Project.toml b/docs/Project.toml
index 9539b6b4..eb28eb76 100644
--- a/docs/Project.toml
+++ b/docs/Project.toml
@@ -1,10 +1,14 @@
[deps]
Coverage = "a2441757-f6aa-5fb2-8edb-039e3f45d037"
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
+DocumenterVitepress = "4710194d-e776-4893-9690-8d956a29c365"
+LiveServer = "16fef848-5104-11e9-1b77-fb7a48bbb589"
MarkdownAST = "d0879d2d-cac2-40c8-9cee-1863dc0c7391"
[compat]
Coverage = "1"
Documenter = "1"
+DocumenterVitepress = "0.3"
+LiveServer = "1"
MarkdownAST = "0.1"
julia = "1.10"
diff --git a/docs/make.jl b/docs/make.jl
index 19ff15ea..77de1930 100644
--- a/docs/make.jl
+++ b/docs/make.jl
@@ -4,6 +4,7 @@ pushfirst!(LOAD_PATH, joinpath(@__DIR__))
pushfirst!(LOAD_PATH, joinpath(@__DIR__, ".."))
using Documenter
+using DocumenterVitepress
using CTBase
using Markdown
using MarkdownAST: MarkdownAST
@@ -56,14 +57,11 @@ with_api_reference(src_dir) do api_pages
remotes=nothing, # Disable remote links. Needed for DocumenterReference
warnonly=[:cross_references],
sitename="CTBase.jl",
- format=Documenter.HTML(;
- repolink="https://" * repo_url,
- prettyurls=false,
- size_threshold_ignore=["api.md", "dev.md"],
- assets=[
- asset("https://control-toolbox.org/assets/css/documentation.css"),
- asset("https://control-toolbox.org/assets/js/documentation.js"),
- ],
+ format=DocumenterVitepress.MarkdownVitepress(;
+ repo="https://" * repo_url,
+ devbranch="main",
+ devurl="dev",
+ sidebar_drawer=true,
),
pages=[
"Introduction" => "index.md",
@@ -83,4 +81,8 @@ end
# ═══════════════════════════════════════════════════════════════════════════════
# Deploy documentation to GitHub Pages
# ═══════════════════════════════════════════════════════════════════════════════
-deploydocs(; repo=repo_url * ".git", devbranch="main")
+DocumenterVitepress.deploydocs(;
+ repo=repo_url * ".git",
+ devbranch="main",
+ push_preview=true,
+)
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 00000000..cfca9a46
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,20 @@
+{
+ "devDependencies": {
+ "@types/markdown-it-footnote": "^3.0.4",
+ "@types/node": "^25.3.5"
+ },
+ "scripts": {
+ "docs:dev": "vitepress dev build/.documenter",
+ "docs:build": "vitepress build build/.documenter",
+ "docs:preview": "vitepress preview build/.documenter"
+ },
+ "dependencies": {
+ "@mdit/plugin-mathjax": "^0.26.1",
+ "@mdit/plugin-tex": "^0.24.1",
+ "@nolebase/vitepress-plugin-enhanced-readabilities": "^2.18.2",
+ "markdown-it": "^14.1.0",
+ "markdown-it-footnote": "^4.0.0",
+ "vitepress": "^1.6.4",
+ "vitepress-plugin-tabs": "^0.9.0"
+ }
+}
diff --git a/docs/src/.vitepress/config.mts b/docs/src/.vitepress/config.mts
new file mode 100644
index 00000000..3e64b2d8
--- /dev/null
+++ b/docs/src/.vitepress/config.mts
@@ -0,0 +1,107 @@
+import { defineConfig } from 'vitepress'
+import { tabsMarkdownPlugin } from 'vitepress-plugin-tabs'
+import { mathjaxPlugin } from './mathjax-plugin'
+import { juliaReplTransformer } from './julia-repl-transformer'
+import footnote from "markdown-it-footnote";
+import path from 'path'
+
+const mathjax = mathjaxPlugin()
+
+function getBaseRepository(base: string): string {
+ if (!base || base === '/') return '/';
+ const parts = base.split('/').filter(Boolean);
+ return parts.length > 0 ? `/${parts[0]}/` : '/';
+}
+
+const baseTemp = {
+ base: 'REPLACE_ME_DOCUMENTER_VITEPRESS',// TODO: replace this in makedocs!
+}
+
+const navTemp = {
+ nav: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+}
+
+const nav = [
+ ...navTemp.nav,
+ {
+ component: 'VersionPicker'
+ }
+]
+
+// https://vitepress.dev/reference/site-config
+export default defineConfig({
+ base: 'REPLACE_ME_DOCUMENTER_VITEPRESS',// TODO: replace this in makedocs!
+ title: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ description: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ lastUpdated: true,
+ cleanUrls: true,
+ outDir: 'REPLACE_ME_DOCUMENTER_VITEPRESS', // This is required for MarkdownVitepress to work correctly...
+ head: [
+ ['link', { rel: 'icon', href: 'REPLACE_ME_DOCUMENTER_VITEPRESS_FAVICON' }],
+ ['link', { rel: 'stylesheet', href: 'https://control-toolbox.org/assets/css/vitepress-documentation.css' }],
+ ['script', {src: `${getBaseRepository(baseTemp.base)}versions.js`}],
+ ['script', {src: 'https://control-toolbox.org/assets/js/vitepress-documentation.js'}],
+ ['script', {src: `${baseTemp.base}siteinfo.js`}]
+ ],
+
+ markdown: {
+ codeTransformers: [juliaReplTransformer()],
+ config(md) {
+ md.use(tabsMarkdownPlugin);
+ md.use(footnote);
+ mathjax.markdownConfig(md);
+ },
+ theme: {
+ light: "github-light",
+ dark: "github-dark"
+ },
+ },
+ vite: {
+ plugins: [
+ mathjax.vitePlugin,
+ ],
+ define: {
+ __DEPLOY_ABSPATH__: JSON.stringify('REPLACE_ME_DOCUMENTER_VITEPRESS_DEPLOY_ABSPATH'),
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, '../components')
+ }
+ },
+ optimizeDeps: {
+ exclude: [
+ '@nolebase/vitepress-plugin-enhanced-readabilities/client',
+ 'vitepress',
+ '@nolebase/ui',
+ ],
+ },
+ ssr: {
+ noExternal: [
+ // If there are other packages that need to be processed by Vite, you can add them here.
+ '@nolebase/vitepress-plugin-enhanced-readabilities',
+ '@nolebase/ui',
+ ],
+ },
+ },
+ themeConfig: {
+ outline: 'deep',
+ logo: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ search: {
+ provider: 'local',
+ options: {
+ detailedView: true
+ }
+ },
+ nav,
+ sidebar: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ sidebarDrawer: 'REPLACE_ME_DOCUMENTER_VITEPRESS_SIDEBAR_DRAWER',
+ editLink: 'REPLACE_ME_DOCUMENTER_VITEPRESS',
+ socialLinks: [
+ { icon: 'github', link: 'REPLACE_ME_DOCUMENTER_VITEPRESS' }
+ ],
+ footer: {
+ message: 'Made with DocumenterVitepress.jl
',
+ copyright: `© Copyright ${new Date().getUTCFullYear()}.`
+ }
+ }
+})
diff --git a/docs/src/.vitepress/julia-repl-transformer.ts b/docs/src/.vitepress/julia-repl-transformer.ts
new file mode 100644
index 00000000..2f372e74
--- /dev/null
+++ b/docs/src/.vitepress/julia-repl-transformer.ts
@@ -0,0 +1,54 @@
+import type { ShikiTransformer } from "shiki"
+
+type PromptKind = "julia" | "pkg" | null
+
+export function juliaReplTransformer(): ShikiTransformer {
+ let promptInfoByLine: Array<{ len: number; kind: PromptKind }> = []
+ let isJuliaBlock = false
+ const rules: Array<{ kind: PromptKind; re: RegExp }> = [
+ { kind: "julia", re: /^julia>/ },
+ { kind: "pkg", re: /^(\([^)]*\)\s*)?pkg>/ }, // handles (@v1.9) pkg>
+ ]
+
+ function classify(line: string): { len: number; kind: PromptKind } {
+ for (const r of rules) {
+ const m = line.match(r.re)
+ if (m) return { len: m[0].length, kind: r.kind }
+ }
+
+ return { len: 0, kind: null }
+ }
+
+ return {
+ name: "julia-repl-prompts",
+
+ preprocess(code, options) {
+ isJuliaBlock = options.lang === "julia"
+ return code
+ },
+
+ tokens(tokens) {
+ if (!isJuliaBlock) {
+ promptInfoByLine = []
+ return
+ }
+
+ promptInfoByLine = tokens.map((lineTokens) => {
+ const line = lineTokens.map((t) => t.content).join("")
+ return classify(line)
+ })
+ },
+
+ span(node, line, col) {
+ if (!isJuliaBlock) return
+
+ const info = promptInfoByLine[line - 1]
+ if (!info || !info.kind || info.len <= 0) return
+
+ if (col < info.len) {
+ this.addClassToHast(node, "repl-prompt")
+ this.addClassToHast(node, `repl-prompt-${info.kind}`)
+ }
+ },
+ }
+}
diff --git a/docs/src/.vitepress/mathjax-plugin.ts b/docs/src/.vitepress/mathjax-plugin.ts
new file mode 100644
index 00000000..4307d32d
--- /dev/null
+++ b/docs/src/.vitepress/mathjax-plugin.ts
@@ -0,0 +1,142 @@
+// adapter from https://github.com/orgs/vuepress-theme-hope/discussions/5178#discussioncomment-15642629
+// mathjax-plugin.ts
+// @ts-ignore
+import MathJax from '@mathjax/src'
+import type { Plugin as VitePlugin } from 'vite'
+import type MarkdownIt from 'markdown-it'
+import { tex as mdTex } from '@mdit/plugin-tex'
+
+const mathjaxStyleModuleID = 'virtual:mathjax-styles.css'
+
+interface MathJaxOptions {
+ font?: string
+}
+
+async function initializeMathJax(options: MathJaxOptions = {}) {
+ const font = options.font || 'mathjax-newcm'
+
+ const config: any = {
+ loader: {
+ load: [
+ 'input/tex',
+ 'output/svg',
+ '[tex]/boldsymbol',
+ '[tex]/braket',
+ '[tex]/mathtools',
+ ],
+ paths: { mathjax: '@mathjax/src/bundle' },
+ },
+ tex: {
+ tags: 'ams',
+ packages: {
+ '[+]': ['boldsymbol', 'braket', 'mathtools'],
+ },
+ },
+ output: {
+ font,
+ displayOverflow: 'linebreak',
+ mtextInheritFont: true,
+ },
+ svg: {
+ fontCache: 'none', // critical: avoids async font loading
+ },
+ }
+
+ await MathJax.init(config)
+ await MathJax.startup.document.outputJax.font.loadDynamicFiles()
+}
+
+export function mathjaxPlugin(options: MathJaxOptions = {}) {
+ let adaptor: any
+ let initialized = false
+
+ async function ensureInitialized() {
+ if (!initialized) {
+ await initializeMathJax(options)
+ adaptor = MathJax.startup.adaptor
+ initialized = true
+ }
+ }
+
+ function renderMath(content: string, displayMode: boolean): string {
+ if (!initialized) {
+ throw new Error('MathJax not initialized')
+ }
+
+ const node = MathJax.tex2svg(content, { display: displayMode })
+
+ // Prevent Vue from touching MathJax output
+ adaptor.setAttribute(node, 'v-pre', '')
+
+ let html = adaptor.outerHTML(node)
+
+ // Preserve spaces inside mjx-break (SVG only)
+ html = html.replace(
+ /(.*?)<\/mjx-break>/g,
+ (_: string, attr: string, inner: string) =>
+ `${inner.replace(/ /g, ' ')}`,
+ )
+
+ // Wrap only display equations (not inline math)
+ html = html.replace(
+ /(]*display="true"[^>]*>)([\s\S]*?)(<\/mjx-container>)/,
+ '$1$2$3
'
+ )
+
+ return html
+ }
+
+ function getMathJaxStyles(): string {
+ return initialized
+ ? adaptor.textContent(MathJax.svgStylesheet()) || ''
+ : ''
+ }
+
+ function resetMathJax(): void {
+ if (!initialized) return
+ MathJax.texReset()
+ MathJax.typesetClear()
+ }
+
+ function viteMathJax(): VitePlugin {
+ const virtualModuleID = '\0' + mathjaxStyleModuleID
+
+ return {
+ name: 'mathjax-styles',
+
+ resolveId(id) {
+ if (id === mathjaxStyleModuleID) {
+ return virtualModuleID
+ }
+ },
+
+ async load(id) {
+ if (id === virtualModuleID) {
+ await ensureInitialized()
+ return getMathJaxStyles()
+ }
+ },
+ }
+ }
+
+ function mdMathJax(md: MarkdownIt): void {
+ mdTex(md, {
+ render: renderMath,
+ })
+
+ const orig = md.render
+ md.render = function (...args) {
+ resetMathJax()
+ return orig.apply(this, args)
+ }
+ }
+
+ const init = ensureInitialized()
+
+ return {
+ vitePlugin: viteMathJax(),
+ markdownConfig: mdMathJax,
+ styleModuleID: mathjaxStyleModuleID,
+ init,
+ }
+}
\ No newline at end of file
diff --git a/docs/src/.vitepress/theme/docstrings.css b/docs/src/.vitepress/theme/docstrings.css
new file mode 100644
index 00000000..4d992460
--- /dev/null
+++ b/docs/src/.vitepress/theme/docstrings.css
@@ -0,0 +1,51 @@
+.jldocstring.custom-block {
+ border: 1px solid var(--vp-c-gray-2);
+ color: var(--vp-c-text-1);
+ overflow: hidden;
+}
+
+.jldocstring.custom-block summary {
+ font-weight: 700;
+ cursor: pointer;
+ user-select: none;
+ margin: 0 0 8px;
+}
+.jldocstring.custom-block summary a {
+ pointer-events: none;
+ text-decoration: none;
+}
+
+.jldocstring.custom-block .source-link {
+ border: 1px solid var(--vp-c-gray-2);
+ border-radius: 4px;
+ text-decoration: none;
+ background-color: #414040;
+ float: right;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-5px);
+ transition: all 0.5s cubic-bezier(0.25, 0.1, 0.25, 1);
+}
+
+.jldocstring.custom-block .source-link a {
+ text-decoration: none;
+ color: #e5e5e5;
+}
+
+.jldocstring.custom-block .source-link a:hover {
+ text-decoration: underline;
+}
+
+.jldocstring.custom-block:hover .source-link {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+@media (max-width: 768px) {
+ .jldocstring.custom-block .source-link {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+ }
+}
diff --git a/docs/src/.vitepress/theme/index.ts b/docs/src/.vitepress/theme/index.ts
new file mode 100644
index 00000000..ec0a8cad
--- /dev/null
+++ b/docs/src/.vitepress/theme/index.ts
@@ -0,0 +1,43 @@
+// .vitepress/theme/index.ts
+import { h } from 'vue'
+import DefaultTheme from 'vitepress/theme'
+import type { Theme as ThemeConfig } from 'vitepress'
+import 'virtual:mathjax-styles.css';
+
+import {
+ NolebaseEnhancedReadabilitiesMenu,
+ NolebaseEnhancedReadabilitiesScreenMenu,
+} from '@nolebase/vitepress-plugin-enhanced-readabilities/client'
+
+import VersionPicker from "@/VersionPicker.vue"
+import AuthorBadge from '@/AuthorBadge.vue'
+import Authors from '@/Authors.vue'
+import SidebarDrawerToggle from '@/SidebarDrawerToggle.vue'
+
+import { enhanceAppWithTabs } from 'vitepress-plugin-tabs/client'
+
+import '@nolebase/vitepress-plugin-enhanced-readabilities/client/style.css'
+import './style.css' // You could setup your own, or else a default will be copied.
+import './docstrings.css' // You could setup your own, or else a default will be copied.
+
+export const Theme: ThemeConfig = {
+ extends: DefaultTheme,
+ Layout() {
+ return h(DefaultTheme.Layout, null, {
+ 'nav-bar-content-after': () => [
+ h(NolebaseEnhancedReadabilitiesMenu), // Enhanced Readabilities menu
+ ],
+ // A enhanced readabilities menu for narrower screens (usually smaller than iPad Mini)
+ 'nav-screen-content-after': () => h(NolebaseEnhancedReadabilitiesScreenMenu),
+ // Sidebar drawer toggle button (to the left of search bar)
+ 'nav-bar-content-before': () => h(SidebarDrawerToggle),
+ })
+ },
+ enhanceApp({ app, router, siteData }) {
+ enhanceAppWithTabs(app);
+ app.component('VersionPicker', VersionPicker);
+ app.component('AuthorBadge', AuthorBadge)
+ app.component('Authors', Authors)
+ }
+}
+export default Theme
\ No newline at end of file
diff --git a/docs/src/.vitepress/theme/style.css b/docs/src/.vitepress/theme/style.css
new file mode 100644
index 00000000..76f87af7
--- /dev/null
+++ b/docs/src/.vitepress/theme/style.css
@@ -0,0 +1,323 @@
+/* Customize default theme styling by overriding CSS variables:
+https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css */
+/* Example */
+/* https://github.com/vuejs/vitepress/blob/main/template/.vitepress/theme/style.css */
+
+.VPHero .clip {
+ white-space: pre;
+ max-width: 600px;
+}
+
+/* Fonts */
+@font-face {
+ font-family: JuliaMono-Regular;
+ src: url("https://cdn.jsdelivr.net/gh/cormullion/juliamono/webfonts/JuliaMono-Regular.woff2");
+}
+
+:root {
+scroll-behavior: smooth;
+/* Typography */
+--vp-font-family-base: "Barlow", "Inter var experimental", "Inter var",
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
+ Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
+
+/* Code Snippet font */
+--vp-font-family-mono: JuliaMono-Regular, monospace;
+}
+
+/* Disable contextual alternates (kind of like ligatures but different) in monospace,
+ which turns `/>` to an up arrow and `|>` (the Julia pipe symbol) to an up arrow as well. */
+.mono-no-substitutions {
+font-family: "JuliaMono-Regular", monospace;
+font-feature-settings: "calt" off;
+}
+
+.mono-no-substitutions-alt {
+font-family: "JuliaMono-Regular", monospace;
+font-variant-ligatures: none;
+}
+
+pre, code {
+font-family: "JuliaMono-Regular", monospace;
+font-feature-settings: "calt" off;
+}
+
+/* Colors */
+:root {
+ --julia-blue: #4063D8;
+ --julia-purple: #9558B2;
+ --julia-red: #CB3C33;
+ --julia-green: #389826;
+
+ --vp-c-brand: #0087d7;
+ --vp-c-brand-1: #0890df;
+ --vp-c-brand-2: #0599ef;
+ --vp-c-brand-3: #0c9ff4;
+ --vp-c-brand-light: #0087d7;
+ --vp-c-brand-dark: #5fd7ff;
+ --vp-c-brand-dimm: #212425;
+
+ /* Greens */
+ --vp-dark-green: #155f3e; /* Main accent green */
+ --vp-dark-green-dark: #2b855c;
+ --vp-dark-green-light: #42d392;
+ --vp-dark-green-lighter: #35eb9a;
+ /* Complementary Colors */
+ --vp-dark-gray: #1e1e1e;
+ --vp-dark-gray-soft: #2a2a2a;
+ --vp-dark-gray-mute: #242424;
+ --vp-light-gray: #d1d5db;
+ --vp-tip-bg: rgb(254, 254, 254);
+
+ /* Text Colors */
+ --vp-dark-text: #e5e5e5; /* Primary text color */
+ --vp-dark-subtext: #c1c1c1; /* Subtle text */
+ --vp-source-text: #e5e5e5;
+ /* custom tip */
+ --vp-custom-block-tip-border: var(--vp-c-brand-light);
+ --vp-custom-block-tip-bg: var(--vp-tip-bg);
+}
+
+ /* Component: Button */
+:root {
+ --vp-button-brand-border: var(--vp-light-gray);
+ --vp-button-brand-bg: var(--vp-c-brand-light);
+ --vp-button-brand-hover-border: var(--vp-c-bg-alt);
+ --vp-button-brand-hover-bg: var(--julia-blue);
+}
+
+/* Component: Home */
+:root {
+ --vp-home-hero-name-color: transparent;
+ --vp-home-hero-name-background: -webkit-linear-gradient(
+ 120deg,
+ #9558B2 30%,
+ #CB3C33
+ );
+
+ --vp-home-hero-image-background-image: linear-gradient(
+ -145deg,
+ #9558b282 30%,
+ #3798269a 30%,
+ #cb3d33e3
+ );
+ --vp-home-hero-image-filter: blur(40px);
+}
+
+/* Hero Section */
+:root.dark {
+ --vp-home-hero-name-color: transparent;
+ --vp-home-hero-name-background: -webkit-linear-gradient(
+ 120deg,
+ var(--julia-purple) 15%,
+ var(--vp-dark-green-light),
+ var(--vp-dark-green)
+
+ );
+ --vp-home-hero-image-background-image: linear-gradient(
+ -45deg,
+ var(--vp-dark-green) 30%,
+ var(--vp-dark-green-light),
+ var(--vp-dark-gray) 30%
+ );
+ --vp-home-hero-image-filter: blur(56px);
+}
+
+:root.dark {
+ /* custom tip */
+ --vp-custom-block-tip-border: var(--vp-dark-green-dark);
+ --vp-custom-block-tip-text: var(--vp-dark-subtext);
+ --vp-custom-block-tip-bg: var(--vp-dark-gray-mute);
+}
+
+/**
+ * Colors links
+ * -------------------------------------------------------------------------- */
+
+.dark {
+ --vp-c-brand: var(--vp-dark-green-light);
+ --vp-button-brand-border: var(--vp-dark-green-lighter);
+ --vp-button-brand-bg: var(--vp-dark-green);
+ --vp-c-brand-1: var(--vp-dark-green-light);
+ --vp-c-brand-2: var(--vp-dark-green-lighter);
+ --vp-c-brand-3: var(--vp-dark-green);
+}
+
+@media (min-width: 640px) {
+ :root {
+ --vp-home-hero-image-filter: blur(56px);
+ }
+}
+
+@media (min-width: 960px) {
+ :root {
+ --vp-home-hero-image-filter: blur(72px);
+ }
+}
+/* Component: MathJax */
+
+
+.mjx-scroll-wrapper {
+ display: block;
+ max-width: 100%;
+ overflow-x: auto;
+ overflow-y: hidden;
+ text-align: center;
+}
+
+mjx-container {
+ display: inline-block;
+ padding: 0.5rem 0;
+ margin: auto 2px -2px;
+ overflow: visible !important; /* Override MathJax's overflow */
+ text-align: left;
+}
+
+mjx-container > svg {
+ display: inline-block;
+ height: auto;
+}
+
+mjx-container > svg[data-labels="true"] {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+
+/* @mdit/plugin-mathjax theming refs */
+
+mjx-container svg path {
+ fill: currentColor !important;
+ stroke: currentColor !important;
+}
+
+.MathJax_ref {
+ color: var(--vp-c-brand-1);
+}
+
+.MathJax_ref:hover {
+ color: var(--vp-c-brand-2);
+ cursor: pointer;
+}
+
+/* ===== Custom colors for input and output code blocks ===== */
+:root {
+ /* --vp-c-bg-input: #eef0f3; */
+ --vp-c-bg-output: #fbfbfb;
+ --vp-c-bg-output-outline: #e2e2e3;
+}
+
+.dark {
+ /* --vp-c-bg-input: #1a1a1a; */
+ --vp-c-bg-output: #1a1a1a;
+ --vp-c-bg-output-outline: #2e2e32;
+}
+
+/*
+.language-julia {
+ background-color: var(--vp-c-bg-input) !important;
+}
+*/
+
+.language- {
+ background-color: var(--vp-c-bg-output) !important;
+ outline: 1px solid var(--vp-c-bg-output-outline);
+}
+
+/* Julia REPL prompt syntax highlighting */
+/* prompt token behavior */
+.vp-doc .shiki .repl-prompt {
+ opacity: 1.0;
+ user-select: none;
+}
+
+:root:not(.dark) .vp-doc .shiki .repl-prompt-julia {
+ color: var(--vp-dark-green);
+}
+
+:root:not(.dark) .vp-doc .shiki .repl-prompt-pkg {
+ color: var(--vp-c-brand-light);
+}
+
+.dark .vp-doc .shiki .repl-prompt-julia {
+ color: var(--vp-dark-green-light);
+}
+
+.dark .vp-doc .shiki .repl-prompt-pkg {
+ color: var(--vp-c-brand-dark);
+}
+
+/* ===== Sidebar Drawer Toggle ===== */
+/* Smooth transitions for sidebar collapse/expand on desktop */
+@media (min-width: 960px) {
+ .VPSidebar {
+ transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.25s ease;
+ }
+
+ .VPContent.has-sidebar {
+ transition: padding-left 0.35s cubic-bezier(0.4, 0, 0.2, 1),
+ padding-right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ /* Smooth transitions for navbar elements (needed for expand-back too) */
+ .VPNavBar.has-sidebar .content {
+ transition: padding-left 0.35s cubic-bezier(0.4, 0, 0.2, 1) !important;
+ }
+
+ .VPNavBar.has-sidebar .divider {
+ transition: padding-left 0.35s cubic-bezier(0.4, 0, 0.2, 1) !important;
+ }
+
+ .VPNavBar.has-sidebar .title {
+ transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
+ padding 0.35s cubic-bezier(0.4, 0, 0.2, 1),
+ opacity 0.25s ease !important;
+ }
+
+ /* Collapsed state: zero the sidebar width variable so all layout formulas
+ (VitePress defaults + Enhanced Readabilities plugin) adjust automatically */
+ html.sidebar-drawer-collapsed {
+ --vp-sidebar-width: 0px !important;
+ }
+
+ html.sidebar-drawer-collapsed .VPSidebar {
+ transform: translateX(-100%);
+ opacity: 0;
+ pointer-events: none;
+ }
+
+ /* Original width mode only: let content fill space up to the aside */
+ html.sidebar-drawer-collapsed
+ body:not(.VPNolebaseEnhancedReadabilitiesLayoutSwitchFullWidth):not(.VPNolebaseEnhancedReadabilitiesLayoutSwitchSidebarWidthAdjustableOnly):not(.VPNolebaseEnhancedReadabilitiesLayoutSwitchBothWidthAdjustable)
+ .VPDoc.has-sidebar .container {
+ display: flex;
+ justify-content: center;
+ max-width: 992px;
+ }
+
+ html.sidebar-drawer-collapsed
+ body:not(.VPNolebaseEnhancedReadabilitiesLayoutSwitchFullWidth):not(.VPNolebaseEnhancedReadabilitiesLayoutSwitchSidebarWidthAdjustableOnly):not(.VPNolebaseEnhancedReadabilitiesLayoutSwitchBothWidthAdjustable)
+ .VPDoc.has-sidebar .content-container {
+ max-width: none;
+ }
+
+ /* Collapse the navbar title area (site logo + name) */
+ html.sidebar-drawer-collapsed .VPNavBar.has-sidebar .title,
+ html.sidebar-drawer-collapsed .VPNavBar.has-sidebar > .wrapper > .container > .title {
+ width: 0 !important;
+ overflow: hidden;
+ opacity: 0;
+ padding: 0 !important;
+ pointer-events: none;
+ }
+
+ /* Move drawer button closer to the edge */
+ html.sidebar-drawer-collapsed .VPNavBar.has-sidebar .content,
+ html.sidebar-drawer-collapsed .VPNavBar.has-sidebar > .wrapper > .container > .content {
+ padding-left: 8px !important;
+ }
+}
\ No newline at end of file
diff --git a/docs/src/components/AuthorBadge.vue b/docs/src/components/AuthorBadge.vue
new file mode 100644
index 00000000..a64b0afd
--- /dev/null
+++ b/docs/src/components/AuthorBadge.vue
@@ -0,0 +1,139 @@
+
+
+
+ Author
+
+
+ {{ author }}
+
+
+
+
+ Author
+
+
+ {{ author }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/src/components/Authors.vue b/docs/src/components/Authors.vue
new file mode 100644
index 00000000..ee7920b4
--- /dev/null
+++ b/docs/src/components/Authors.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/src/components/SidebarDrawerToggle.vue b/docs/src/components/SidebarDrawerToggle.vue
new file mode 100644
index 00000000..96a10b0b
--- /dev/null
+++ b/docs/src/components/SidebarDrawerToggle.vue
@@ -0,0 +1,110 @@
+
+
+
+
+
+
+
+
diff --git a/docs/src/components/VersionPicker.vue b/docs/src/components/VersionPicker.vue
new file mode 100644
index 00000000..d03b2e84
--- /dev/null
+++ b/docs/src/components/VersionPicker.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/src/guide/coverage.md b/docs/src/guide/coverage.md
index 4e4ec5e5..e0a3d9b6 100644
--- a/docs/src/guide/coverage.md
+++ b/docs/src/guide/coverage.md
@@ -105,7 +105,7 @@ julia --project=@v1.12 -e 'using Pkg; Pkg.add("Coverage")'
julia --project -e 'using Pkg; Pkg.test("MyPackage"; coverage=true)'
```
-### Issue: Coverage report shows 0% for all files
+### Issue: Coverage report shows zero percent for all files
**Problem**: Coverage data exists but shows no coverage
diff --git a/docs/src/index.md b/docs/src/index.md
index d9e5b1c2..89c7fcd6 100644
--- a/docs/src/index.md
+++ b/docs/src/index.md
@@ -44,7 +44,7 @@ CTBase.Descriptions.complete(:euler, :explicit; descriptions=descs)
try
throw(CTBase.Exceptions.IncorrectArgument("n must be positive"; got="-1"))
catch e
- showerror(stdout, e)
+ showerror(IOContext(stdout, :color => false), e)
end
```
diff --git a/src/Exceptions/display.jl b/src/Exceptions/display.jl
index 10bbb045..79f8b182 100644
--- a/src/Exceptions/display.jl
+++ b/src/Exceptions/display.jl
@@ -1,6 +1,22 @@
# Custom display functions for user-friendly error messages
# ANSI formatting primitives
+"""
+ _apply_ansi(s, code, io::IO)
+
+Apply ANSI escape codes to a string if color is enabled in the IO context.
+
+# Arguments
+- `s::AbstractString`: The string to style.
+- `code::String`: The ANSI code (e.g., "2" for dim, "1" for bold, "1;31" for red).
+- `io::IO`: Output stream to check for color support.
+
+# Returns
+- `String`: The string wrapped in ANSI escape codes if `get(io, :color, false)` is true,
+ otherwise the plain string.
+"""
+_apply_ansi(s, code, io::IO) = get(io, :color, false) ? "\033[$(code)m$(s)\033[0m" : s
+
"""
$(TYPEDSIGNATURES)
@@ -8,11 +24,12 @@ Apply dimmed (faint) ANSI styling to a string.
# Arguments
- `s::AbstractString`: The string to style.
+- `io::IO`: Output stream to check for color support.
# Returns
-- `String`: The string wrapped in dim ANSI escape codes.
+- `String`: The string wrapped in dim ANSI escape codes if color is enabled.
"""
-_dim(s) = "\033[2m$(s)\033[0m"
+_dim(s, io::IO) = _apply_ansi(s, "2", io)
"""
$(TYPEDSIGNATURES)
@@ -21,11 +38,12 @@ Apply bold ANSI styling to a string.
# Arguments
- `s::AbstractString`: The string to style.
+- `io::IO`: Output stream to check for color support.
# Returns
-- `String`: The string wrapped in bold ANSI escape codes.
+- `String`: The string wrapped in bold ANSI escape codes if color is enabled.
"""
-_bold(s) = "\033[1m$(s)\033[0m"
+_bold(s, io::IO) = _apply_ansi(s, "1", io)
"""
$(TYPEDSIGNATURES)
@@ -34,11 +52,12 @@ Apply red ANSI styling to a string.
# Arguments
- `s::AbstractString`: The string to style.
+- `io::IO`: Output stream to check for color support.
# Returns
-- `String`: The string wrapped in red ANSI escape codes.
+- `String`: The string wrapped in red ANSI escape codes if color is enabled.
"""
-_red(s) = "\033[1;31m$(s)\033[0m"
+_red(s, io::IO) = _apply_ansi(s, "1;31", io)
"""
$(TYPEDSIGNATURES)
@@ -47,11 +66,12 @@ Apply yellow ANSI styling to a string.
# Arguments
- `s::AbstractString`: The string to style.
+- `io::IO`: Output stream to check for color support.
# Returns
-- `String`: The string wrapped in yellow ANSI escape codes.
+- `String`: The string wrapped in yellow ANSI escape codes if color is enabled.
"""
-_yellow(s) = "\033[33m$(s)\033[0m"
+_yellow(s, io::IO) = _apply_ansi(s, "33", io)
"""
$(TYPEDSIGNATURES)
@@ -60,11 +80,12 @@ Apply green ANSI styling to a string.
# Arguments
- `s::AbstractString`: The string to style.
+- `io::IO`: Output stream to check for color support.
# Returns
-- `String`: The string wrapped in green ANSI escape codes.
+- `String`: The string wrapped in green ANSI escape codes if color is enabled.
"""
-_green(s) = "\033[32m$(s)\033[0m"
+_green(s, io::IO) = _apply_ansi(s, "32", io)
"""
extract_user_frames(st::Vector)
@@ -339,16 +360,16 @@ function _print_pipe_field(io, label::String, value, max_len::Int, color::Symbol
# Multi-line case: AmbiguousDescription.candidates
for (i, v) in enumerate(value)
if i == 1
- print(io, _dim("│"), " ", _bold(rpad(label, max_len)), " ")
+ print(io, _dim("│", io), " ", _bold(rpad(label, max_len), io), " ")
_print_colored(io, string(v), color)
println(io)
else
- println(io, _dim("│"), " ", " "^max_len, " ", string(v))
+ println(io, _dim("│", io), " ", " "^max_len, " ", string(v))
end
end
else
# Single value
- print(io, _dim("│"), " ", _bold(rpad(label, max_len)), " ")
+ print(io, _dim("│", io), " ", _bold(rpad(label, max_len), io), " ")
_print_colored(io, string(value), color)
println(io)
end
@@ -369,9 +390,9 @@ Print colored text based on a color symbol.
"""
function _print_colored(io, text, color::Symbol)
if color == :yellow
- print(io, _yellow(text))
+ print(io, _yellow(text, io))
elseif color == :green
- print(io, _green(text))
+ print(io, _green(text, io))
else
print(io, text)
end
@@ -392,22 +413,22 @@ function _format_user_friendly_error(io::IO, e::CTException)
frame = isempty(user_frames) ? nothing : user_frames[1]
type_name = string(nameof(typeof(e)))
- print(io, _red(type_name))
+ print(io, _red(type_name, io))
if !isnothing(frame)
func_name = string(frame.func)
file_name = basename(string(frame.file))
line_num = frame.line
- print(io, " ", _dim("→"), " ", _bold(func_name), " ")
- print(io, _yellow("$(file_name):$(line_num)"))
+ print(io, " ", _dim("→", io), " ", _bold(func_name, io), " ")
+ print(io, _yellow("$(file_name):$(line_num)", io))
end
println(io)
# Blank pipe separator
- println(io, _dim("│"))
+ println(io, _dim("│", io))
# Message
- println(io, _dim("│"), " ", _bold(e.msg))
+ println(io, _dim("│", io), " ", _bold(e.msg, io))
# Build field pairs
primary_pairs = _build_primary_pairs(e)
@@ -419,7 +440,7 @@ function _format_user_friendly_error(io::IO, e::CTException)
max_len = maximum(length(p[1]) for p in all_pairs)
# Blank pipe separator
- println(io, _dim("│"))
+ println(io, _dim("│", io))
# Primary fields
for p in primary_pairs
@@ -428,7 +449,7 @@ function _format_user_friendly_error(io::IO, e::CTException)
# Separator between primary and secondary
if !isempty(primary_pairs) && !isempty(secondary_pairs)
- println(io, _dim("│"))
+ println(io, _dim("│", io))
end
# Secondary fields
@@ -438,7 +459,7 @@ function _format_user_friendly_error(io::IO, e::CTException)
end
# Closing visual
- return println(io, _dim("└─"))
+ return println(io, _dim("└─", io))
end
"""
diff --git a/test/runtests.jl b/test/runtests.jl
index 6e429452..08623a07 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -39,6 +39,7 @@ CTBase.run_tests(;
verbose=VERBOSE,
showtiming=SHOWTIMING,
test_dir=@__DIR__,
+ show_progress_bar=false,
)
# If running with coverage enabled, remind the user to run the post-processing script
diff --git a/test/suite/exceptions/test_exception_display.jl b/test/suite/exceptions/test_exception_display.jl
index 692ed8ab..d5874dec 100644
--- a/test/suite/exceptions/test_exception_display.jl
+++ b/test/suite/exceptions/test_exception_display.jl
@@ -37,6 +37,8 @@ function test_exception_display()
Test.@test contains(output, "test function")
Test.@test contains(output, "Hint")
Test.@test contains(output, "Fix it like this")
+ # Regression: IOBuffer without :color should not contain ANSI codes
+ Test.@test !contains(output, "\033[")
end
Test.@testset "IncorrectArgument - Full Stacktrace Display" begin
@@ -476,6 +478,24 @@ function test_exception_display()
Test.@test contains(output, "closest matches")
Test.@test contains(output, "(:a, :b, :c)")
end
+
+ Test.@testset "Color-aware output" begin
+ e = Exceptions.IncorrectArgument("msg"; got="x")
+
+ # color=false — sortie texte brut
+ io_plain = IOContext(IOBuffer(), :color => false)
+ showerror(io_plain, e)
+ out_plain = String(take!(io_plain.io))
+ Test.@test !contains(out_plain, "\033[")
+ Test.@test contains(out_plain, "msg")
+
+ # color=true — sortie avec codes ANSI
+ io_color = IOContext(IOBuffer(), :color => true)
+ showerror(io_color, e)
+ out_color = String(take!(io_color.io))
+ Test.@test contains(out_color, "\033[")
+ Test.@test contains(out_color, "msg")
+ end
end
end