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 @@ + + + + + \ 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