From 6db43a77a1395dc539b7b98ddf3cf83b8e6c3868 Mon Sep 17 00:00:00 2001 From: Mark Bacon Date: Wed, 21 Oct 2026 14:31:54 +0100 Subject: [PATCH 1/6] Support Option 1 (HTML twin --watch server) and Option 3 (Instant previews) for Word, slides, and posters --- .gitignore | 1 + editors/vscode/README.md | 39 +++++++--- editors/vscode/extension.js | 95 ++++++++++++++++++----- editors/vscode/package-lock.json | 110 +++++++++++++++++++++++++++ editors/vscode/package.json | 26 ++++++- editors/vscode/test/test.js | 21 ++++- src/py_doc/_cli.py | 83 ++++++++++++++++++++ src/py_doc/cli.py | 3 +- src/py_doc/convert.py | 18 +++++ src/py_doc/pandoc/resources/html.css | 9 +++ src/py_doc/poster_watch.py | 9 ++- src/py_doc/slides_cli.py | 3 +- tests/test_cli.py | 26 +++++++ tests/test_convert.py | 22 ++++++ tests/test_slides_cli.py | 27 +++++++ 15 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 editors/vscode/package-lock.json diff --git a/.gitignore b/.gitignore index d3b7ab9..98a667d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ build/ .py-doc/ .structurizr/ .venv/ +node_modules/ diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 3ac429e..498b905 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -6,17 +6,36 @@ It formats custom `py-doc` Word (`.docx`) markers, callouts, numbered captions, --- -## Two Preview Modes: Instant vs. Watch Server +## Two Preview Modes: Instant vs. HTML Twin Live Watch -### 1. Instant Editor Preview (`Ctrl+K V` or `Ctrl+Shift+V`) -* **Zero dependencies, instant typing-latency**: Formats text, callouts, numbered captions, slide cards, poster stat strips, card grids, and Mermaid diagrams directly in the editor as you type. -* **No Docker required**: Works offline without compiling files. +This extension provides two complementary ways to preview your documents: -### 2. Live Poster Watch Server (`py-doc poster --watch`) -* **100% Pixel-Perfect Production Render**: Click the **πŸ‘οΈ (Live Poster Watch)** button in the editor navigation bar (or run `py-doc poster --watch --port 8765 --no-open` in the terminal). -* **Side-by-Side in VS Code**: Automatically docks a side-by-side browser tab inside VS Code using `Simple Browser`. -* **Auto-Reloads on Save (`Ctrl+S`)**: Rebuilds the poster in the background and reloads the tab in ~0.7 seconds. -* **Compiles Every Diagram**: Renders PlantUML, Graphviz, Ditaa, Wavedrom, Vega, Pikchr, and Bytefield diagrams into true vector SVGs via `py-doc`'s container. +| Capability | Option 3: Instant In-Editor Preview | Option 1: Live Watch Server (HTML Twin) | +|:---|:---|:---| +| **Trigger** | `Ctrl+K V` or `Ctrl+Shift+V` (or standard Preview icon) | `πŸ‘οΈ` eye icon in editor title bar, or `py-doc ... --watch` | +| **Speed** | Instant (<1ms), updates on every keystroke | ~1 second on save (`Ctrl+S`) | +| **Docker / Tooling** | **Zero dependencies**, 100% offline pure JavaScript | Uses `py-doc`'s tool container via Pandoc | +| **Formats Supported** | Technical Documents, Slide Decks, Posters | Technical Documents (`docx`), Slide Decks (`slides`), Posters (`poster`) | +| **Diagrams** | Live client SVG preview for Mermaid, PlantUML syntax cards | Full 100% compilation of all 12 diagram engines (Mermaid, PlantUML, Graphviz, Ditaa, Wavedrom, Vega, Pikchr, Bytefield, etc.) | +| **Where it Displays** | Native VS Code Markdown preview pane | Side-by-side inside VS Code via `Simple Browser` or external browser | +| **File Lock Tolerance** | Never touches output files | Tolerates open Word/PowerPoint locks; always serves HTML twin reliably | + +--- + +### How Option 1 (Live Watch Server) Works Across All Formats + +When you run `py-doc` with `--watch` (or click `πŸ‘οΈ` in VS Code): +1. **Technical Documents (`py-doc docx file.md --watch`)**: + - Rebuilds `file.docx` on save (gracefully warning if Word has it open). + - Compiles an **HTML twin** (`file.html`) with Pandoc, MathML, admonitions, and all diagrams. + - Serves the twin at `http://127.0.0.1:8765` with auto-reload polling. +2. **Slide Decks (`py-doc slides deck.md --watch`)**: + - Rebuilds `deck.pptx` on save. + - Compiles an HTML presentation twin with styled slides, columns, and formatted speaker notes (`::: notes` / ``). + - Serves the twin with auto-reload polling. +3. **Posters (`py-doc poster poster.md --watch`)**: + - Rebuilds the self-contained poster HTML file on save with true vector graphics and barcodes. + - Serves the poster with auto-reload polling. --- @@ -110,4 +129,4 @@ In projects initialized with `py-doc init`, or at the root of `py-doc`: npm test ``` -Runs all 8 test suites validating documents, slides, posters, image dimensions, and diagram features. +Runs all 9 test suites validating documents, slides, posters, image dimensions, diagram features, and format auto-detection. diff --git a/editors/vscode/extension.js b/editors/vscode/extension.js index 56b77ca..b523fd1 100644 --- a/editors/vscode/extension.js +++ b/editors/vscode/extension.js @@ -742,35 +742,93 @@ try { vscode = require('vscode'); } catch (e) { vscode = null; } let watchTerminal = null; +function detectDocumentFormat(document) { + const text = typeof document.getText === 'function' ? document.getText() : String(document); + const frontMatterMatch = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const frontMatter = frontMatterMatch ? frontMatterMatch[1] : ''; + const parsed = parseSimpleYaml(frontMatter); + const fileName = ((document && document.fileName) || (document && document.name) || '').toLowerCase(); + + // 1. Poster detection + if (parsed.theme || parsed.size || parsed.columns || parsed.posters || fileName.includes('poster')) { + return 'poster'; + } + + // 2. Slides detection + if (parsed.format === 'slides' || parsed.aspect_ratio || parsed.split_level || fileName.includes('slide') || fileName.includes('talk') || fileName.includes('deck')) { + return 'slides'; + } + + // 3. Default to Word document + return 'docx'; +} + +function launchWatchTerminal(subcommand, filePath, port) { + if (!vscode) return; + + if (!watchTerminal || watchTerminal.exitStatus !== undefined) { + watchTerminal = vscode.window.createTerminal({ + name: `py-doc ${subcommand} watch`, + iconPath: new vscode.ThemeIcon('eye') + }); + } + + watchTerminal.show(true); + watchTerminal.sendText(`py-doc ${subcommand} "${filePath}" --watch --port ${port} --no-open`); + + setTimeout(() => { + vscode.commands.executeCommand('simpleBrowser.show', `http://127.0.0.1:${port}`); + }, 1500); +} + function activate(context) { if (!vscode) return; - const disposable = vscode.commands.registerCommand('pydoc.openPosterWatch', async () => { + const getActiveFile = () => { const editor = vscode.window.activeTextEditor; if (!editor) { - vscode.window.showErrorMessage('No active Markdown file to watch.'); - return; + vscode.window.showErrorMessage('No active Markdown file to preview.'); + return null; } + return editor; + }; - const filePath = editor.document.uri.fsPath; - const port = 8765; + const port = 8765; - if (!watchTerminal || watchTerminal.exitStatus !== undefined) { - watchTerminal = vscode.window.createTerminal({ - name: 'py-doc poster watch', - iconPath: new vscode.ThemeIcon('eye') - }); - } + const liveWatchCmd = vscode.commands.registerCommand('pydoc.openLiveWatch', async () => { + const editor = getActiveFile(); + if (!editor) return; + const format = detectDocumentFormat(editor.document); + launchWatchTerminal(format, editor.document.uri.fsPath, port); + }); + + const docxWatchCmd = vscode.commands.registerCommand('pydoc.openDocxWatch', async () => { + const editor = getActiveFile(); + if (!editor) return; + launchWatchTerminal('docx', editor.document.uri.fsPath, port); + }); - watchTerminal.show(true); - watchTerminal.sendText(`py-doc poster "${filePath}" --watch --port ${port} --no-open`); + const slidesWatchCmd = vscode.commands.registerCommand('pydoc.openSlidesWatch', async () => { + const editor = getActiveFile(); + if (!editor) return; + launchWatchTerminal('slides', editor.document.uri.fsPath, port); + }); - setTimeout(() => { - vscode.commands.executeCommand('simpleBrowser.show', `http://127.0.0.1:${port}`); - }, 1500); + const posterWatchCmd = vscode.commands.registerCommand('pydoc.openPosterWatch', async () => { + const editor = getActiveFile(); + if (!editor) return; + launchWatchTerminal('poster', editor.document.uri.fsPath, port); + }); + + const stopWatchCmd = vscode.commands.registerCommand('pydoc.stopLiveWatch', async () => { + if (watchTerminal) { + watchTerminal.dispose(); + watchTerminal = null; + vscode.window.showInformationMessage('py-doc live watch server stopped.'); + } }); - context.subscriptions.push(disposable); + context.subscriptions.push(liveWatchCmd, docxWatchCmd, slidesWatchCmd, posterWatchCmd, stopWatchCmd); } function deactivate() { @@ -788,5 +846,6 @@ module.exports = { deactivate, extendMarkdownIt, pyDocPlugin, - parseSimpleYaml + parseSimpleYaml, + detectDocumentFormat }; diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json new file mode 100644 index 0000000..9dd18d0 --- /dev/null +++ b/editors/vscode/package-lock.json @@ -0,0 +1,110 @@ +{ + "name": "vscode-py-doc-preview", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vscode-py-doc-preview", + "version": "1.0.0", + "devDependencies": { + "markdown-it": "^14.1.0" + }, + "engines": { + "vscode": "^1.75.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 745d607..6b3e2ab 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -28,16 +28,36 @@ "contributes": { "commands": [ { - "command": "pydoc.openPosterWatch", - "title": "py-doc: Open Live Poster Preview (via Watch Server)", + "command": "pydoc.openLiveWatch", + "title": "py-doc: Open Live Preview (HTML Twin Watch Server)", "category": "py-doc", "icon": "$(eye)" + }, + { + "command": "pydoc.openDocxWatch", + "title": "py-doc: Open Live Word Document Preview (docx --watch)", + "category": "py-doc" + }, + { + "command": "pydoc.openSlidesWatch", + "title": "py-doc: Open Live Presentation Preview (slides --watch)", + "category": "py-doc" + }, + { + "command": "pydoc.openPosterWatch", + "title": "py-doc: Open Live Poster Preview (poster --watch)", + "category": "py-doc" + }, + { + "command": "pydoc.stopLiveWatch", + "title": "py-doc: Stop Live Preview Server", + "category": "py-doc" } ], "menus": { "editor/title": [ { - "command": "pydoc.openPosterWatch", + "command": "pydoc.openLiveWatch", "when": "resourceExtname == .md", "group": "navigation" } diff --git a/editors/vscode/test/test.js b/editors/vscode/test/test.js index aa5a0fe..b68cf0b 100644 --- a/editors/vscode/test/test.js +++ b/editors/vscode/test/test.js @@ -210,4 +210,23 @@ digraph G { console.log('βœ“ Image width attributes and diagram fence blocks rendered correctly'); } -console.log('\nAll 8 test suites passed successfully! πŸŽ‰'); +// Test 9: Document format detection +{ + const { detectDocumentFormat } = require('../extension'); + + // Poster detection + assert(detectDocumentFormat('---\ntitle: Poster\ntheme: dark\nsize: a0\n---\n# Poster') === 'poster', 'theme in front-matter means poster'); + assert(detectDocumentFormat({ getText: () => '# Just doc', fileName: 'c:/docs/sn100-poster.md' }) === 'poster', 'poster in filename means poster'); + + // Slides detection + assert(detectDocumentFormat('---\ntitle: Deck\nformat: slides\n---\n# Slide 1') === 'slides', 'format: slides means slides'); + assert(detectDocumentFormat({ getText: () => '# Presentation', fileName: 'c:/decks/talk.md' }) === 'slides', 'talk/deck in filename means slides'); + + // Docx default + assert(detectDocumentFormat('---\ntitle: Tech Spec\nauthor: Jane\n---\n# Chapter 1') === 'docx', 'standard technical doc defaults to docx'); + assert(detectDocumentFormat({ getText: () => '# Guide', fileName: 'c:/docs/guide.md' }) === 'docx', 'guide.md defaults to docx'); + + console.log('βœ“ Document format detection for live watch server (docx, slides, poster) works correctly'); +} + +console.log('\nAll 9 test suites passed successfully! πŸŽ‰'); diff --git a/src/py_doc/_cli.py b/src/py_doc/_cli.py index 15e41c1..7dbcf2f 100644 --- a/src/py_doc/_cli.py +++ b/src/py_doc/_cli.py @@ -50,6 +50,22 @@ def add_common_options(parser: argparse.ArgumentParser, kind: str, suffix: str) action="store_true", help="Use a default blank document with a warning if the template file is not found (default: fail)", ) + parser.add_argument( + "--watch", + action="store_true", + help="Build again on every save, and reload an HTML preview twin in the browser (Ctrl+C stops)", + ) + parser.add_argument( + "--port", + type=int, + default=0, + help="With --watch, the local port to serve the preview on (default: a free one)", + ) + parser.add_argument( + "--no-open", + action="store_true", + help="With --watch, do not open the browser", + ) noise = parser.add_mutually_exclusive_group() noise.add_argument("-q", "--quiet", action="store_true", help="Only show warnings and errors") noise.add_argument("-v", "--verbose", action="store_true", help="Show debug detail") @@ -124,6 +140,71 @@ def convert_all(converter: Converter, args: argparse.Namespace, suffix: str, lab return status +def watch_converter(converter: Converter, args: argparse.Namespace, suffix: str, label: str) -> int: + """Watch source files, rebuild the document and an HTML twin preview on every save, and reload in browser.""" + input_path = Path(args.input).resolve() + try: + converter.validate_template() + if input_path.is_file(): + if not is_markdown(input_path): + logger.warning(f"'{input_path.name}' does not end in .md or .markdown") + pairs = [(input_path, output_for_file(input_path, args.output, suffix).resolve())] + elif input_path.is_dir(): + pairs = plan_directory(input_path, args.output, suffix) + else: + error(f"Path not found: {input_path}") + return 1 + except (FileNotFoundError, ConversionError) as exc: + error(str(exc)) + return exit_status(exc) + + if not pairs: + logger.warning(f"No Markdown files found in '{input_path}'") + return 0 + + def build_all() -> tuple[dict[str, Path], set[Path]]: + pages: dict[str, Path] = {} + sources: set[Path] = {p[0].resolve() for p in pairs} + template_path = getattr(converter, "template_path", None) + if template_path: + sources.add(template_path.resolve()) + + for src, dst in pairs: + # 1. Attempt primary document (.docx or .pptx) + try: + converter.convert_file(src, dst) + print(f"wrote {dst}") + except PermissionError: + logger.warning(f"{dst.name} is open in Word/PowerPoint; skipped writing {dst.suffix}") + except ConversionError as exc: + if "locked" in str(exc).lower(): + logger.warning(f"{dst.name} is open in Word/PowerPoint; skipped writing {dst.suffix}") + else: + raise + + # 2. Render HTML preview twin + twin_path = dst.with_suffix(".html") + if hasattr(converter, "convert_twin"): + converter.convert_twin(src, twin_path) + else: + twin_path = converter.convert_file(src, twin_path) + print(f"wrote {twin_path} (HTML preview twin)") + pages[src.stem] = twin_path.resolve() + + return pages, sources + + from .poster_watch import watch + + errors = (ConversionError, FileNotFoundError, OSError) + return watch( + build_all, + errors, + port=getattr(args, "port", 0), + open_browser=not getattr(args, "no_open", False), + title=f"{label} Preview", + ) + + def deprecation_notice(old: str, new: str) -> str: return ( f"{old} is now part of py-doc. `{new}` does the same, and `py-doc build` builds every document " @@ -143,4 +224,6 @@ def run( configure_logging(quiet=args.quiet, verbose=args.verbose) if notice: logger.info(notice) + if getattr(args, "watch", False): + return watch_converter(make_converter(args), args, suffix, label) return convert_all(make_converter(args), args, suffix, label) diff --git a/src/py_doc/cli.py b/src/py_doc/cli.py index db89b69..6c3a607 100644 --- a/src/py_doc/cli.py +++ b/src/py_doc/cli.py @@ -17,7 +17,8 @@ EXAMPLES = """examples: py-doc docx guide.md guide.docx next to it (or -o FILE) py-doc docx guide.md -t Template.docx with a Word template - py-doc docx docs -o build every Markdown file in docs, into build/""" + py-doc docx docs -o build every Markdown file in docs, into build/ + py-doc docx guide.md --watch live HTML twin preview in the browser on every save""" def build_parser(prog: str = "py-doc docx") -> argparse.ArgumentParser: diff --git a/src/py_doc/convert.py b/src/py_doc/convert.py index bc998fd..43dc47d 100644 --- a/src/py_doc/convert.py +++ b/src/py_doc/convert.py @@ -72,6 +72,24 @@ def convert_file(self, md_path: Path, output_path: Path) -> Path: self.options, self.runtime, keep_going=False, ) # fmt: skip + def convert_twin(self, md_path: Path, output_path: Path) -> Path: + """Render an HTML preview twin of the document or presentation.""" + md_path = Path(md_path).resolve() + if not md_path.is_file(): + raise FileNotFoundError(f"Markdown file not found: {md_path}") + joined = join_sources([Source(md_path)], {}, {}, md_path.name) + if self.fmt == "pptx": + from .pandoc.pptx_finish import notes_to_divs + + joined = replace(joined, parts=[(src, notes_to_divs(md)) for src, md in joined.parts]) + metadata = joined.metadata + if not metadata.title: + metadata = replace(metadata, title=joined.first_heading or md_path.stem) + return render_output( + "html", joined, metadata, Path(output_path), md_path.name, [md_path.parent], None, + self.options, self.runtime, keep_going=False, + ) # fmt: skip + class DocxConverter(_Converter): """Markdown to a Word document. The template, when given, is the reference document.""" diff --git a/src/py_doc/pandoc/resources/html.css b/src/py_doc/pandoc/resources/html.css index 567b4b7..6831a51 100644 --- a/src/py_doc/pandoc/resources/html.css +++ b/src/py_doc/pandoc/resources/html.css @@ -12,4 +12,13 @@ div.tip { background: #e8f5e9; border-color: #2e7d32; } div.important { background: #f1ecf9; border-color: #6a3fb5; } div.warning { background: #fff4e0; border-color: #e08a00; } div.caution { background: #fce8e8; border-color: #c62828; } +figure { margin: 1.5em 0; text-align: center; } +figcaption { font-size: 0.9em; color: #4b5563; margin-top: 0.5em; font-style: italic; } +caption { font-size: 0.9em; font-weight: 600; color: #003c64; padding-bottom: 0.5em; text-align: left; } +div.notes { background: #f1f5f9; border-left: 4px solid #64748b; border-radius: 4px; padding: 0.5em 1em; margin: 1.2em 0; font-size: 0.9em; color: #334155; } +div.notes > p:first-child::before { content: "πŸ“ Speaker Note: "; font-weight: 600; color: #1e293b; } +div.columns { display: flex; gap: 1.5em; margin: 1em 0; } +div.column { flex: 1; min-width: 0; } +hr.pagebreak, hr.pydoc-pagebreak { border: none; border-top: 2px dashed #cbd5e1; margin: 2em 0; position: relative; } +hr.pagebreak::after, hr.pydoc-pagebreak::after { content: "Page Break"; position: absolute; right: 0; top: -0.8em; background: #fff; padding: 0 0.5em; font-size: 0.75em; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; } @media print { h1, h2, h3 { break-after: avoid; } pre, table, img { break-inside: avoid; } } diff --git a/src/py_doc/poster_watch.py b/src/py_doc/poster_watch.py index 2e143a1..7124fea 100644 --- a/src/py_doc/poster_watch.py +++ b/src/py_doc/poster_watch.py @@ -46,11 +46,12 @@ class State: """The pages being served, the build they are from, and the error of the last build, if it failed.""" - def __init__(self) -> None: + def __init__(self, title: str = "Posters") -> None: self.lock = threading.Lock() self.pages: dict[str, Path] = {} self.version = 0 self.error: str | None = None + self.title = title def watched_files(folders: set[Path], files: set[Path], outputs: set[Path]) -> dict[Path, int]: @@ -94,7 +95,7 @@ def do_GET(self): # noqa: N802 (the name http.server calls) return self._send(200, "text/html; charset=utf-8", page.replace("", script + "", 1)) if not name: links = "".join(f'
  • {html.escape(n)}
  • ' for n in pages) - return self._send(200, "text/html; charset=utf-8", f"Posters") + return self._send(200, "text/html; charset=utf-8", f"{state.title}") self._send(404, "text/plain", f"No poster {name}") def _send(self, code: int, kind: str, body: str) -> None: @@ -115,9 +116,9 @@ def log_message(self, *args) -> None: def watch(build: Build, errors: tuple[type[Exception], ...], port: int = 0, open_browser: bool = True, - stop: threading.Event | None = None, interval: float = INTERVAL) -> int: + stop: threading.Event | None = None, interval: float = INTERVAL, title: str = "py-doc preview") -> int: """Build, serve, and build again on every change, until Ctrl+C or ``stop``. Returns the exit status.""" - state, stop = State(), stop or threading.Event() + state, stop = State(title=title), stop or threading.Event() def rebuild() -> set[Path]: started = time.monotonic() diff --git a/src/py_doc/slides_cli.py b/src/py_doc/slides_cli.py index efd9161..112a9b1 100644 --- a/src/py_doc/slides_cli.py +++ b/src/py_doc/slides_cli.py @@ -14,7 +14,8 @@ EXAMPLES = """examples: py-doc slides talk.md talk.pptx next to it (or -o FILE) py-doc slides talk.md -t Template.pptx with a PowerPoint template - py-doc slides talk.md --split-level 1 a new slide only at each # heading""" + py-doc slides talk.md --split-level 1 a new slide only at each # heading + py-doc slides talk.md --watch live HTML twin preview in the browser on every save""" def build_parser(prog: str = "py-doc slides") -> argparse.ArgumentParser: diff --git a/tests/test_cli.py b/tests/test_cli.py index ec8080c..dcf386a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -297,3 +297,29 @@ def test_compat_module_entry_point_runs(self, tmp_path): ) assert result.returncode == 0 assert "py-doc2docx" in result.stdout + + def test_docx_watch_mode(self, tmp_path, monkeypatch): + from py_doc.cli import run_docx + from py_doc import poster_watch + + src = tmp_path / "spec.md" + src.write_text("# Spec\n\nText\n", encoding="utf-8") + + called = [] + + def fake_watch(build, errors, port=0, open_browser=True, stop=None, interval=0.5, title=""): + pages, sources = build() + called.append((pages, sources, port, open_browser, title)) + return 0 + + monkeypatch.setattr(poster_watch, "watch", fake_watch) + code = run_docx([str(src), "--watch", "--port", "9876", "--no-open"]) + assert code == 0 + assert len(called) == 1 + pages, sources, port, open_browser, title = called[0] + assert "spec" in pages + assert pages["spec"].is_file() + assert (tmp_path / "spec.docx").is_file() + assert port == 9876 + assert open_browser is False + assert "Word Preview" in title diff --git a/tests/test_convert.py b/tests/test_convert.py index 91a81b6..d6174bd 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -90,3 +90,25 @@ def test_a_template_without_the_layouts_pandoc_needs_is_explained(tmp_path, pand (tmp_path / "a.md").write_text("# A\n\n## B\n\n- x\n", encoding="utf-8") with pytest.raises(ConversionError, match="no slides"): SlidesConverter(template_path=template).convert_file(tmp_path / "a.md", tmp_path / "a.pptx") + + +def test_docx_convert_twin_produces_html(tmp_path, pandoc_runtime): + md = tmp_path / "spec.md" + md.write_text("# Specification\n\n> [!NOTE]\n> A note box.\n\nParagraph text.\n", encoding="utf-8") + out = DocxConverter().convert_twin(md, tmp_path / "spec.html") + assert out.is_file() + html_text = out.read_text(encoding="utf-8") + assert "Specification" in html_text + assert "admonition note" in html_text or "note" in html_text + assert "A note box." in html_text + + +def test_slides_convert_twin_produces_html_with_notes(tmp_path, pandoc_runtime): + md = tmp_path / "talk.md" + md.write_text("# Title\n\n## First Slide\n\n- point\n\n\n", encoding="utf-8") + out = SlidesConverter().convert_twin(md, tmp_path / "talk.html") + assert out.is_file() + html_text = out.read_text(encoding="utf-8") + assert "First Slide" in html_text + assert "Remember to emphasize this" in html_text + diff --git a/tests/test_slides_cli.py b/tests/test_slides_cli.py index 6bb1626..1a7a79f 100644 --- a/tests/test_slides_cli.py +++ b/tests/test_slides_cli.py @@ -101,3 +101,30 @@ def test_module_entry_point_prints_help(): ) assert result.returncode == 0 assert "PowerPoint" in result.stdout + + +def test_slides_watch_mode(tmp_path, monkeypatch): + from py_doc.slides_cli import run_slides + from py_doc import poster_watch + + src = tmp_path / "deck.md" + src.write_text("# Deck\n\n## S1\n\n- a\n", encoding="utf-8") + + called = [] + + def fake_watch(build, errors, port=0, open_browser=True, stop=None, interval=0.5, title=""): + pages, sources = build() + called.append((pages, sources, port, open_browser, title)) + return 0 + + monkeypatch.setattr(poster_watch, "watch", fake_watch) + code = run_slides([str(src), "--watch", "--port", "9875", "--no-open"]) + assert code == 0 + assert len(called) == 1 + pages, sources, port, open_browser, title = called[0] + assert "deck" in pages + assert pages["deck"].is_file() + assert (tmp_path / "deck.pptx").is_file() + assert port == 9875 + assert open_browser is False + assert "PowerPoint Preview" in title From 7070caf4487d0f6f549ee92d7ec6deb37d6bd3e2 Mon Sep 17 00:00:00 2001 From: Mark Bacon Date: Wed, 21 Oct 2026 15:03:54 +0100 Subject: [PATCH 2/6] Fix live preview review findings, and document it VS Code extension: - One format detection (front matter and deck-only blocks) for the instant preview and the eye button; real posters (paper, byline, orientation) are no longer sent to `py-doc docx`, and file names no longer decide - The front matter header keeps its mode when callouts or captions render nested Markdown - List and mapping values in front matter are shown instead of crashing - Heading accents only on posters, splitting inline tokens so code, bold and links keep their formatting - A new live preview stops the running one first, waits for the server before opening Simple Browser, and the port is a setting - Slide grouping honours split_level; colours and weights are sanitised; command titles no longer repeat "py-doc" docx/slides --watch: - HTML twins go to .py-doc/preview, keyed by relative path, so no user file is overwritten and same-named files stay apart - OutputLocked replaces matching "locked" in the message - convert_file and convert_twin share one render helper - Twin-only preview.css (page breaks, notes, columns); html.css is unchanged for normal HTML builds; page breaks carry a class - --port/--no-open without --watch warn; served paths are URL-decoded Docs: README, guide (single files, writing), architecture (live previews, AD-08), workspace.dsl (VS Code extension, Live preview component and dynamic view), requirements FR-161/162 with traceability. CI runs the extension tests. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 14 +- README.md | 18 +- docs/architecture/00_introduction.md | 2 +- docs/architecture/02_pipeline.md | 9 + docs/architecture/05_decisions.md | 7 + docs/architecture/workspace.dsl | 24 +- docs/guide/02_projects.md | 14 + docs/guide/03_writing.md | 4 +- docs/requirements/01_functional.md | 2 + docs/requirements/04_traceability.md | 2 + editors/vscode/README.md | 29 +- editors/vscode/extension.js | 324 ++++++++++++---------- editors/vscode/package.json | 20 +- editors/vscode/test/test.js | 63 ++++- src/py_doc/_cli.py | 59 ++-- src/py_doc/convert.py | 23 +- src/py_doc/errors.py | 4 + src/py_doc/pandoc/engine.py | 6 +- src/py_doc/pandoc/resources/html.css | 9 - src/py_doc/pandoc/resources/pagebreak.lua | 2 +- src/py_doc/pandoc/resources/preview.css | 12 + src/py_doc/poster_watch.py | 9 +- tests/test_cli.py | 47 +++- tests/test_slides_cli.py | 7 +- 24 files changed, 465 insertions(+), 245 deletions(-) create mode 100644 src/py_doc/pandoc/resources/preview.css diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7d358a..e3511e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,10 +114,22 @@ jobs: - run: python -m build - run: python -m twine check dist/* + vscode: + name: VS Code extension + runs-on: ubuntu-latest + timeout-minutes: 10 + defaults: + run: + working-directory: editors/vscode + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: npm ci --ignore-scripts # the runner's own Node; markdown-it is the only (dev) dependency + - run: npm test + gate: name: CI gate if: always() - needs: [lint, test, build] + needs: [lint, test, build, vscode] runs-on: ubuntu-latest timeout-minutes: 30 steps: diff --git a/README.md b/README.md index 79ba647..73378a6 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Pandoc writes every output, and it runs in a container with every other tool, so | `py-doc init` | Start a project: `py-doc.yml`, a first document, and optionally a C4 model, templates and a site | | `py-doc new` | Start a legacy document, or number an existing Markdown file, from `doc-numbers.yml` at the repository root | | `py-doc build` | Build every document described in `py-doc.yml` (generators, lint, documents, site), or one legacy `createdocs` file with `--legacy` | -| `py-doc docx` | Convert Markdown to Word | -| `py-doc slides` | Convert Markdown to PowerPoint | +| `py-doc docx` | Convert Markdown to Word, or with `--watch` rebuild on every save and serve a live HTML preview | +| `py-doc slides` | Convert Markdown to PowerPoint, with the same `--watch` live preview | | `py-doc lint` | Check writing style, glossary terms, acronyms, structure, links and project conventions; text, CSV, GitHub, JSON or SARIF | | `py-doc check` | Check a project without building it: `py-doc.yml`, sources, templates, the site and lint | | `py-doc generate` | Write the Markdown that `content.generators` makes: glossaries, indexes, traceability, API references | @@ -25,7 +25,7 @@ Pandoc writes every output, and it runs in a container with every other tool, so | `py-doc diff` | Compare two versions of documents section by section, or against a Git revision | | `py-doc c4` | Check, export, draw and edit Structurizr workspaces (`c4 up` opens the live editor) | | `py-doc site` | Make and build a web site of the documents with MkDocs or Astro + Starlight | -| `py-doc poster` | Build self-contained HTML posters from Markdown, and print them to PDF | +| `py-doc poster` | Build self-contained HTML posters from Markdown, and print them to PDF; `--watch` reloads them on every save | | `py-doc import` | Convert a Word document or a PowerPoint deck to Markdown | | `py-doc template` | Check and list the styles of a Word or PowerPoint template | | `py-doc tools`, `doctor`, `setup` | Build and check the tool container, and install Docker Engine | @@ -59,6 +59,8 @@ py-doc c4 up architecture/workspace.dsl # arrange the C4 views in Structuriz py-doc site build # site/build/index.html ``` +While writing, `py-doc docx guide.md --watch` (or `slides`, or `poster`) builds on every save and keeps a live preview open in the browser, with every diagram drawn. The [VS Code extension](editors/vscode/README.md) runs it for the open file and shows the preview beside the editor, and also previews py-doc Markdown instantly as you type. + ## Documentation py-doc documents itself with py-doc. The sources are in [`docs/`](docs), and the [`py-doc.yml`](py-doc.yml) at the root of this repository builds them: `py-doc build` for Word, HTML and PDF, `py-doc site build` for the web site. @@ -79,8 +81,8 @@ src/ β”‚ β”œβ”€β”€ new.py # py-doc new β”‚ β”œβ”€β”€ cli.py # py-doc docx (and the old py-doc2docx) β”‚ β”œβ”€β”€ slides_cli.py # py-doc slides (and the old py-doc2slides) -β”‚ β”œβ”€β”€ _cli.py # options, output paths and batch conversion shared by both -β”‚ β”œβ”€β”€ convert.py # DocxConverter and SlidesConverter, one file through pandoc +β”‚ β”œβ”€β”€ _cli.py # options, output paths, batch conversion and --watch shared by both +β”‚ β”œβ”€β”€ convert.py # DocxConverter and SlidesConverter, one file through pandoc, and its HTML preview twin β”‚ β”œβ”€β”€ config.py # py-doc.yml model and validation β”‚ β”œβ”€β”€ build.py # joins a document's sources and renders it β”‚ β”œβ”€β”€ directives.py # includes, conditions and variables @@ -97,11 +99,12 @@ src/ β”‚ β”œβ”€β”€ analysis/ # py-doc stats, check and diff β”‚ β”œβ”€β”€ migrate.py # createdocs to py-doc.yml β”‚ β”œβ”€β”€ report.py # the JSON build report -β”‚ └── poster*.py # py-doc poster: Markdown to an HTML poster, its blocks, settings, themes, fonts and --watch +β”‚ β”œβ”€β”€ poster*.py # py-doc poster: Markdown to an HTML poster, its blocks, settings, themes and fonts +β”‚ └── poster_watch.py # the --watch loop and preview server of poster, docx and slides β”œβ”€β”€ pydoc2docx/ # old import names, kept as aliases β”œβ”€β”€ pydoc2slides/ └── pydoclint/ -editors/ # editor integrations: VS Code preview extension for documents, slides and posters +editors/vscode/ # VS Code extension: instant in-editor preview, and the --watch live preview beside the editor docs/ # py-doc's own user guide, requirements and architecture examples/ # a project that uses every feature tests/ @@ -114,6 +117,7 @@ pip install -e ".[dev]" python -m pytest ruff check . py-doc lint docs --strict +cd editors/vscode && npm ci && npm test # the VS Code extension ``` CI runs on Linux and Windows with Python 3.14, the newest supported. Python 3.10, the oldest supported, is not tested in CI. macOS is not supported. diff --git a/docs/architecture/00_introduction.md b/docs/architecture/00_introduction.md index 1005036..5581240 100644 --- a/docs/architecture/00_introduction.md +++ b/docs/architecture/00_introduction.md @@ -13,7 +13,7 @@ keywords: [py-doc, architecture, C4] This document describes how py-doc meets its [requirements](../requirements/00_introduction.md). It covers what py-doc is made of, how a document travels from Markdown to each output, where the tools run, and why it is built this way. It is for maintainers and contributors. -py-doc writes the C4 chapters from the Structurizr model in `docs/architecture/workspace.dsl`, the way any py-doc project documents its architecture. They cover the context, containers, components, relationships, deployment, the dynamic view of a build, and the element catalogue. Change the model, not those chapters. `py-doc c4 up docs/architecture/workspace.dsl` opens it in Structurizr to arrange its views. +py-doc writes the C4 chapters from the Structurizr model in `docs/architecture/workspace.dsl`, the way any py-doc project documents its architecture. They cover the context, containers, components, relationships, deployment, the dynamic views of a build and of a live preview, and the element catalogue. Change the model, not those chapters. `py-doc c4 up docs/architecture/workspace.dsl` opens it in Structurizr to arrange its views. ## Goals diff --git a/docs/architecture/02_pipeline.md b/docs/architecture/02_pipeline.md index e2d8ac9..d3044e2 100644 --- a/docs/architecture/02_pipeline.md +++ b/docs/architecture/02_pipeline.md @@ -68,3 +68,12 @@ flowchart LR - **Check outputs**: every file the build wrote is there and not empty. `py-doc check`, `py-doc stats` and `py-doc diff` (`analysis/`) read the same configuration and the same document model, and build nothing. The diff splits each version into sections by its top-level headings. It matches them by heading path, then by text, so a renamed or moved section is not mistaken for one removed and another added. + +## Live previews + +`--watch` on `py-doc poster`, `py-doc docx` and `py-doc slides` runs one loop (`poster_watch.py`). It builds, then serves the pages on 127.0.0.1 from a thread of its own. Every half second it looks at the modification times of the source files: the Markdown, includes, pictures, diagram sources and templates below the folders of the sources. The pages it wrote and hidden folders are not watched. A change builds again. Each page is served with a small script that asks `/__status` whether a newer build is there and reloads when it is, so the files written are the same as without `--watch`. A build that fails keeps the last good page and shows its error over it. + +A poster is already a web page. A Word document or deck is not, so `docx` and `slides` (`_cli.py`) also write an *HTML twin* of each file (`convert.py`, `convert_twin`). The twin is the same Markdown through the same pipeline to pandoc's HTML. It adds `preview.css` after `html.css` to show page breaks, speaker notes and columns. The twins go to `.py-doc/preview/` below the input's folder and are served by their path. No file of the user's is overwritten, and two `readme.md` in different folders stay apart. When Word or PowerPoint has the output open, the engine raises `OutputLocked`; the watch loop warns and still writes the twin. + +The VS Code extension (`editors/vscode`) is outside the Python package. Its instant preview is a markdown-it plugin in the editor and runs no tool. Its live preview starts `py-doc --watch` in a terminal, waits until `/__status` answers, and opens the served page in VS Code's Simple Browser. The extension and the instant preview choose the command with one function, from the front matter and the blocks only a deck has. + diff --git a/docs/architecture/05_decisions.md b/docs/architecture/05_decisions.md index 77f2b8e..c17a45f 100644 --- a/docs/architecture/05_decisions.md +++ b/docs/architecture/05_decisions.md @@ -47,3 +47,10 @@ Each decision records the context, the choice and its consequences. - **Context**: lint findings need exact lines and source text, and must skip code, links and front matter reliably. - **Decision**: the linter parses with markdown-it-py into py-doc's own document model. - **Consequences**: precise, fast checks without the container. Two parsers can disagree on edge cases, so the anchor rules are tested against pandoc's own identifiers. + +## AD-08 Previews are served, not written beside the outputs + +- **Context**: authors want to see a document while they write it, but Word and PowerPoint have no live view, a document open in Word cannot be written, and the in-editor Markdown preview cannot draw most diagrams. +- **Decision**: `--watch` builds an HTML twin of each document with the same pipeline, keeps it in `.py-doc/preview`, and serves it with a reload script added as it is served. The VS Code extension runs that command; its own instant preview is an approximation for typing. +- **Consequences**: the preview shows every diagram exactly as the build draws it, and a document open in Word does not stop it. It is one HTML page, so slide boundaries and Word page layout are not shown, and a first build waits for the tool container. + diff --git a/docs/architecture/workspace.dsl b/docs/architecture/workspace.dsl index 14ccd3a..0cf3517 100644 --- a/docs/architecture/workspace.dsl +++ b/docs/architecture/workspace.dsl @@ -12,7 +12,7 @@ workspace "py-doc" "The C4 model of py-doc, the Markdown technical documentation pydoc = softwareSystem "py-doc" "Turns Markdown into Word, PowerPoint, PDF, HTML and web sites, checks the writing, and documents C4 models." { cli = container "py-doc command" "The py-doc command and Python package. Reads py-doc.yml, prepares the Markdown and starts every tool in the tool image." "Python 3.10+" { - commands = component "Commands" "Parses the command line and hands each command to its module: build, docx, slides, lint, check, generate, stats, diff, c4, site, import, init, new, template, tools, doctor, setup, migrate. Runs the build pipeline around the documents." "argparse" + commands = component "Commands" "Parses the command line and hands each command to its module: build, docx, slides, poster, lint, check, generate, stats, diff, c4, site, import, init, new, template, tools, doctor, setup, migrate. Runs the build pipeline around the documents." "argparse" configuration = component "Configuration" "Loads py-doc.yml and checks every key, so a mistake is an error that names its place." "PyYAML" pipeline = component "Build pipeline" "Joins a document's sources, expands includes, conditions and variables, and picks the template and the output name." "Python" diagrams = component "Diagram renderer" "Turns diagram fences into pictures with the right tool, and keeps them in a cache." "Python" @@ -26,15 +26,23 @@ workspace "py-doc" "The C4 model of py-doc, the Markdown technical documentation generators = component "Content generators" "Write Markdown from other sources before the build: glossaries, indexes, traceability, API references, diagram files, commands." "Python, entry points" analysis = component "Analysis" "Counts what documents hold, checks a project without building it, and compares versions by section." "Python, Git" report = component "Build report" "Records the tools, sources, outputs, diagrams and warnings of a build as JSON." "JSON schema" + watch = component "Live preview" "With --watch, builds documents, decks and posters again on every save, writes an HTML twin of each document and deck, and serves the pages on 127.0.0.1 with a script that reloads them." "Python, http.server" } toolImage = container "Tool image" "Every program that is not Python: pandoc, XeLaTeX, PlantUML, Mermaid, Graphviz, the diagram tools, Chromium, structurizr-cli, Vale, markdownlint, MkDocs and Node." "Docker image on Debian 12" + vscode = container "VS Code extension" "Previews py-doc Markdown in the editor as it is typed, and starts the live preview of the open file and shows it beside the editor." "JavaScript, markdown-it" local = container "Structurizr Local" "Shows a workspace in a browser to view it and arrange its views, and draws the pictures for documents." "Docker image structurizr/structurizr" - cache = container "Caches" "Diagram pictures (user cache folder), Structurizr exports and generated chapters (.py-doc in the project)." "Files" "Store" + cache = container "Caches" "Diagram pictures (user cache folder), Structurizr exports, generated chapters and live preview pages (.py-doc in the project)." "Files" "Store" } author -> pydoc.cli.commands "Runs py-doc commands" "Terminal" author -> pydoc.local "Views the model and arranges its views" "Browser, HTTP" reader -> office "Opens the documents in" + author -> pydoc.vscode "Writes and previews documents in" "VS Code" + pydoc.vscode -> pydoc.cli.commands "Starts py-doc docx, slides or poster --watch for the open file" "Terminal" + pydoc.vscode -> pydoc.cli.watch "Shows the served preview from" "HTTP on 127.0.0.1" + pydoc.cli.commands -> pydoc.cli.watch "Runs --watch with" + pydoc.cli.watch -> pydoc.cli.pipeline "Builds each document and its HTML twin with" + pydoc.cli.watch -> pydoc.cache "Keeps the HTML twins in" "Files" ci -> pydoc.cli.commands "Runs builds and lint checks" "Shell" pydoc.cli.commands -> pydoc.cli.configuration "Loads the project from" pydoc.cli.commands -> pydoc.cli.pipeline "Builds documents with" @@ -73,6 +81,9 @@ workspace "py-doc" "The C4 model of py-doc, the Markdown technical documentation deploymentNode "Python" "The py-doc package, installed with pip." "Python 3.10+" { containerInstance pydoc.cli } + deploymentNode "Visual Studio Code" "The author's editor, with the py-doc extension installed from editors/vscode." "VS Code 1.75+" { + containerInstance pydoc.vscode + } deploymentNode "Cache folders" "The user cache folder and the project's .py-doc folder." "File system" { containerInstance pydoc.cache } @@ -110,6 +121,15 @@ workspace "py-doc" "The C4 model of py-doc, the Markdown technical documentation pydoc.cli.pipeline -> pydoc.cli.report "Records the outputs" autoLayout lr } + dynamic pydoc.cli "LivePreview" "How the VS Code extension shows a live preview of a Word document." { + author -> pydoc.vscode "Clicks the eye button" + pydoc.vscode -> pydoc.cli.commands "py-doc docx guide.md --watch" + pydoc.cli.commands -> pydoc.cli.watch "Watches guide.md" + pydoc.cli.watch -> pydoc.cli.pipeline "Builds guide.docx and its HTML twin" + pydoc.cli.watch -> pydoc.cache "Writes the twin to .py-doc/preview" + pydoc.vscode -> pydoc.cli.watch "Shows the twin, which reloads on every save" + autoLayout lr + } deployment pydoc live "Deployment" "Where py-doc runs on an author's computer." { include * autoLayout lr diff --git a/docs/guide/02_projects.md b/docs/guide/02_projects.md index c23bbac..c1bf828 100644 --- a/docs/guide/02_projects.md +++ b/docs/guide/02_projects.md @@ -146,6 +146,7 @@ py-doc docx Technical_Specification.md -t Template.docx py-doc docx docs/ -o dist/docx/ # a whole folder, structure kept py-doc slides talk.md -o talk.pptx py-doc slides talk.md -t Corporate.pptx --split-level 1 +py-doc docx Technical_Specification.md --watch # build on every save, with a live HTML preview ``` | Option | Commands | Description | @@ -155,6 +156,9 @@ py-doc slides talk.md -t Corporate.pptx --split-level 1 | `-t`, `--template` | both | A reference `.docx` or `.dotx` (Word), or `.pptx` or `.potx` (PowerPoint). `-r`, `--reference` is the old slides name | | `--allow-missing-template` | both | Warn and use a blank document if the template file is missing (default: fail) | | `--split-level N` | slides | Start a new slide at each heading up to level N (default 2) | +| `--watch` | both | Build again on every save, and serve an HTML preview of each file that reloads itself (Ctrl+C stops) | +| `--port N` | both | With `--watch`, the local port to serve the preview on (default: a free one) | +| `--no-open` | both | With `--watch`, do not open the browser | | `-q`, `-v` | both | Only warnings and errors, or debug detail | | `--version` | both | Print the version | @@ -165,6 +169,16 @@ A file that uses `[[IF:]]` needs its conditions defined, and only `py-doc build` - Two sources that would write the same output (`a.md` and `a.markdown`) are refused before anything is converted. - The input is checked before any folder is created, so a typo leaves nothing behind. +### Live preview + +`--watch` builds the files, then builds them again whenever a source below their folder changes: the Markdown, an included file, a picture, a diagram source or the template. Next to each `.docx` or `.pptx` it writes an HTML twin of the same Markdown, to `.py-doc/preview/` below the input's folder, and serves the twins on this computer only. The page in the browser reloads after each build, and a build that fails shows its error over the last good page. + +- A document open in Word or PowerPoint cannot be written, so it is left as it is, with a warning, and its preview is still updated. +- The twin shows page breaks as dashed lines, speaker notes in boxes and columns side by side. It is one page: a deck is not split into slides. +- The twin is a preview, not an output. Build HTML with `py-doc build` and `outputs: html`. + +The [VS Code extension](03_writing.md#vs-code-preview) starts `--watch` for the open file and shows the preview beside it. + The old commands `py-doc2docx`, `py-doc2slides` and `py-doclint` still work and print a one-line note naming their replacement. `-q` hides the note. ## File names and versions diff --git a/docs/guide/03_writing.md b/docs/guide/03_writing.md index f020a29..2c2fe5a 100644 --- a/docs/guide/03_writing.md +++ b/docs/guide/03_writing.md @@ -25,7 +25,9 @@ py-doc itself never drops a word or a sentence. Anything pandoc cannot represent ## VS Code preview -A companion VS Code extension in [`editors/vscode/`](../../editors/vscode) provides live preview for py-doc Markdown as you write. It formats numbered captions, Word callout boxes, presentation slide cards, speaker notes, and poster structures (stat strips, card grids, flow sequences) directly in VS Code's built-in Markdown preview (`Ctrl+Shift+V` or `Ctrl+K V`). +A companion VS Code extension in [`editors/vscode/`](../../editors/vscode) provides live preview for py-doc Markdown as you write. It works in VS Code's built-in Markdown preview (`Ctrl+Shift+V` or `Ctrl+K V`). There it formats numbered captions, Word callout boxes, slide cards, speaker notes, and poster stat strips, card grids and flows. This preview is instant and needs no tools, but it only approximates the output: diagrams other than Mermaid show as their source. + +For the real output, use the eye button in the editor title bar. It runs `py-doc docx`, `py-doc slides` or `py-doc poster` with `--watch` on the open file, and shows the page it serves beside the editor. It rebuilds on every save, with every diagram drawn. The extension chooses the command from the file. A poster setting such as `paper` or `byline` in the front matter means a poster. `split_level` or speaker notes mean a deck, and anything else is a Word document. See [live preview](02_projects.md#live-preview) and the extension's [README](../../editors/vscode/README.md). ## Front matter diff --git a/docs/requirements/01_functional.md b/docs/requirements/01_functional.md index 8ad9ff1..acac62f 100644 --- a/docs/requirements/01_functional.md +++ b/docs/requirements/01_functional.md @@ -125,6 +125,8 @@ title: Functional requirements | FR-158 | `py-doc lint` and `py-doc build --lint` shall lint the posters of `py-doc.yml` with every rule but the document structure, acronym and passive voice rules. | Should | Met | | FR-159 | `py-doc poster --watch` shall build the posters again on every save of their sources and reload them in the browser, and show a failed build's error over the last good page. | Could | Met | | FR-160 | A poster's sections shall say in braces what they are and how they are laid out: `{.stats}`, `{.abstract}`, `{.summary}`, `{.references}`, `{.wide}` and `{cols=}`. No heading name shall have a meaning of its own, and every part shall be placed in the order of the file. Each kind shall have a choice of looks (`style=`), and `colour=` shall colour every one. | Should | Met | +| FR-161 | `py-doc docx --watch` and `py-doc slides --watch` shall build again on every save, serve an HTML preview of each file that reloads itself, and keep updating the preview while the document is open in Word or PowerPoint. The preview shall never overwrite a file of the user's. | Could | Met | +| FR-162 | The VS Code extension in `editors/vscode` shall preview py-doc Markdown in the editor without any tools, and start the live preview of the open file with the command that suits it. | Could | Met | ## Tool container diff --git a/docs/requirements/04_traceability.md b/docs/requirements/04_traceability.md index 0fb9945..f4f2930 100644 --- a/docs/requirements/04_traceability.md +++ b/docs/requirements/04_traceability.md @@ -20,6 +20,8 @@ title: Traceability | FR-100 to FR-106 | `lint/`, `markdown/` | `test_lint.py`, `test_lint_cli.py`, `test_structure.py`, `test_vale.py`, `test_markdownlint.py`, `test_markdown_ast.py` | | FR-120 to FR-127, QR-082 | `site/`, `pandoc/resources/site.lua` | `test_site.py` | | FR-130 to FR-139, FR-150 to FR-160 | `poster.py`, `poster_blocks.py`, `poster_fonts.py`, `poster_settings.py`, `poster_themes.py`, `poster_cli.py`, `poster_watch.py`, `config.py`, `app.py`, `lint/linter.py`, `diagrams/tools/poster_pdf.js` | `test_poster.py`, `test_poster_browser.py` | +| FR-161 | `_cli.py`, `convert.py`, `poster_watch.py`, `pandoc/engine.py`, `pandoc/resources/preview.css` | `test_cli.py`, `test_slides_cli.py`, `test_convert.py` | +| FR-162 | `editors/vscode/extension.js` | `editors/vscode/test/test.js` | | FR-028 | `diagrams/render.py`, `config.py` | `test_diagrams.py` | | FR-128 | `site/pages.py`, `site/engines.py` | `test_site.py` | | FR-140 to FR-143, QR-040 to QR-043 | `runtime/`, `tools_cli.py` | `test_runtime.py`, `test_image.py`, `test_setup.py`, `test_doctor.py`, `test_platform.py` | diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 498b905..7e240b7 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -18,7 +18,7 @@ This extension provides two complementary ways to preview your documents: | **Formats Supported** | Technical Documents, Slide Decks, Posters | Technical Documents (`docx`), Slide Decks (`slides`), Posters (`poster`) | | **Diagrams** | Live client SVG preview for Mermaid, PlantUML syntax cards | Full 100% compilation of all 12 diagram engines (Mermaid, PlantUML, Graphviz, Ditaa, Wavedrom, Vega, Pikchr, Bytefield, etc.) | | **Where it Displays** | Native VS Code Markdown preview pane | Side-by-side inside VS Code via `Simple Browser` or external browser | -| **File Lock Tolerance** | Never touches output files | Tolerates open Word/PowerPoint locks; always serves HTML twin reliably | +| **File Lock Tolerance** | Never touches output files | Skips a `.docx`/`.pptx` open in Word or PowerPoint, with a warning, and still updates the HTML twin | --- @@ -26,16 +26,23 @@ This extension provides two complementary ways to preview your documents: When you run `py-doc` with `--watch` (or click `πŸ‘οΈ` in VS Code): 1. **Technical Documents (`py-doc docx file.md --watch`)**: - - Rebuilds `file.docx` on save (gracefully warning if Word has it open). - - Compiles an **HTML twin** (`file.html`) with Pandoc, MathML, admonitions, and all diagrams. - - Serves the twin at `http://127.0.0.1:8765` with auto-reload polling. + - Rebuilds `file.docx` on save. If Word has it open, it is left as it is, with a warning, and the preview still updates. + - Builds an **HTML twin** with Pandoc, MathML, admonitions, all diagrams, and page breaks shown as dashed lines. + - The twin is written to `.py-doc/preview/` below the document's folder, so no file of yours is overwritten. 2. **Slide Decks (`py-doc slides deck.md --watch`)**: - - Rebuilds `deck.pptx` on save. - - Compiles an HTML presentation twin with styled slides, columns, and formatted speaker notes (`::: notes` / ``). - - Serves the twin with auto-reload polling. + - Rebuilds `deck.pptx` on save (PowerPoint locks are handled the same way). + - Builds an HTML twin of the deck's content as one continuous page: columns side by side, and speaker notes (`::: notes` / ``) in labelled boxes. It does not split the content into slides; use the instant preview for slide layout. 3. **Posters (`py-doc poster poster.md --watch`)**: - Rebuilds the self-contained poster HTML file on save with true vector graphics and barcodes. - - Serves the poster with auto-reload polling. + +Every format is served at `http://127.0.0.1:8765` (the `pydoc.watch.port` setting) and reloads on each save. + +The `πŸ‘οΈ` button chooses the command from the file itself, the same way the instant preview chooses its mode: +- **Poster**: the front matter has a poster setting (`paper`, `byline`, `orientation`, `theme`, `columns`, `width`, `colours`, `palette`, `logo`, `embed_fonts`). +- **Slides**: the front matter has `split_level` or a `format` of `slides`/`pptx`, or the body has `:::: columns`, `::: notes`, `::: incremental` or `` outside code blocks. +- **Word document**: anything else. + +Use **py-doc: Open Live Word Document / Presentation / Poster Preview** from the Command Palette to choose the format yourself. Opening a new preview stops the one already running, and **py-doc: Stop Live Preview Server** stops it. --- @@ -58,7 +65,7 @@ When you run `py-doc` with `--watch` (or click `πŸ‘οΈ` in VS Code): ### 2. Slide Decks (PowerPoint `.pptx`) -When authoring a slide deck (e.g. `slides.md`, `format: pptx`, or using slide directives), the preview switches to **Slide Deck Mode**: +When authoring a slide deck (`format: pptx`, `split_level`, or columns, speaker notes and incremental lists in the body), the preview switches to **Slide Deck Mode**. With `split_level: 1`, each `#` heading starts a content slide, as it does in PowerPoint: | Slide Feature | `py-doc` Syntax | Preview Rendering | |:---|:---|:---| @@ -73,7 +80,7 @@ When authoring a slide deck (e.g. `slides.md`, `format: pptx`, or using slide di ### 3. Posters (HTML & PDF Posters) -When authoring a poster (e.g. `paper: a1`, `byline: ...`, or headings with `{.stats}`), the preview switches to **Poster Mode**: +When authoring a poster (a poster setting such as `paper: a1` or `byline: ...` in the front matter), the preview switches to **Poster Mode**: | Poster Feature | `py-doc` Syntax | Preview Rendering | |:---|:---|:---| @@ -129,4 +136,4 @@ In projects initialized with `py-doc init`, or at the root of `py-doc`: npm test ``` -Runs all 9 test suites validating documents, slides, posters, image dimensions, diagram features, and format auto-detection. +Runs all 10 test suites validating documents, slides, posters, image dimensions, diagram features, format auto-detection, and regressions. diff --git a/editors/vscode/extension.js b/editors/vscode/extension.js index b523fd1..dae3179 100644 --- a/editors/vscode/extension.js +++ b/editors/vscode/extension.js @@ -54,14 +54,18 @@ function parseSimpleYaml(yamlStr) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; + const item = trimmed.match(/^-\s+(.*)$/); + if (item && currentKey) { + if (!Array.isArray(result[currentKey])) result[currentKey] = []; + result[currentKey].push(unquote(item[1].trim())); + continue; + } + if (line.startsWith(' ') && currentKey) { const subColon = trimmed.indexOf(':'); if (subColon > 0) { const subKey = trimmed.slice(0, subColon).trim(); - let subVal = trimmed.slice(subColon + 1).trim(); - if ((subVal.startsWith('"') && subVal.endsWith('"')) || (subVal.startsWith("'") && subVal.endsWith("'"))) { - subVal = subVal.slice(1, -1); - } + const subVal = unquote(trimmed.slice(subColon + 1).trim()); if (typeof result[currentKey] !== 'object' || result[currentKey] === null) { result[currentKey] = {}; } @@ -73,23 +77,36 @@ function parseSimpleYaml(yamlStr) { const colonIdx = trimmed.indexOf(':'); if (colonIdx > 0) { const key = trimmed.slice(0, colonIdx).trim(); - let val = trimmed.slice(colonIdx + 1).trim(); - if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { - val = val.slice(1, -1); - } - if (val === '') { - currentKey = key; - result[key] = {}; - } else { - currentKey = key; - result[key] = val; - } + const val = unquote(trimmed.slice(colonIdx + 1).trim()); + currentKey = key; + result[key] = val === '' ? {} : val; } } return result; } -function escapeHtml(str) { +function unquote(val) { + if (val.length >= 2 && ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))) { + return val.slice(1, -1); + } + return val; +} + +// Front matter values may be lists (author: [- Jane, - Joe]) or mappings, so anything is turned into text first +function asText(val) { + if (val === null || val === undefined) return ''; + if (Array.isArray(val)) return val.map(asText).join(', '); + if (typeof val === 'object') return Object.values(val).map(asText).join(', '); + return String(val); +} + +// A colour name written in the Markdown, made safe to use as part of a class name +function cssName(val) { + return asText(val).toLowerCase().replace(/[^a-z0-9_-]/g, ''); +} + +function escapeHtml(val) { + const str = asText(val); if (!str) return ''; return str.replace(/&/g, '&') .replace(/Paper: ${escapeHtml(meta.paper.toUpperCase())}\n`; + html += ` Paper: ${escapeHtml(asText(meta.paper).toUpperCase())}\n`; } if (meta.document_number) { html += ` Doc #: ${escapeHtml(meta.document_number)}\n`; @@ -142,7 +159,7 @@ function renderFrontMatterBlock(meta, docMode) { html += ` Version: ${escapeHtml(meta.version)}\n`; } if (meta.status) { - const statusClass = meta.status.toLowerCase() === 'draft' ? 'draft' : 'released'; + const statusClass = asText(meta.status).toLowerCase() === 'draft' ? 'draft' : 'released'; html += ` Status: ${escapeHtml(meta.status)}\n`; } if (meta.author) { @@ -156,7 +173,54 @@ function renderFrontMatterBlock(meta, docMode) { return html; } +// Front matter keys only a poster has (see py_doc/poster_settings.py) +const POSTER_KEYS = ['paper', 'byline', 'orientation', 'theme', 'columns', 'width', 'colours', 'palette', 'embed_fonts', 'logo']; +const SLIDE_DIVS = /^:{3,4}\s*\{?\.?(columns|column|notes|incremental)\}?\s*$/; + +/** + * Which py-doc command a Markdown file is for: 'poster', 'slides' or 'docx'. + * Decided from the front matter, then from blocks only a deck has (columns, speaker notes, incremental lists). + * The instant preview and the live watch button both use this, so they always agree. + */ +function detectFormat(meta, body) { + meta = meta || {}; + const fmt = asText(meta.format || meta.output || meta.type || meta.layout).toLowerCase(); + if (fmt.includes('pptx') || fmt.includes('slide') || meta.split_level) return 'slides'; + if (fmt.includes('poster') || POSTER_KEYS.some((k) => meta[k] !== undefined && meta[k] !== '')) return 'poster'; + + let fence = null; + for (const line of (body || '').split(/\r?\n/)) { + const trimmed = line.trim(); + const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/); + if (fenceMatch) { + if (!fence) fence = fenceMatch[1][0]; + else if (trimmed[0] === fence) fence = null; + continue; + } + if (fence) continue; + if (SLIDE_DIVS.test(trimmed) || /^\n", encoding="utf-8") called = [] @@ -122,9 +122,10 @@ def fake_watch(build, errors, port=0, open_browser=True, stop=None, interval=0.5 assert code == 0 assert len(called) == 1 pages, sources, port, open_browser, title = called[0] - assert "deck" in pages - assert pages["deck"].is_file() + assert pages == {"deck": (tmp_path / ".py-doc" / "preview" / "deck.html").resolve()} assert (tmp_path / "deck.pptx").is_file() assert port == 9875 assert open_browser is False assert "PowerPoint Preview" in title + twin = pages["deck"].read_text(encoding="utf-8") + assert 'class="notes"' in twin and "Say this" in twin From 9dd85177309d261a12d8f9a51931568fced628e8 Mon Sep 17 00:00:00 2001 From: Mark Bacon Date: Wed, 21 Oct 2026 15:18:55 +0100 Subject: [PATCH 3/6] Live preview: child process, slide boxes, shared cases, js-yaml, module split - The VS Code extension runs py-doc --watch as a child process instead of in a terminal. py-doc picks a free port and the extension reads the address from its "serving" line, so windows never fight over a port. Output goes to an output channel, failures are reported, and stopping ends the whole process tree. New pydoc.executable setting; pydoc.watch.port defaults to 0. - The HTML twin of a deck shows each slide as a numbered 16:9 box, split where PowerPoint splits it (slides-preview.lua, split level and breaks). - Shared preview cases (tests/fixtures/preview/cases.json) run through both the instant preview and the HTML twin, so the two cannot drift apart. - Front matter is read with js-yaml (core schema) instead of a hand parser. - extension.js is split into src/ modules (plugin, detect, frontmatter, text, watch), with ESLint, node --test and tests for the live preview. - Ready to package: license, icon, repository, bugs and homepage fields; the .vsix keeps js-yaml and leaves out dev dependencies. Install docs added. - poster_watch.py is now watch.py, as it serves docx and slides too. - CI lints the extension; docs, model and traceability updated. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 +- README.md | 4 +- docs/architecture/02_pipeline.md | 8 +- docs/architecture/05_decisions.md | 2 +- docs/architecture/workspace.dsl | 4 +- docs/guide/02_projects.md | 2 +- docs/requirements/04_traceability.md | 6 +- editors/vscode/.vscodeignore | 2 +- editors/vscode/LICENSE | 21 + editors/vscode/README.md | 56 +- editors/vscode/eslint.config.js | 23 + editors/vscode/extension.js | 883 +------------ editors/vscode/images/icon.png | Bin 0 -> 6651 bytes editors/vscode/package-lock.json | 1120 ++++++++++++++++- editors/vscode/package.json | 34 +- editors/vscode/src/detect.js | 43 + editors/vscode/src/frontmatter.js | 21 + editors/vscode/src/plugin.js | 679 ++++++++++ editors/vscode/src/text.js | 25 + editors/vscode/src/watch.js | 121 ++ editors/vscode/test/cases.test.js | 39 + editors/vscode/test/watch.test.js | 98 ++ src/py_doc/_cli.py | 2 +- src/py_doc/convert.py | 3 +- src/py_doc/pandoc/engine.py | 5 +- src/py_doc/pandoc/resources/preview.css | 10 +- .../pandoc/resources/slides-preview.lua | 39 + src/py_doc/poster_cli.py | 2 +- src/py_doc/{poster_watch.py => watch.py} | 6 +- tests/fixtures/preview/cases.json | 30 + tests/test_cli.py | 13 +- tests/test_logging_policy.py | 2 +- tests/test_poster.py | 12 +- tests/test_preview_cases.py | 41 + tests/test_slides_cli.py | 20 +- 35 files changed, 2458 insertions(+), 921 deletions(-) create mode 100644 editors/vscode/LICENSE create mode 100644 editors/vscode/eslint.config.js create mode 100644 editors/vscode/images/icon.png create mode 100644 editors/vscode/src/detect.js create mode 100644 editors/vscode/src/frontmatter.js create mode 100644 editors/vscode/src/plugin.js create mode 100644 editors/vscode/src/text.js create mode 100644 editors/vscode/src/watch.js create mode 100644 editors/vscode/test/cases.test.js create mode 100644 editors/vscode/test/watch.test.js create mode 100644 src/py_doc/pandoc/resources/slides-preview.lua rename src/py_doc/{poster_watch.py => watch.py} (95%) create mode 100644 tests/fixtures/preview/cases.json create mode 100644 tests/test_preview_cases.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3511e4..b9efb7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,7 +123,8 @@ jobs: working-directory: editors/vscode steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - run: npm ci --ignore-scripts # the runner's own Node; markdown-it is the only (dev) dependency + - run: npm ci --ignore-scripts # the runner's own Node + - run: npm run lint - run: npm test gate: diff --git a/README.md b/README.md index 73378a6..c9e863f 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ src/ β”‚ β”œβ”€β”€ migrate.py # createdocs to py-doc.yml β”‚ β”œβ”€β”€ report.py # the JSON build report β”‚ β”œβ”€β”€ poster*.py # py-doc poster: Markdown to an HTML poster, its blocks, settings, themes and fonts -β”‚ └── poster_watch.py # the --watch loop and preview server of poster, docx and slides +β”‚ └── watch.py # the --watch loop and preview server of poster, docx and slides β”œβ”€β”€ pydoc2docx/ # old import names, kept as aliases β”œβ”€β”€ pydoc2slides/ └── pydoclint/ @@ -117,7 +117,7 @@ pip install -e ".[dev]" python -m pytest ruff check . py-doc lint docs --strict -cd editors/vscode && npm ci && npm test # the VS Code extension +cd editors/vscode && npm ci && npm run lint && npm test # the VS Code extension ``` CI runs on Linux and Windows with Python 3.14, the newest supported. Python 3.10, the oldest supported, is not tested in CI. macOS is not supported. diff --git a/docs/architecture/02_pipeline.md b/docs/architecture/02_pipeline.md index d3044e2..d99d6b2 100644 --- a/docs/architecture/02_pipeline.md +++ b/docs/architecture/02_pipeline.md @@ -63,7 +63,7 @@ flowchart LR - **Generators** (`generators/`): each entry of `content.generators` returns Markdown, written only when it changes. The built-in types are in `generators/builtin.py`, and others are found through the `py_doc.generators` entry point group, so the core depends on none of their tools. They run before anything reads the sources, because the files they write are sources. - **Lint** (`lint/`): with `build.lint` or `--lint`, the sources of the documents being built are linted before anything is written, and the issues can go to a SARIF, JSON or CSV file for CI (`lint/formats.py`). - **Documents and bundles**: a bundle is a document whose sources are those of its documents, so it takes the same path. -- **Posters** (`poster*.py`): every poster under `posters:`, one self-contained HTML page each, with its PDF when it asks for one. The settings come in layers (`poster_settings.py`): `poster:`, the front matter, the poster's entry, then the command line. The page is built in Python, with diagrams through the same cache as the documents. A script in the page balances the columns by measured height and fits the poster to its paper. The PDF is that page printed by the container's Chromium (`diagrams/tools/poster_pdf.js`), from a copy with the web fonts inside, as the container has no network. `py-doc poster --watch` (`poster_watch.py`) runs the same build on every save and serves the page with a script that reloads it. +- **Posters** (`poster*.py`): every poster under `posters:`, one self-contained HTML page each, with its PDF when it asks for one. The settings come in layers (`poster_settings.py`): `poster:`, the front matter, the poster's entry, then the command line. The page is built in Python, with diagrams through the same cache as the documents. A script in the page balances the columns by measured height and fits the poster to its paper. The PDF is that page printed by the container's Chromium (`diagrams/tools/poster_pdf.js`), from a copy with the web fonts inside, as the container has no network. `py-doc poster --watch` (`watch.py`) runs the same build on every save and serves the page with a script that reloads it. - **Site** (`site/`): with `build.site` or `--site`, the pages are written and the site tool runs. The posters `site.posters` names are built into it as pages of their own. - **Check outputs**: every file the build wrote is there and not empty. @@ -71,9 +71,9 @@ flowchart LR ## Live previews -`--watch` on `py-doc poster`, `py-doc docx` and `py-doc slides` runs one loop (`poster_watch.py`). It builds, then serves the pages on 127.0.0.1 from a thread of its own. Every half second it looks at the modification times of the source files: the Markdown, includes, pictures, diagram sources and templates below the folders of the sources. The pages it wrote and hidden folders are not watched. A change builds again. Each page is served with a small script that asks `/__status` whether a newer build is there and reloads when it is, so the files written are the same as without `--watch`. A build that fails keeps the last good page and shows its error over it. +`--watch` on `py-doc poster`, `py-doc docx` and `py-doc slides` runs one loop (`watch.py`). It builds, then serves the pages on 127.0.0.1 from a thread of its own. Every half second it looks at the modification times of the source files: the Markdown, includes, pictures, diagram sources and templates below the folders of the sources. The pages it wrote and hidden folders are not watched. A change builds again. Each page is served with a small script that asks `/__status` whether a newer build is there and reloads when it is, so the files written are the same as without `--watch`. A build that fails keeps the last good page and shows its error over it. -A poster is already a web page. A Word document or deck is not, so `docx` and `slides` (`_cli.py`) also write an *HTML twin* of each file (`convert.py`, `convert_twin`). The twin is the same Markdown through the same pipeline to pandoc's HTML. It adds `preview.css` after `html.css` to show page breaks, speaker notes and columns. The twins go to `.py-doc/preview/` below the input's folder and are served by their path. No file of the user's is overwritten, and two `readme.md` in different folders stay apart. When Word or PowerPoint has the output open, the engine raises `OutputLocked`; the watch loop warns and still writes the twin. +A poster is already a web page. A Word document or deck is not, so `docx` and `slides` (`_cli.py`) also write an *HTML twin* of each file (`convert.py`, `convert_twin`). The twin is the same Markdown through the same pipeline to pandoc's HTML. It adds `preview.css` after `html.css` to show page breaks, speaker notes and columns. The twin of a deck also runs `slides-preview.lua`, which puts each slide in a box. A box starts where pandoc's PowerPoint writer starts a slide: at headings up to the split level and at page breaks. The twins go to `.py-doc/preview/` below the input's folder and are served by their path. No file of the user's is overwritten, and two `readme.md` in different folders stay apart. When Word or PowerPoint has the output open, the engine raises `OutputLocked`; the watch loop warns and still writes the twin. -The VS Code extension (`editors/vscode`) is outside the Python package. Its instant preview is a markdown-it plugin in the editor and runs no tool. Its live preview starts `py-doc --watch` in a terminal, waits until `/__status` answers, and opens the served page in VS Code's Simple Browser. The extension and the instant preview choose the command with one function, from the front matter and the blocks only a deck has. +The VS Code extension (`editors/vscode`) is outside the Python package. Its instant preview is a markdown-it plugin in the editor and runs no tool. Its live preview (`src/watch.js`) runs `py-doc --watch --no-open` as a child process, with the output in an output channel. py-doc picks a free port, and the extension reads the address from the `serving` line it prints, then opens it in VS Code's Simple Browser. Stopping the preview ends the whole process tree. The extension and the instant preview choose the command with one function (`src/detect.js`), from the front matter and the blocks only a deck has. diff --git a/docs/architecture/05_decisions.md b/docs/architecture/05_decisions.md index c17a45f..7f3a7f5 100644 --- a/docs/architecture/05_decisions.md +++ b/docs/architecture/05_decisions.md @@ -52,5 +52,5 @@ Each decision records the context, the choice and its consequences. - **Context**: authors want to see a document while they write it, but Word and PowerPoint have no live view, a document open in Word cannot be written, and the in-editor Markdown preview cannot draw most diagrams. - **Decision**: `--watch` builds an HTML twin of each document with the same pipeline, keeps it in `.py-doc/preview`, and serves it with a reload script added as it is served. The VS Code extension runs that command; its own instant preview is an approximation for typing. -- **Consequences**: the preview shows every diagram exactly as the build draws it, and a document open in Word does not stop it. It is one HTML page, so slide boundaries and Word page layout are not shown, and a first build waits for the tool container. +- **Consequences**: the preview shows every diagram exactly as the build draws it, and a document open in Word does not stop it. It is one HTML page: slides are boxes and page breaks are lines, but Word's page layout is not shown, and a first build waits for the tool container. diff --git a/docs/architecture/workspace.dsl b/docs/architecture/workspace.dsl index 0cf3517..d3ed7f4 100644 --- a/docs/architecture/workspace.dsl +++ b/docs/architecture/workspace.dsl @@ -29,7 +29,7 @@ workspace "py-doc" "The C4 model of py-doc, the Markdown technical documentation watch = component "Live preview" "With --watch, builds documents, decks and posters again on every save, writes an HTML twin of each document and deck, and serves the pages on 127.0.0.1 with a script that reloads them." "Python, http.server" } toolImage = container "Tool image" "Every program that is not Python: pandoc, XeLaTeX, PlantUML, Mermaid, Graphviz, the diagram tools, Chromium, structurizr-cli, Vale, markdownlint, MkDocs and Node." "Docker image on Debian 12" - vscode = container "VS Code extension" "Previews py-doc Markdown in the editor as it is typed, and starts the live preview of the open file and shows it beside the editor." "JavaScript, markdown-it" + vscode = container "VS Code extension" "Previews py-doc Markdown in the editor as it is typed, and starts the live preview of the open file and shows it beside the editor." "JavaScript, markdown-it, js-yaml" local = container "Structurizr Local" "Shows a workspace in a browser to view it and arrange its views, and draws the pictures for documents." "Docker image structurizr/structurizr" cache = container "Caches" "Diagram pictures (user cache folder), Structurizr exports, generated chapters and live preview pages (.py-doc in the project)." "Files" "Store" } @@ -38,7 +38,7 @@ workspace "py-doc" "The C4 model of py-doc, the Markdown technical documentation author -> pydoc.local "Views the model and arranges its views" "Browser, HTTP" reader -> office "Opens the documents in" author -> pydoc.vscode "Writes and previews documents in" "VS Code" - pydoc.vscode -> pydoc.cli.commands "Starts py-doc docx, slides or poster --watch for the open file" "Terminal" + pydoc.vscode -> pydoc.cli.commands "Starts py-doc docx, slides or poster --watch for the open file" "Child process" pydoc.vscode -> pydoc.cli.watch "Shows the served preview from" "HTTP on 127.0.0.1" pydoc.cli.commands -> pydoc.cli.watch "Runs --watch with" pydoc.cli.watch -> pydoc.cli.pipeline "Builds each document and its HTML twin with" diff --git a/docs/guide/02_projects.md b/docs/guide/02_projects.md index c1bf828..72691d1 100644 --- a/docs/guide/02_projects.md +++ b/docs/guide/02_projects.md @@ -174,7 +174,7 @@ A file that uses `[[IF:]]` needs its conditions defined, and only `py-doc build` `--watch` builds the files, then builds them again whenever a source below their folder changes: the Markdown, an included file, a picture, a diagram source or the template. Next to each `.docx` or `.pptx` it writes an HTML twin of the same Markdown, to `.py-doc/preview/` below the input's folder, and serves the twins on this computer only. The page in the browser reloads after each build, and a build that fails shows its error over the last good page. - A document open in Word or PowerPoint cannot be written, so it is left as it is, with a warning, and its preview is still updated. -- The twin shows page breaks as dashed lines, speaker notes in boxes and columns side by side. It is one page: a deck is not split into slides. +- The twin shows page breaks as dashed lines, speaker notes in boxes and columns side by side. The twin of a deck shows each slide as a numbered 16:9 box, split as PowerPoint splits it. - The twin is a preview, not an output. Build HTML with `py-doc build` and `outputs: html`. The [VS Code extension](03_writing.md#vs-code-preview) starts `--watch` for the open file and shows the preview beside it. diff --git a/docs/requirements/04_traceability.md b/docs/requirements/04_traceability.md index f4f2930..24fba2c 100644 --- a/docs/requirements/04_traceability.md +++ b/docs/requirements/04_traceability.md @@ -19,9 +19,9 @@ title: Traceability | FR-080 to FR-086, QR-081 | `c4/` | `test_c4.py` | | FR-100 to FR-106 | `lint/`, `markdown/` | `test_lint.py`, `test_lint_cli.py`, `test_structure.py`, `test_vale.py`, `test_markdownlint.py`, `test_markdown_ast.py` | | FR-120 to FR-127, QR-082 | `site/`, `pandoc/resources/site.lua` | `test_site.py` | -| FR-130 to FR-139, FR-150 to FR-160 | `poster.py`, `poster_blocks.py`, `poster_fonts.py`, `poster_settings.py`, `poster_themes.py`, `poster_cli.py`, `poster_watch.py`, `config.py`, `app.py`, `lint/linter.py`, `diagrams/tools/poster_pdf.js` | `test_poster.py`, `test_poster_browser.py` | -| FR-161 | `_cli.py`, `convert.py`, `poster_watch.py`, `pandoc/engine.py`, `pandoc/resources/preview.css` | `test_cli.py`, `test_slides_cli.py`, `test_convert.py` | -| FR-162 | `editors/vscode/extension.js` | `editors/vscode/test/test.js` | +| FR-130 to FR-139, FR-150 to FR-160 | `poster.py`, `poster_blocks.py`, `poster_fonts.py`, `poster_settings.py`, `poster_themes.py`, `poster_cli.py`, `watch.py`, `config.py`, `app.py`, `lint/linter.py`, `diagrams/tools/poster_pdf.js` | `test_poster.py`, `test_poster_browser.py` | +| FR-161 | `_cli.py`, `convert.py`, `watch.py`, `pandoc/engine.py`, `pandoc/resources/preview.css`, `pandoc/resources/slides-preview.lua` | `test_cli.py`, `test_slides_cli.py`, `test_convert.py`, `test_preview_cases.py` | +| FR-162 | `editors/vscode/extension.js`, `editors/vscode/src/` | `editors/vscode/test/`, `test_preview_cases.py` | | FR-028 | `diagrams/render.py`, `config.py` | `test_diagrams.py` | | FR-128 | `site/pages.py`, `site/engines.py` | `test_site.py` | | FR-140 to FR-143, QR-040 to QR-043 | `runtime/`, `tools_cli.py` | `test_runtime.py`, `test_image.py`, `test_setup.py`, `test_doctor.py`, `test_platform.py` | diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore index bf57179..57df9cf 100644 --- a/editors/vscode/.vscodeignore +++ b/editors/vscode/.vscodeignore @@ -1,5 +1,5 @@ .vscode/** .git/** -node_modules/** test/** +eslint.config.js .vscodeignore diff --git a/editors/vscode/LICENSE b/editors/vscode/LICENSE new file mode 100644 index 0000000..e9513c5 --- /dev/null +++ b/editors/vscode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mark Bacon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 7e240b7..8b14af5 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -17,7 +17,7 @@ This extension provides two complementary ways to preview your documents: | **Docker / Tooling** | **Zero dependencies**, 100% offline pure JavaScript | Uses `py-doc`'s tool container via Pandoc | | **Formats Supported** | Technical Documents, Slide Decks, Posters | Technical Documents (`docx`), Slide Decks (`slides`), Posters (`poster`) | | **Diagrams** | Live client SVG preview for Mermaid, PlantUML syntax cards | Full 100% compilation of all 12 diagram engines (Mermaid, PlantUML, Graphviz, Ditaa, Wavedrom, Vega, Pikchr, Bytefield, etc.) | -| **Where it Displays** | Native VS Code Markdown preview pane | Side-by-side inside VS Code via `Simple Browser` or external browser | +| **Where it Displays** | Native VS Code Markdown preview pane | Side-by-side inside VS Code via `Simple Browser`, on a free local port | | **File Lock Tolerance** | Never touches output files | Skips a `.docx`/`.pptx` open in Word or PowerPoint, with a warning, and still updates the HTML twin | --- @@ -31,11 +31,11 @@ When you run `py-doc` with `--watch` (or click `πŸ‘οΈ` in VS Code): - The twin is written to `.py-doc/preview/` below the document's folder, so no file of yours is overwritten. 2. **Slide Decks (`py-doc slides deck.md --watch`)**: - Rebuilds `deck.pptx` on save (PowerPoint locks are handled the same way). - - Builds an HTML twin of the deck's content as one continuous page: columns side by side, and speaker notes (`::: notes` / ``) in labelled boxes. It does not split the content into slides; use the instant preview for slide layout. + - Builds an HTML twin of the deck: each slide a numbered 16:9 box, split where PowerPoint splits it (headings up to `--split-level`, and page breaks), with columns side by side and speaker notes (`::: notes` / ``) in labelled boxes. 3. **Posters (`py-doc poster poster.md --watch`)**: - Rebuilds the self-contained poster HTML file on save with true vector graphics and barcodes. -Every format is served at `http://127.0.0.1:8765` (the `pydoc.watch.port` setting) and reloads on each save. +py-doc serves the preview on a free local port and the page reloads on each save. Its output is in the **py-doc live preview** output channel. The `πŸ‘οΈ` button chooses the command from the file itself, the same way the instant preview chooses its mode: - **Poster**: the front matter has a poster setting (`paper`, `byline`, `orientation`, `theme`, `columns`, `width`, `colours`, `palette`, `logo`, `embed_fonts`). @@ -96,6 +96,30 @@ When authoring a poster (a poster setting such as `paper: a1` or `byline: ...` i --- +## Install + +The extension is not on the Marketplace yet. Build it and install the file: + +```bash +cd editors/vscode +npm ci +npx @vscode/vsce package # writes vscode-py-doc-preview-.vsix +code --install-extension vscode-py-doc-preview-*.vsix +``` + +The live preview needs py-doc itself (`pip install -e .` at the root of this repository, and Docker). + +--- + +## Settings + +| Setting | Default | What it does | +|:---|:---|:---| +| `pydoc.executable` | `py-doc` | The py-doc program the live preview runs. Set it to the `py-doc` of a virtual environment (`.venv/Scripts/py-doc.exe` on Windows, `.venv/bin/py-doc` elsewhere) when it is not on the `PATH`. | +| `pydoc.watch.port` | `0` | The local port of the live preview. `0` lets py-doc pick a free one, so each window can have its own preview. | + +--- + ## Adding to the `py-doc` Repository To include this extension as a standard writing tool in the [`markbac/py-doc`](https://github.com/markbac/py-doc) repository: @@ -105,16 +129,20 @@ Located in `editors/vscode`: ```text py-doc/ β”œβ”€β”€ src/py_doc/ -β”œβ”€β”€ docs/ -β”œβ”€β”€ examples/ +β”œβ”€β”€ tests/fixtures/preview/cases.json <-- shared by the Python and the extension tests └── editors/ - └── vscode/ <-- VS Code extension + └── vscode/ <-- VS Code extension β”œβ”€β”€ package.json - β”œβ”€β”€ extension.js - β”œβ”€β”€ styles/ - β”‚ └── py-doc-preview.css + β”œβ”€β”€ extension.js # activation and commands + β”œβ”€β”€ src/ + β”‚ β”œβ”€β”€ plugin.js # the instant preview (markdown-it plugin) + β”‚ β”œβ”€β”€ detect.js # which command a file is for + β”‚ β”œβ”€β”€ frontmatter.js # front matter (js-yaml) + β”‚ β”œβ”€β”€ text.js # escaping + β”‚ └── watch.js # the live preview process + β”œβ”€β”€ styles/py-doc-preview.css + β”œβ”€β”€ images/icon.png β”œβ”€β”€ test/ - β”‚ └── test.js └── README.md ``` @@ -133,7 +161,11 @@ In projects initialized with `py-doc init`, or at the root of `py-doc`: ## Running Automated Tests ```bash -npm test +npm ci +npm run lint # ESLint +npm test # node --test ``` -Runs all 10 test suites validating documents, slides, posters, image dimensions, diagram features, format auto-detection, and regressions. +- `test/test.js`: the instant preview of documents, slides and posters, format detection, and regressions. +- `test/cases.test.js`: the shared cases of `tests/fixtures/preview/cases.json`. The Python tests (`tests/test_preview_cases.py`) run the same cases through the HTML twin that `--watch` builds, so the two previews cannot drift apart. +- `test/watch.test.js`: the live preview, against a fake `py-doc`. diff --git a/editors/vscode/eslint.config.js b/editors/vscode/eslint.config.js new file mode 100644 index 0000000..0155b8f --- /dev/null +++ b/editors/vscode/eslint.config.js @@ -0,0 +1,23 @@ +'use strict'; + +const js = require('@eslint/js'); +const globals = require('globals'); + +module.exports = [ + { ignores: ['node_modules/**'] }, + js.configs.recommended, + { + files: ['**/*.js'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'commonjs', + globals: { ...globals.node }, + }, + rules: { + 'no-unused-vars': ['error', { caughtErrors: 'none' }], + eqeqeq: ['error', 'always'], + 'prefer-const': 'error', + 'no-var': 'error', + }, + }, +]; diff --git a/editors/vscode/extension.js b/editors/vscode/extension.js index dae3179..7a3c8d1 100644 --- a/editors/vscode/extension.js +++ b/editors/vscode/extension.js @@ -1,881 +1,58 @@ /** - * vscode-py-doc-preview extension + * vscode-py-doc-preview: previews of py-doc Markdown in VS Code. * - * Extends VS Code's built-in Markdown preview for all py-doc outputs: - * 1. Word Technical Documents (.docx): - * - Numbered captions: [[CAPTION:Figure]] text, [[CAPTION:Table]] text - * - Word callout boxes: > [!NOTE] and > **Note:** text (Note, Tip, Important, Warning, Caution) - * - Page breaks: [[PAGEBREAK]] and ::: {.pagebreak} - * - Section breaks: [[LANDSCAPE]] and [[PORTRAIT]] - * - Paragraph Word styles: [[STYLE:StyleName]] text - * - Image width attributes: ![](images/board.png){width=60%} - * - Diagram types: plantuml, dot/graphviz, ditaa, wavedrom, vega, bytefield, etc. - * - Directives: [[INCLUDE:...]], [[INCLUDE-CODE:...]], [[IF:...]], [[ENDIF]] - * 2. PowerPoint Slide Decks (.pptx): - * - Section Header slides (# Section Title) - * - Content slide cards (## Slide Title) with slide footers - * - Responsive two-column layouts (:::: columns / ::: column) - * - Speaker notes (::: notes and ) - * - Incremental lists (::: incremental) - * 3. HTML & PDF Posters (py-doc poster): - * - Stat strip (## ... {.stats}) with KPI cards & trend indicators - * - Abstract introduction banner (## ... {.abstract}) - * - Headings with colon accent (## 1. Title: Accent) and class stripping - * - Card grid blocks (```cards cols=N style=...```) - * - Segments bar blocks (```segments```) - * - Flow sequence blocks (```flow```) - * - Summary closing banner (## ... {.summary}) - * - Poster header & footer (paper size, byline, QR code label) + * - The instant preview (src/plugin.js) extends VS Code's built-in Markdown preview as you type. + * - The live preview (src/watch.js) runs `py-doc docx|slides|poster --watch` on the open file and shows + * the page it serves beside the editor, rebuilt with every diagram on each save. + * - Both choose the format of a file the same way (src/detect.js). */ 'use strict'; -const KINDS = { - NOTE: { title: 'Note', icon: 'ℹ️', class: 'note' }, - TIP: { title: 'Tip', icon: 'πŸ’‘', class: 'tip' }, - IMPORTANT: { title: 'Important', icon: '❗', class: 'important' }, - WARNING: { title: 'Warning', icon: '⚠️', class: 'warning' }, - CAUTION: { title: 'Caution', icon: 'πŸ›‘', class: 'caution' }, -}; - -const DIAGRAM_TYPES = new Set([ - 'mermaid', 'plantuml', 'puml', 'dot', 'graphviz', 'ditaa', - 'blockdiag', 'seqdiag', 'actdiag', 'nwdiag', 'packetdiag', - 'wavedrom', 'bytefield-svg', 'pikchr', 'railroad', 'wireviz', - 'vega', 'vega-lite' -]); - -function parseSimpleYaml(yamlStr) { - const result = {}; - const lines = yamlStr.split('\n'); - let currentKey = null; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) continue; - - const item = trimmed.match(/^-\s+(.*)$/); - if (item && currentKey) { - if (!Array.isArray(result[currentKey])) result[currentKey] = []; - result[currentKey].push(unquote(item[1].trim())); - continue; - } - - if (line.startsWith(' ') && currentKey) { - const subColon = trimmed.indexOf(':'); - if (subColon > 0) { - const subKey = trimmed.slice(0, subColon).trim(); - const subVal = unquote(trimmed.slice(subColon + 1).trim()); - if (typeof result[currentKey] !== 'object' || result[currentKey] === null) { - result[currentKey] = {}; - } - result[currentKey][subKey] = subVal; - } - continue; - } - - const colonIdx = trimmed.indexOf(':'); - if (colonIdx > 0) { - const key = trimmed.slice(0, colonIdx).trim(); - const val = unquote(trimmed.slice(colonIdx + 1).trim()); - currentKey = key; - result[key] = val === '' ? {} : val; - } - } - return result; -} - -function unquote(val) { - if (val.length >= 2 && ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))) { - return val.slice(1, -1); - } - return val; -} - -// Front matter values may be lists (author: [- Jane, - Joe]) or mappings, so anything is turned into text first -function asText(val) { - if (val === null || val === undefined) return ''; - if (Array.isArray(val)) return val.map(asText).join(', '); - if (typeof val === 'object') return Object.values(val).map(asText).join(', '); - return String(val); -} - -// A colour name written in the Markdown, made safe to use as part of a class name -function cssName(val) { - return asText(val).toLowerCase().replace(/[^a-z0-9_-]/g, ''); -} - -function escapeHtml(val) { - const str = asText(val); - if (!str) return ''; - return str.replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -function renderFrontMatterBlock(meta, docMode) { - if (!meta || Object.keys(meta).length === 0) return ''; - - if (docMode === 'poster') { - let html = '
    \n'; - if (meta.title) { - html += `

    ${escapeHtml(meta.title)}

    \n`; - } - if (meta.subtitle) { - html += `
    ${escapeHtml(meta.subtitle)}
    \n`; - } - if (meta.byline) { - html += ` \n`; - } - html += '
    \n'; - if (meta.paper) { - html += ` Paper: ${escapeHtml(asText(meta.paper).toUpperCase())}\n`; - } - if (meta.document_number) { - html += ` Doc #: ${escapeHtml(meta.document_number)}\n`; - } - if (meta.version) { - html += ` Version: ${escapeHtml(meta.version)}\n`; - } - html += '
    \n'; - html += '
    \n'; - return html; - } - - const isSlide = (docMode === 'slide'); - let html = `
    \n`; - if (meta.title) { - html += `

    ${escapeHtml(meta.title)}

    \n`; - } - if (meta.subtitle) { - html += `
    ${escapeHtml(meta.subtitle)}
    \n`; - } - html += '
    \n'; - if (meta.document_number) { - html += ` Doc #: ${escapeHtml(meta.document_number)}\n`; - } - if (meta.version) { - html += ` Version: ${escapeHtml(meta.version)}\n`; - } - if (meta.status) { - const statusClass = asText(meta.status).toLowerCase() === 'draft' ? 'draft' : 'released'; - html += ` Status: ${escapeHtml(meta.status)}\n`; - } - if (meta.author) { - html += ` Author: ${escapeHtml(meta.author)}\n`; - } - if (meta.date) { - html += ` Date: ${escapeHtml(meta.date)}\n`; - } - html += '
    \n'; - html += '
    \n'; - return html; -} - -// Front matter keys only a poster has (see py_doc/poster_settings.py) -const POSTER_KEYS = ['paper', 'byline', 'orientation', 'theme', 'columns', 'width', 'colours', 'palette', 'embed_fonts', 'logo']; -const SLIDE_DIVS = /^:{3,4}\s*\{?\.?(columns|column|notes|incremental)\}?\s*$/; - -/** - * Which py-doc command a Markdown file is for: 'poster', 'slides' or 'docx'. - * Decided from the front matter, then from blocks only a deck has (columns, speaker notes, incremental lists). - * The instant preview and the live watch button both use this, so they always agree. - */ -function detectFormat(meta, body) { - meta = meta || {}; - const fmt = asText(meta.format || meta.output || meta.type || meta.layout).toLowerCase(); - if (fmt.includes('pptx') || fmt.includes('slide') || meta.split_level) return 'slides'; - if (fmt.includes('poster') || POSTER_KEYS.some((k) => meta[k] !== undefined && meta[k] !== '')) return 'poster'; - - let fence = null; - for (const line of (body || '').split(/\r?\n/)) { - const trimmed = line.trim(); - const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/); - if (fenceMatch) { - if (!fence) fence = fenceMatch[1][0]; - else if (trimmed[0] === fence) fence = null; - continue; - } - if (fence) continue; - if (SLIDE_DIVS.test(trimmed) || /^ - if (token.type === 'html_block') { - const commentNoteMatch = token.content.match(//i); - if (commentNoteMatch) { - const noteText = commentNoteMatch[1].trim(); - token.content = `
    πŸ“ Speaker Notes

    ${escapeHtml(noteText)}

    \n`; - continue; - } - } - - // C. Process Headings: {.stats}, {.abstract}, {.summary}, {.references}, and accent colons - if (token.type === 'heading_open') { - const next = tokens[i + 1]; - if (next && next.type === 'inline' && next.content) { - let text = next.content; - - const classMatch = text.match(/\s*\{([.#][\w\s.-]+)\}\s*$/); - let headingClass = ''; - if (classMatch) { - headingClass = classMatch[1].replace(/^\./, '').trim(); - text = text.replace(/\s*\{([.#][\w\s.-]+)\}\s*$/, '').trim(); - token.attrs = token.attrs || []; - token.attrs.push(['class', `pydoc-heading-${headingClass}`]); - } - - next.content = text; - if (classMatch) { - for (const c of next.children) { - if (c.type === 'text') { - c.content = c.content.replace(/\s*\{([.#][\w\s.-]+)\}\s*$/, ''); - } - } - } else if (docMode === 'poster') { - // Poster headings: what follows the first colon is the accent. The inline tokens are split, not - // re-rendered, so code, bold and links after the colon keep their formatting - const children = next.children || []; - const k = children.findIndex((c) => c.type === 'text' && c.content.indexOf(':') > 0); - if (k !== -1) { - const colon = children[k].content.indexOf(':'); - const rest = children[k].content.slice(colon + 1); - children[k].content = children[k].content.slice(0, colon + 1); - const open = new state.Token('html_inline', '', 0); - open.content = ''; - const restToken = new state.Token('text', '', 0); - restToken.content = rest; - const close = new state.Token('html_inline', '', 0); - close.content = ''; - children.splice(k + 1, 0, open, restToken); - children.push(close); - } - } - - if (headingClass === 'stats') { - let tableOpenIdx = -1; - let tableCloseIdx = -1; - for (let j = i + 1; j < tokens.length; j++) { - if (tokens[j].type === 'table_open') tableOpenIdx = j; - if (tokens[j].type === 'table_close') { - tableCloseIdx = j; - break; - } - if (tokens[j].type === 'heading_open') break; - } - - if (tableOpenIdx !== -1 && tableCloseIdx !== -1) { - const rows = []; - let curRow = null; - let inTbody = false; - - for (let k = tableOpenIdx; k <= tableCloseIdx; k++) { - if (tokens[k].type === 'tbody_open') inTbody = true; - if (tokens[k].type === 'tbody_close') inTbody = false; - if (inTbody) { - if (tokens[k].type === 'tr_open') curRow = []; - if (tokens[k].type === 'tr_close' && curRow) { - rows.push(curRow); - curRow = null; - } - if (tokens[k].type === 'inline' && curRow) { - curRow.push(tokens[k].content); - } - } - } - - let statsHtml = '
    \n'; - for (const r of rows) { - const val = r[0] || ''; - const label = r[1] || ''; - const change = r[2] || ''; - const verdict = (r[3] || '').toLowerCase().trim(); - const changeClass = verdict ? verdict : (change.startsWith('+') ? 'good' : (change.startsWith('-') ? 'bad' : '')); - - statsHtml += '
    \n'; - statsHtml += `
    ${escapeHtml(val)}
    \n`; - statsHtml += `
    ${escapeHtml(label)}
    \n`; - if (change) { - statsHtml += `
    ${escapeHtml(change)}
    \n`; - } - statsHtml += '
    \n'; - } - statsHtml += '
    \n'; - - const replacement = new state.Token('html_block', '', 0); - replacement.content = statsHtml; - tokens.splice(tableOpenIdx, (tableCloseIdx - tableOpenIdx + 1), replacement); - } - } - } - } - - // D. Fenced Blocks: ```cards```, ```segments```, ```flow```, and diagram types - if (token.type === 'fence') { - const rawInfo = token.info.trim(); - const mainTag = rawInfo.split(/\s+/)[0].toLowerCase(); - - // Cards: ```cards cols=3 style=top``` - if (mainTag === 'cards') { - const lines = token.content.split('\n').filter(l => l.trim()); - let cardsHtml = '
    \n'; - for (const line of lines) { - const parts = line.split('|').map(s => s.trim()); - if (parts.length >= 2) { - const title = parts[0]; - const desc = parts[1]; - const color = parts[2] || 'blue'; - cardsHtml += `
    \n`; - cardsHtml += `
    ${escapeHtml(title)}
    \n`; - cardsHtml += `
    ${escapeHtml(desc)}
    \n`; - cardsHtml += '
    \n'; - } - } - cardsHtml += '
    \n'; - token.type = 'html_block'; - token.content = cardsHtml; - continue; - } - - // Segments: ```segments``` - if (mainTag === 'segments') { - const lines = token.content.split('\n').filter(l => l.trim()); - let segTitle = ''; - const items = []; - for (const line of lines) { - if (line.toLowerCase().startsWith('title:')) { - segTitle = line.replace(/^title:\s*/i, ''); - } else { - const parts = line.split('|').map(s => s.trim()); - if (parts.length >= 2) { - items.push({ - weight: parts[0], - label: parts[1], - value: parts[2] || '', - color: parts[3] || 'blue' - }); - } - } - } - - let segHtml = '
    \n'; - if (segTitle) { - segHtml += `
    ${escapeHtml(segTitle)}
    \n`; - } - segHtml += '
    \n'; - for (const it of items) { - segHtml += `
    ${escapeHtml(it.label)}
    \n`; - } - segHtml += '
    \n'; - segHtml += '
    \n'; - token.type = 'html_block'; - token.content = segHtml; - continue; - } - - // Flow: ```flow``` - if (mainTag === 'flow') { - const lines = token.content.split('\n').filter(l => l.trim()); - let flowHtml = '
    \n'; - for (const line of lines) { - const parts = line.split(/-(.*?)->/).map(s => s.trim()); - flowHtml += '
    \n'; - for (let p = 0; p < parts.length; p++) { - const item = parts[p]; - if (p % 2 === 0) { - const nodeMatch = item.match(/^\[(.*?)(?:\|(.*?))?\]$/); - const nodeText = nodeMatch ? nodeMatch[1] : item; - const nodeColor = (nodeMatch && nodeMatch[2]) ? nodeMatch[2] : 'blue'; - flowHtml += ` ${escapeHtml(nodeText)}\n`; - } else { - flowHtml += ` βž” ${escapeHtml(item)}\n`; - } - } - flowHtml += '
    \n'; - } - flowHtml += '
    \n'; - token.type = 'html_block'; - token.content = flowHtml; - continue; - } - - // Other py-doc diagram types (plantuml, dot, ditaa, wavedrom, vega, bytefield, etc.) - if (DIAGRAM_TYPES.has(mainTag) && mainTag !== 'mermaid') { - const titleMatch = rawInfo.match(/(?:title|caption)=["']?([^"']+)["']?/i); - const diagTitle = titleMatch ? titleMatch[1] : ''; - const escapedContent = md.utils.escapeHtml(token.content); - - token.type = 'html_block'; - token.content = `
    \n` + - `
    ${mainTag.toUpperCase()}${diagTitle ? ` ${escapeHtml(diagTitle)}` : ''}
    \n` + - `
    ${escapedContent}
    \n` + - `
    \n`; - continue; - } - } - - // E. Process Paragraphs containing py-doc markers - if (token.type === 'paragraph_open') { - const next = tokens[i + 1]; - if (next && next.type === 'inline' && next.content) { - const rawLines = next.content.split('\n'); - let hasMarkers = false; - - for (const line of rawLines) { - const trimmed = line.trim(); - if ( - trimmed === '[[PAGEBREAK]]' || - trimmed === '::: {.pagebreak}' || - /^\[\[(LANDSCAPE|PORTRAIT)\]\]$/i.test(trimmed) || - /^\[\[CAPTION:[A-Za-z0-9 _-]+\]\]/i.test(trimmed) || - /^\[\[STYLE:[A-Za-z0-9 _-]+\]\]/i.test(trimmed) || - /^\[\[(INCLUDE|INCLUDE-CODE|IF|NOT|ENDIF)(?::.*)?\]\]$/i.test(trimmed) - ) { - hasMarkers = true; - break; - } - } - - if (hasMarkers) { - let renderedHtml = ''; - for (const line of rawLines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - // Page Break - if (trimmed === '[[PAGEBREAK]]' || trimmed === '::: {.pagebreak}') { - renderedHtml += '
    PAGE BREAK
    \n'; - continue; - } - - // Orientation - const orientMatch = trimmed.match(/^\[\[(LANDSCAPE|PORTRAIT)\]\]$/i); - if (orientMatch) { - const orient = orientMatch[1].toUpperCase(); - renderedHtml += `
    SECTION BREAK (${orient})
    \n`; - continue; - } - - // Directives - const directiveMatch = trimmed.match(/^\[\[(INCLUDE|INCLUDE-CODE|IF|NOT|ENDIF)(?::\s*([^\]]+))?\]\]$/i); - if (directiveMatch) { - const dType = directiveMatch[1].toUpperCase(); - const dArg = (directiveMatch[2] || '').trim(); - renderedHtml += `
    ${escapeHtml(dType)} ${escapeHtml(dArg)}
    \n`; - continue; - } - - // Numbered Caption - const capMatch = trimmed.match(/^\[\[CAPTION:([A-Za-z0-9 _-]+)\]\](?:\s*(.*))?$/); - if (capMatch) { - const label = capMatch[1].trim(); - const text = (capMatch[2] || '').trim(); - counters[label] = (counters[label] || 0) + 1; - const num = counters[label]; - renderedHtml += `
    ${escapeHtml(label)} ${num}: ${md.renderInline(text)}
    \n`; - continue; - } - - // Style - const styleMatch = trimmed.match(/^\[\[STYLE:([A-Za-z0-9 _-]+)\]\](?:\s*(.*))?$/); - if (styleMatch) { - const styleName = styleMatch[1].trim(); - const text = (styleMatch[2] || '').trim(); - renderedHtml += `
    ${escapeHtml(styleName)}${md.renderInline(text)}
    \n`; - continue; - } - - renderedHtml += `

    ${md.renderInline(line)}

    \n`; - } - - tokens[i] = new state.Token('html_block', '', 0); - tokens[i].content = renderedHtml; - tokens.splice(i + 1, 2); - continue; - } - } - } - - // F. Process Blockquotes for Callouts / Admonitions - if (token.type === 'blockquote_open') { - let firstInlineIdx = -1; - for (let j = i + 1; j < tokens.length; j++) { - if (tokens[j].type === 'blockquote_close') break; - if (tokens[j].type === 'inline') { - firstInlineIdx = j; - break; - } - } - - if (firstInlineIdx !== -1) { - const inlineToken = tokens[firstInlineIdx]; - const rawContent = inlineToken.content.trim(); - const ghMatch = rawContent.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i); - const boldMatch = rawContent.match(/^\*\*(Note|Tip|Important|Warning|Caution)\s*:\*\*/i) - || rawContent.match(/^\*\*(Note|Tip|Important|Warning|Caution)\*\*\s*:/i); - - const kindRaw = ghMatch ? ghMatch[1] : (boldMatch ? boldMatch[1] : null); - - if (kindRaw) { - const kindKey = kindRaw.toUpperCase(); - const info = KINDS[kindKey] || { title: kindKey, icon: 'ℹ️', class: 'note' }; - - token.attrs = [['class', `pydoc-callout pydoc-callout-${info.class}`]]; - - let bodyText = rawContent; - if (ghMatch) { - bodyText = bodyText.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i, ''); - } else if (boldMatch) { - bodyText = bodyText.replace(/^\*\*(Note|Tip|Important|Warning|Caution)\s*:\*\*\s*/i, '') - .replace(/^\*\*(Note|Tip|Important|Warning|Caution)\*\*\s*:\s*/i, ''); - } - - const headerBlock = new state.Token('html_block', '', 0); - headerBlock.content = `
    ${info.icon}${info.title}
    \n`; - - const pOpenIdx = firstInlineIdx - 1; - tokens.splice(pOpenIdx, 0, headerBlock); - i++; - - inlineToken.content = bodyText; - if (!bodyText) { - tokens.splice(pOpenIdx + 1, 3); - } else { - inlineToken.children = [new state.Token('html_inline', '', 0)]; - inlineToken.children[0].content = md.renderInline(bodyText); - } - } - } - } - } - - // G. If in Slide Deck mode, group tokens into presentation slide cards - if (docMode === 'slide') { - groupTokensIntoSlides(state, docTitle, Number.parseInt(asText(frontMeta && frontMeta.split_level), 10) || 2); - } - - // H. If in Poster mode, append poster footer if defined - if (docMode === 'poster' && frontMeta && frontMeta.footer) { - const footer = frontMeta.footer; - let footerHtml = '
    \n'; - footerHtml += ` \n`; - footerHtml += ` \n`; - if (footer.qr || footer['qr-label']) { - footerHtml += `
    πŸ“± ${escapeHtml(footer['qr-label'] || 'QR Code')}
    \n`; - } - footerHtml += '
    \n'; - - const footToken = new state.Token('html_block', '', 0); - footToken.content = footerHtml; - tokens.push(footToken); - } - }); -} - -// A heading at splitLevel or above starts a slide, as pandoc's --slide-level does; one above it is a section slide -function groupTokensIntoSlides(state, docTitle, splitLevel) { - const tokens = state.tokens; - const newTokens = []; - let currentSlideTokens = []; - let slideNum = 0; - let isCurrentSection = false; - - function flushSlide(isSection) { - if (currentSlideTokens.length === 0) return; - slideNum++; - const cardOpen = new state.Token('html_block', '', 0); - const slideClass = isSection ? 'pydoc-slide pydoc-slide-section' : 'pydoc-slide pydoc-slide-content'; - cardOpen.content = `
    \n`; - newTokens.push(cardOpen); - - for (const t of currentSlideTokens) { - newTokens.push(t); - } - - const cardClose = new state.Token('html_block', '', 0); - cardClose.content = `
    ${escapeHtml(docTitle)}Slide ${slideNum}
    \n`; - newTokens.push(cardClose); - currentSlideTokens = []; - } - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - - if (token.type === 'pydoc_front_matter') { - newTokens.push(token); - continue; - } - - if (token.type === 'heading_open') { - const level = parseInt(token.tag.slice(1), 10); - if (level <= splitLevel) { - flushSlide(isCurrentSection); - isCurrentSection = (level < splitLevel); - } - } - - if (token.type === 'html_block' && token.content && token.content.includes('pydoc-pagebreak')) { - flushSlide(isCurrentSection); - isCurrentSection = false; - continue; - } - - currentSlideTokens.push(token); - } - - flushSlide(isCurrentSection); - state.tokens = newTokens; -} +const { pyDocPlugin } = require('./src/plugin'); +const { detectFormat, detectDocumentFormat } = require('./src/detect'); +const { parseFrontMatter } = require('./src/frontmatter'); let vscode; -try { vscode = require('vscode'); } catch (e) { vscode = null; } - -let watchTerminal = null; - -const WATCH_START_TIMEOUT = 120000; // ms: a first build may have to start the tool container -const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -// Whether a py-doc watch server answers on this port -function serverUp(port) { - return new Promise((resolve) => { - const req = require('http').get({ host: '127.0.0.1', port, path: '/__status', timeout: 500 }, (res) => { - res.resume(); - resolve(res.statusCode === 200); - }); - req.on('timeout', () => req.destroy()); - req.on('error', () => resolve(false)); - }); -} - -function stopWatch() { - if (watchTerminal) { - watchTerminal.dispose(); // ends the py-doc process running in it - watchTerminal = null; - return true; - } - return false; -} +try { vscode = require('vscode'); } catch (e) { vscode = null; } // absent when the tests run in Node -async function launchWatch(subcommand, filePath) { - const port = vscode.workspace.getConfiguration('pydoc').get('watch.port', 8765); - - // A running watch is stopped first: typing a new command into its terminal would go to the running process - if (stopWatch()) { - for (let waited = 0; waited < 5000 && await serverUp(port); waited += 250) await delay(250); - } - - const terminal = vscode.window.createTerminal({ - name: `py-doc ${subcommand} watch`, - iconPath: new vscode.ThemeIcon('eye') - }); - watchTerminal = terminal; - terminal.show(true); - terminal.sendText(`py-doc ${subcommand} "${filePath}" --watch --port ${port} --no-open`); - - const started = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Window, title: `py-doc: building the ${subcommand} preview` }, - async () => { - for (let waited = 0; waited < WATCH_START_TIMEOUT; waited += 500) { - if (watchTerminal !== terminal || terminal.exitStatus !== undefined) return false; - if (await serverUp(port)) return true; - await delay(500); - } - return false; - } - ); - if (started) { - vscode.commands.executeCommand('simpleBrowser.show', `http://127.0.0.1:${port}`); - } else if (watchTerminal === terminal) { - vscode.window.showWarningMessage(`The py-doc preview server did not start on port ${port}. See the "${terminal.name}" terminal.`); - } -} +let live = null; function activate(context) { if (!vscode) return; + const { LiveWatch } = require('./src/watch'); + live = new LiveWatch(vscode); - const withActiveFile = (run) => async () => { + // Run a live preview command on the Markdown file of the active editor + const onActiveFile = (command) => async () => { const editor = vscode.window.activeTextEditor; if (!editor) { vscode.window.showErrorMessage('No active Markdown file to preview.'); return; } - await run(editor.document); + const doc = editor.document; + if (doc.isUntitled) { + vscode.window.showErrorMessage('Save the file first: py-doc builds it from disk.'); + return; + } + const folder = vscode.workspace.getWorkspaceFolder(doc.uri); // where py-doc.yml and themes are found + await live.start(command || detectDocumentFormat(doc), doc.uri.fsPath, folder ? folder.uri.fsPath : undefined); }; context.subscriptions.push( - vscode.commands.registerCommand('pydoc.openLiveWatch', - withActiveFile((doc) => launchWatch(detectDocumentFormat(doc), doc.uri.fsPath))), - vscode.commands.registerCommand('pydoc.openDocxWatch', withActiveFile((doc) => launchWatch('docx', doc.uri.fsPath))), - vscode.commands.registerCommand('pydoc.openSlidesWatch', withActiveFile((doc) => launchWatch('slides', doc.uri.fsPath))), - vscode.commands.registerCommand('pydoc.openPosterWatch', withActiveFile((doc) => launchWatch('poster', doc.uri.fsPath))), - vscode.commands.registerCommand('pydoc.stopLiveWatch', () => { - if (stopWatch()) vscode.window.showInformationMessage('py-doc live preview server stopped.'); + vscode.commands.registerCommand('pydoc.openLiveWatch', onActiveFile(null)), + vscode.commands.registerCommand('pydoc.openDocxWatch', onActiveFile('docx')), + vscode.commands.registerCommand('pydoc.openSlidesWatch', onActiveFile('slides')), + vscode.commands.registerCommand('pydoc.openPosterWatch', onActiveFile('poster')), + vscode.commands.registerCommand('pydoc.stopLiveWatch', async () => { + if (await live.stop()) vscode.window.showInformationMessage('py-doc live preview stopped.'); }), - vscode.window.onDidCloseTerminal((closed) => { - if (closed === watchTerminal) watchTerminal = null; - }) + live ); } function deactivate() { - stopWatch(); + return live ? live.stop() : undefined; } function extendMarkdownIt(md) { @@ -887,7 +64,7 @@ module.exports = { deactivate, extendMarkdownIt, pyDocPlugin, - parseSimpleYaml, + parseFrontMatter, detectFormat, detectDocumentFormat }; diff --git a/editors/vscode/images/icon.png b/editors/vscode/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ebc6842f293e2b736c36493410595f303e0f6674 GIT binary patch literal 6651 zcmV1|EvH1>#M)&0y&(~U?cRiMhaAx8l{S(awo&F3WlW znl&7_n*Nulu2zHHj)O=5#ZWn)_iF69-zWwe0b%DnL0>Q|1rcT_Khcbu8ga_=m6Zl+ zDilyn?R?r83}Y}duMi}Gvptjl>Od_0~HS|SzhM&H0EJEhZ5V#W%Q(y$bU<^>6Ly&_)*fWR(26X!o z83<0o0cU%lSPi&(7PZUX>0*Nchu8rUKzZjer41i2zY3^(VOV3K1Rw;U=ojkp^eyx! zdEX?5^q>?JQyp!{V1OF{Hz|z90rm_W`^R{7#rBSTp}POxfSp#apS}2gaP|N@B2-4XD=`6syC)W3u1nCoC?pzbKwGL)4oCR*yfEnd*;*NGrNjIOv za1cSjFij}t0OIRdyZo{KieG6qH5VjDHK7jR)Kk9mKPdn%*0m#TS%qv5HZv)sENRjLL+2IKU zOv{9m_!QPZ@nT<|p!c3!SxMwju-Hl%RvDzAtTWg1{SOAFxbCdnScxmYT!xxeW>QYz$zCX>z*Lp~y@Do>`t|_s z-*Xp_hEVT-u~F`1PEOX%T99IB1~{BVk6xg=2bfz$ab7{l+h7Go=X(E$fb>S~i`_pf z`|toXqqK7;!=a)LFwJRj9O+8ELkD*rX#_*jC~asz(KB-!q__|pRvhx|t}ml&Yr6+$ zd}Tb$&~89Q+^NXSyMHpe!5M^*PeF+7#t#Q!->X#a2`sE3%XVj_!3Hy9T}wVAL;QR*_B$_Vi1k3 zK){3%4r$V79=b)TIOiGwztgpv9y$q-JjkeoON)R>*O4)B&LNuHP&Rfn?pt&VZk%~3 z&L3aq`!HZT5=R+2< zYN{^Sr2^C1lL?SKsN@t#UDv@fZG71t#SL?&V#PxXa8_wCvI!rYXhU&^$|}R?*56UMKqR~cKxa?hWl^-6vh(krOf0P&nT1vER*9#?xlMEq-lmKLG

    m(6ylt5r5!FY+o1+X0^J@82n8%m9$yB~$a^FcAPHi<&PhfP z)M2!>yPM6d9|nK0^pVl`?L{EwUCuDHuL2ee*Q|sMC}2DL-y%OMneytn4qe-cR*@ z7Dt|C40tKw8B2g<&wukkBNjZh2?tx^fZ-NVW(63)6%b|&)i+n*u4xlGy_t{TO9CWU zB}Q$5FFw5q?|#q-giUvcx@-ai>Kuhd&uqr@iN&ZGTi`nq_>ut0v9>K0j<+Phf(A^J z>mBF)({z2K!{r+cPFn&^QQJN4vsmCu0#FB`#Hlc7;?Y|##^R?pA<<_0d(C)n5EA!Y zHyP8<8Krwasfd1pF9|@PteHB?9n&UY#-vd=SlHE+uOX2 z`-T|43;`oX2~4?hmqbN*A;uL1(VB3Eh2Xv=z(`U8Q-QV&9@+TJ$aW0RRsv*$5~z|JM*H|0vyuRQR|1)5{F1PRY=v(LkPS*8^VB>V zrL6q&RE+Xu-E8s%z9c|aDS@2f|K2!&Zxw~`%iE{uvAv(Cf3t!B*`frh4mFSCP*co3 znAQb$*&1Y+iv)d1fNW9%g;Y3eOa%Wi?;@`dl=Wb2*`+Lq5?Z=7qxUoo0V&);!ekWo z=oa8ZH!a{x0%VU8$h4Yw$@n6Si3Gf))q6^B03-o!0Cd;gF1%3jCigi?aOoDm;S#i~ zFr0Xhc8&&E0tTmhh>!-t&P)Pin-VBH>ire;?OC|#8eR$5M>fGexB-a+e}RnFLDcVn zqSv@z5P{MQ@V&BTyf7_@jZJ4`Q^OdvMk4?hFw8_oiQsDjWQ!8WliS`aGOD4CFWZiy zee*96+f@UnZYPx8B?6q(>al$raYYE2GX;ptt@xlVf|rg?#M1XZhIi`A0MmhC*bp5z zcsYXzkPS*8y7YQ-?`<0&LHm}a5RFH*v6;odXuiKA`Sa?)Jcq%89BoRWB~G~jP@dO@ zd(Yp6znQoj|8rn69@#wwe{CEC9*pYK)F65T-xDAklt6ghI`RGsXnyhUU?1579t1cI zU3CIT59id=Iz|FQ8$OvoBqT#L4wy{h?(=rz=JD_2Uw2Hy@^>!<3<1u?poIiq6Tq)Z zAnREAMLn7!t{4ioejm-XOZ4+RLWUQBbO!kT3c5lJ!0|Xk$VlL4m#@c_rHAprra5S` zgW!fUC?xPL0sO24D$ht1m>iXd9~b=yUvT~iWpTF`576*Efrf@c0mot-UzxNQ6C=&| z{9kTDlM@&e68Mq;{#OE(_fH7_Ui5u@iM@!1fCWx&J_8J;wy?Owp(w|!(I@cyoZn;q z+IeVn0$|J?E{-Sy_*n^5q)|X1A&T+sqQ~%H;fpvZOqi7BXBYu4aXij(b?FH#oBk)v zUwbpSDYSQLX=yYpBIm|UVh!SJ1r zZox}O&cU|&vEZgXpnSl$1n{*Ixar?;tmy;TVG}&vgpUj_1XR>Cc>j&4Kj-RB|dKXg?>Z-OY*=%CSNxNhuW%o?{J>yJ!=VZ|Z(=>>dG z0ADD93}Bwp7u))Cps5ZlVz}yGpXZSw8B{_dGUI-Pr#}dzVEn1Cy9CIeQ;q2MU!ir) zLy(DPFpIk@M^h%Gz&9`0j15O84k$eO{)Q3B+p>E%=N_I10Ik)DIHEhB0=H84(5}WW z`s^|kT=!Gg_(U2C8{|EbVv%&f0)+@qy&olCd=We}N}s2vjr)MX6j~gH*<+62yrL73 z!s@p{eeD5;TM1;zz5YxfFJK{Y_-#0Kdv&}{nXoAiWeXJDv<37GthgeA>vSF`IV!OU>Tzu(2sx|z*kBj1L|xWi=NqnkDmKmToeM@ zbp=mvuV*@Tvq(PjXI6QIADuge($gzfzlz_y@+Mkh3EViX0*gO)4GO}cE+lXeyr2?+ ziT{bj{*77))8V2APz6y;03RX- z7j8hs9fU8w1uU=BnzkDMml$GeL|K{tb~mglg_XCS)k1?|3jZDm*Kfv7u#==K%Jn{uBP zcW8W1UT;)E7|Z_YF0{uINZ1aF3&P#*9;T&XZo)aX1@ZS@(iOvfsg5)S95fYnHIGJH zJk;-L;U2nS=5Aj}4ZuwU(S{bR*|OVB@={EA_yWvrP;0^*H@8bUZ!f_D9L+C32C7#AMSv%qh9am-6W*^(Y(=Xbz`A~37)F|Q!>*l z0UVpCozcbay6u7%9{%;8bVKN4!#~%X0&X)|V=sqMJl0iS`pVAf`)~zix1#>nH^5Gk zKnXCd{nPX|#cHYDH?0foFn+uN_0Qb^`_Q(Oz2Chug?0hFa{O$)lBiE9*u7?}X9BpH z1Fbwb;Lbv2#wvR9BOJmzJNM$-Kd*sl7`g{gPw*`d81+s87A!*Wym^p`t`!}?%Vh@UWk|q+Z&6q zwQd}kNvnr?Bmim548n=RaX!>XDk@IOW~!<UZPB6PJNA;d#?kE|;c}r9M3{VJ7k}y%XlxDKLxA)6Fm1 z4#PR|4q`k08}Yq=gi1KB_MfWOK+gnnj1EZL@x~mi`s*by0@2=T16=|r*?>fJ5;i{F zoSf|JOagaaoE(AdhQY7xoRY~5B+au2EmZ&XIjq~f2M>PbGnjelWJG9ntS@2epMlbg zP(I@>9ACQ(GGwII2FQgQOwZ~iKG=Z7{ta&VSJ=Iah>Ex9nL`4jBtF2?GCn1G1*XK7 z#zOq=gA2i}c<+IbKP_3n8hWe&`C@I_z!{RN4Gw6WGK6q7f9d;&P2+Y$;9Hxn!J6Zx7!#ypnO8Q+_z^>) z&JN(t*RDrnJkOPW`#MQMz#wNX)?(e`@43l-=e$w(tFEo~2Ij^}2s;ku${rc}Mp+7_ zYsCbb?0{aVxxTIxV?!_}@ck>$t2d_y%zU2l^a(zcC?+ zJ#B^B_*?7BVFoGgr}8=^x|keJd^b$zH#+;Tt?rg^oca!~KtOY_KY*|uuQWO51yYdU z3v1`$p|>yBg)GGZ)nT}Sg3i}2-&Fejp4xXFu6_A)*jhhMCw)#jA%`{`Gc4-FKSb@X zVwgW)D|}sF((@IP7pSQbn7#04u=2i!MAQL0d1sp(l5lGUNZ885)G;68yB~W~muXX? z-|D!rn|15}a;jMCT7>$X`z%SrR0B&&@Rr6B{Akxy{AT}!dat0gcL$-^Z(0U~y&3r* zUWw_`ZB$pEqFnX%P7GC53@;vtz%aIe@sB__R27j;bGSnegGmU7sz!lp#vQ=@7i`7s z(nBZ=NNrrwduSSgYWTXsb{=#e0`v)q%;d?ErWn?(RI7_D+;c z=o&gZF%7S-*xaG?o`l|dwHpRDtZ2ckJH7-Xv>uEq^i@x^k>JJ|1t@jfEsKS#d4$ z^e>z!2UebiMC${1^~ufJqgQv2{YmuwuE|g^YvDbxLQ7%C9WZ~_5&8v2a>q}{1O#mr zrjQqkB5c^0T+paDfYR=sDeP@6LW>etd(f)e)rXWq(&8M5HmZy<`RF|RzU6_yan-WCqtCnnT)1c10pX*9Uor0gtmO+b0C_OgM=ZaGdL~?wJN{FPxh6os9mt zad%{(YX%nG0L7n#X_do{k^fSetR2Xq2e<;Agc&d)^?LJvz}jVxx=*HOd<-a#&z)XI*IVN0B!*{3*FT$RP0FCq_U*^*GJGrgH$i*)eRCH zhlb&=fU&0#-v1m{ua4{Ey@)(%eavZJsAk?OHxD4pU34~-F%44A0WcLxR)Dbx3@fk8 zg6kZ5h1W)IO=dF~e-B{W!PQy__Bz%*wzK1Dx93p!V+K?)O^O`SM-5HO_b;Q*AqVsq{|}(`NEOe?t2F=s002ovPDHLk FV1nE|XcYhe literal 0 HcmV?d00001 diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 9dd18d0..4c0768d 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -7,31 +7,851 @@ "": { "name": "vscode-py-doc-preview", "version": "1.0.0", + "dependencies": { + "js-yaml": "^4.1.0" + }, "devDependencies": { + "@eslint/js": "^9.39.1", + "eslint": "^9.39.1", + "globals": "^16.5.0", "markdown-it": "^14.1.0" }, "engines": { - "vscode": "^1.75.0" + "vscode": "^1.75.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", + "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", + "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.1", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "Python-2.0" + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, "engines": { - "node": ">=0.12" + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" } }, "node_modules/linkify-it": { @@ -54,6 +874,29 @@ "uc.micro": "^2.0.0" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/markdown-it": { "version": "14.3.3", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", @@ -89,6 +932,136 @@ "dev": true, "license": "MIT" }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -99,12 +1072,133 @@ "node": ">=6" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true, "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/editors/vscode/package.json b/editors/vscode/package.json index c6faeeb..04d9ec1 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -4,13 +4,24 @@ "description": "Preview py-doc Markdown in VS Code: numbered captions, Word-style callouts, slide decks, and posters.", "version": "1.0.0", "publisher": "py-doc", + "license": "MIT", + "icon": "images/icon.png", + "repository": { + "type": "git", + "url": "https://github.com/markbac/py-doc.git", + "directory": "editors/vscode" + }, + "bugs": { + "url": "https://github.com/markbac/py-doc/issues" + }, + "homepage": "https://github.com/markbac/py-doc/tree/main/editors/vscode#readme", "engines": { "vscode": "^1.75.0" }, "main": "./extension.js", "categories": [ "Other", - "Formatters" + "Visualization" ], "keywords": [ "py-doc", @@ -68,8 +79,16 @@ "properties": { "pydoc.watch.port": { "type": "number", - "default": 8765, - "description": "The local port the live preview watch server (py-doc ... --watch) serves on." + "default": 0, + "minimum": 0, + "maximum": 65535, + "description": "The local port the live preview serves on. 0 lets py-doc pick a free one, so several windows can each have a preview." + }, + "pydoc.executable": { + "type": "string", + "default": "py-doc", + "scope": "machine-overridable", + "description": "The py-doc program the live preview runs, such as the py-doc of a virtual environment (.venv/Scripts/py-doc.exe or .venv/bin/py-doc)." } } }, @@ -79,9 +98,16 @@ ] }, "scripts": { - "test": "node test/test.js" + "test": "node --test test/test.js test/cases.test.js test/watch.test.js", + "lint": "eslint ." }, "devDependencies": { + "@eslint/js": "^9.39.1", + "eslint": "^9.39.1", + "globals": "^16.5.0", "markdown-it": "^14.1.0" + }, + "dependencies": { + "js-yaml": "^4.1.0" } } diff --git a/editors/vscode/src/detect.js b/editors/vscode/src/detect.js new file mode 100644 index 0000000..450eb62 --- /dev/null +++ b/editors/vscode/src/detect.js @@ -0,0 +1,43 @@ +'use strict'; + +const { asText } = require('./text'); +const { splitFrontMatter } = require('./frontmatter'); + +// Front matter keys only a poster has (see py_doc/poster_settings.py) +const POSTER_KEYS = ['paper', 'byline', 'orientation', 'theme', 'columns', 'width', 'colours', 'palette', 'embed_fonts', 'logo']; +const SLIDE_DIVS = /^:{3,4}\s*\{?\.?(columns|column|notes|incremental)\}?\s*$/; + +/** + * Which py-doc command a Markdown file is for: 'poster', 'slides' or 'docx'. + * Decided from the front matter, then from blocks only a deck has (columns, speaker notes, incremental lists). + * The instant preview and the live watch button both use this, so they always agree. + */ +function detectFormat(meta, body) { + meta = meta || {}; + const fmt = asText(meta.format || meta.output || meta.type || meta.layout).toLowerCase(); + if (fmt.includes('pptx') || fmt.includes('slide') || meta.split_level) return 'slides'; + if (fmt.includes('poster') || POSTER_KEYS.some((k) => meta[k] !== undefined && meta[k] !== '')) return 'poster'; + + let fence = null; + for (const line of (body || '').split(/\r?\n/)) { + const trimmed = line.trim(); + const fenceMatch = trimmed.match(/^(`{3,}|~{3,})/); + if (fenceMatch) { + if (!fence) fence = fenceMatch[1][0]; + else if (trimmed[0] === fence) fence = null; + continue; + } + if (fence) continue; + if (SLIDE_DIVS.test(trimmed) || /^) + * - Incremental lists (::: incremental) + * 3. HTML & PDF Posters (py-doc poster): + * - Stat strip (## ... {.stats}) with KPI cards & trend indicators + * - Abstract introduction banner (## ... {.abstract}) + * - Headings with colon accent (## 1. Title: Accent) and class stripping + * - Card grid blocks (```cards cols=N style=...```) + * - Segments bar blocks (```segments```) + * - Flow sequence blocks (```flow```) + * - Summary closing banner (## ... {.summary}) + * - Poster header & footer (paper size, byline, QR code label) + */ + +'use strict'; + +const { asText, cssName, escapeHtml } = require('./text'); +const { parseFrontMatter, splitFrontMatter } = require('./frontmatter'); +const { detectFormat } = require('./detect'); + +const KINDS = { + NOTE: { title: 'Note', icon: 'ℹ️', class: 'note' }, + TIP: { title: 'Tip', icon: 'πŸ’‘', class: 'tip' }, + IMPORTANT: { title: 'Important', icon: '❗', class: 'important' }, + WARNING: { title: 'Warning', icon: '⚠️', class: 'warning' }, + CAUTION: { title: 'Caution', icon: 'πŸ›‘', class: 'caution' }, +}; + +const DIAGRAM_TYPES = new Set([ + 'mermaid', 'plantuml', 'puml', 'dot', 'graphviz', 'ditaa', + 'blockdiag', 'seqdiag', 'actdiag', 'nwdiag', 'packetdiag', + 'wavedrom', 'bytefield-svg', 'pikchr', 'railroad', 'wireviz', + 'vega', 'vega-lite' +]); + +function renderFrontMatterBlock(meta, docMode) { + if (!meta || Object.keys(meta).length === 0) return ''; + + if (docMode === 'poster') { + let html = '

    \n'; + if (meta.title) { + html += `

    ${escapeHtml(meta.title)}

    \n`; + } + if (meta.subtitle) { + html += `
    ${escapeHtml(meta.subtitle)}
    \n`; + } + if (meta.byline) { + html += ` \n`; + } + html += '
    \n'; + if (meta.paper) { + html += ` Paper: ${escapeHtml(asText(meta.paper).toUpperCase())}\n`; + } + if (meta.document_number) { + html += ` Doc #: ${escapeHtml(meta.document_number)}\n`; + } + if (meta.version) { + html += ` Version: ${escapeHtml(meta.version)}\n`; + } + html += '
    \n'; + html += '
    \n'; + return html; + } + + const isSlide = (docMode === 'slide'); + let html = `
    \n`; + if (meta.title) { + html += `

    ${escapeHtml(meta.title)}

    \n`; + } + if (meta.subtitle) { + html += `
    ${escapeHtml(meta.subtitle)}
    \n`; + } + html += '
    \n'; + if (meta.document_number) { + html += ` Doc #: ${escapeHtml(meta.document_number)}\n`; + } + if (meta.version) { + html += ` Version: ${escapeHtml(meta.version)}\n`; + } + if (meta.status) { + const statusClass = asText(meta.status).toLowerCase() === 'draft' ? 'draft' : 'released'; + html += ` Status: ${escapeHtml(meta.status)}\n`; + } + if (meta.author) { + html += ` Author: ${escapeHtml(meta.author)}\n`; + } + if (meta.date) { + html += ` Date: ${escapeHtml(meta.date)}\n`; + } + html += '
    \n'; + html += '
    \n'; + return html; +} + +const PREVIEW_MODE = { poster: 'poster', slides: 'slide', docx: 'document' }; + +function pyDocPlugin(md) { + // The front matter token carries the mode of its document in .info, so a nested md.renderInline call + // (made while processing captions and callouts) cannot change how the outer document's header renders + md.renderer.rules.pydoc_front_matter = (ts, idx) => renderFrontMatterBlock(ts[idx].meta, ts[idx].info || 'document'); + + // 1. Block rule to parse Front Matter at line 0 + md.block.ruler.before('table', 'pydoc_front_matter', (state, startLine, endLine, silent) => { + if (startLine !== 0) return false; + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + if (state.src.slice(start, max).trim() !== '---') return false; + + let nextLine = startLine + 1; + let found = false; + while (nextLine < endLine) { + const s = state.bMarks[nextLine] + state.tShift[nextLine]; + const m = state.eMarks[nextLine]; + if (state.src.slice(s, m).trim() === '---') { + found = true; + break; + } + nextLine++; + } + if (!found) return false; + if (silent) return true; + + const yamlContent = state.src.slice(state.eMarks[startLine] + 1, state.bMarks[nextLine]); + state.line = nextLine + 1; + + const token = state.push('pydoc_front_matter', '', 0); + token.meta = parseFrontMatter(yamlContent); + return true; + }); + + // 2. Block rule for fenced divs (:::: columns, ::: column, ::: notes, ::: incremental) + md.block.ruler.before('fence', 'pydoc_fenced_div', (state, startLine, endLine, silent) => { + const start = state.bMarks[startLine] + state.tShift[startLine]; + const max = state.eMarks[startLine]; + const lineText = state.src.slice(start, max).trim(); + + const openMatch = lineText.match(/^(:{3,4})\s*(?:\{\.([\w-]+)\}|([\w-]+))?(?:\s+(.*))?$/); + if (!openMatch) return false; + + const colons = openMatch[1]; + const tag = (openMatch[2] || openMatch[3] || '').trim().toLowerCase(); + const isClosing = !tag && lineText === colons; + + if (isClosing) return false; + if (silent) return true; + + let nextLine = startLine + 1; + let found = false; + let depth = 1; + + while (nextLine < endLine) { + const s = state.bMarks[nextLine] + state.tShift[nextLine]; + const m = state.eMarks[nextLine]; + const cur = state.src.slice(s, m).trim(); + + if (cur.startsWith(colons)) { + const subMatch = cur.match(/^(:{3,4})\s*(?:\{\.([\w-]+)\}|([\w-]+))?/); + if (subMatch && subMatch[1] === colons) { + if (!subMatch[2] && !subMatch[3]) { + depth--; + if (depth === 0) { + found = true; + break; + } + } else { + depth++; + } + } + } + nextLine++; + } + + if (!found) return false; + + if (tag === 'pagebreak') { + const pbreak = state.push('html_block', '', 0); + pbreak.content = '
    PAGE BREAK
    \n'; + state.line = nextLine + 1; + return true; + } + + const tokenOpen = state.push('pydoc_div_open', 'div', 1); + tokenOpen.block = true; + tokenOpen.attrs = [['class', `pydoc-div pydoc-${tag}`]]; + tokenOpen.info = tag; + + if (tag === 'notes') { + const notesHeader = state.push('html_block', '', 0); + notesHeader.content = '
    πŸ“ Speaker Notes
    \n'; + } else if (tag === 'incremental') { + const incBadge = state.push('html_block', '', 0); + incBadge.content = '
    ⚑ Click-by-click list (incremental)
    \n'; + } + + state.line = startLine + 1; + const oldParentType = state.parentType; + state.parentType = 'div'; + state.md.block.tokenize(state, startLine + 1, nextLine); + state.parentType = oldParentType; + + const tokenClose = state.push('pydoc_div_close', 'div', -1); + tokenClose.block = true; + + state.line = nextLine + 1; + return true; + }); + + // 3. Core rule for py-doc markers, numbered captions, callouts, slides, and posters + md.core.ruler.push('pydoc_core', (state) => { + const counters = {}; + const tokens = state.tokens; + const frontMeta = (tokens.length > 0 && tokens[0].type === 'pydoc_front_matter' && tokens[0].meta) || null; + const docTitle = (frontMeta && asText(frontMeta.title)) || 'py-doc Document'; + const docMode = PREVIEW_MODE[detectFormat(frontMeta, splitFrontMatter(state.src).body)]; + if (frontMeta) tokens[0].info = docMode; + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + // A. Process Image width attributes: ![](img.png){width=60%} + if (token.type === 'inline' && token.children) { + for (let c = 0; c < token.children.length; c++) { + if (token.children[c].type === 'image') { + const nextChild = token.children[c + 1]; + if (nextChild && nextChild.type === 'text') { + const m = nextChild.content.match(/^\{width=([^}]+)\}/); + if (m) { + const widthVal = m[1].replace(/["']/g, '').trim(); + token.children[c].attrs = token.children[c].attrs || []; + token.children[c].attrs.push(['style', `width: ${widthVal}; max-width: 100%;`]); + nextChild.content = nextChild.content.replace(/^\{width=([^}]+)\}\s*/, ''); + } + } + } + } + } + + // B. Process HTML comments for speaker notes: + if (token.type === 'html_block') { + const commentNoteMatch = token.content.match(//i); + if (commentNoteMatch) { + const noteText = commentNoteMatch[1].trim(); + token.content = `
    πŸ“ Speaker Notes

    ${escapeHtml(noteText)}

    \n`; + continue; + } + } + + // C. Process Headings: {.stats}, {.abstract}, {.summary}, {.references}, and accent colons + if (token.type === 'heading_open') { + const next = tokens[i + 1]; + if (next && next.type === 'inline' && next.content) { + let text = next.content; + + const classMatch = text.match(/\s*\{([.#][\w\s.-]+)\}\s*$/); + let headingClass = ''; + if (classMatch) { + headingClass = classMatch[1].replace(/^\./, '').trim(); + text = text.replace(/\s*\{([.#][\w\s.-]+)\}\s*$/, '').trim(); + token.attrs = token.attrs || []; + token.attrs.push(['class', `pydoc-heading-${headingClass}`]); + } + + next.content = text; + if (classMatch) { + for (const c of next.children) { + if (c.type === 'text') { + c.content = c.content.replace(/\s*\{([.#][\w\s.-]+)\}\s*$/, ''); + } + } + } else if (docMode === 'poster') { + // Poster headings: what follows the first colon is the accent. The inline tokens are split, not + // re-rendered, so code, bold and links after the colon keep their formatting + const children = next.children || []; + const k = children.findIndex((c) => c.type === 'text' && c.content.indexOf(':') > 0); + if (k !== -1) { + const colon = children[k].content.indexOf(':'); + const rest = children[k].content.slice(colon + 1); + children[k].content = children[k].content.slice(0, colon + 1); + const open = new state.Token('html_inline', '', 0); + open.content = ''; + const restToken = new state.Token('text', '', 0); + restToken.content = rest; + const close = new state.Token('html_inline', '', 0); + close.content = ''; + children.splice(k + 1, 0, open, restToken); + children.push(close); + } + } + + if (headingClass === 'stats') { + let tableOpenIdx = -1; + let tableCloseIdx = -1; + for (let j = i + 1; j < tokens.length; j++) { + if (tokens[j].type === 'table_open') tableOpenIdx = j; + if (tokens[j].type === 'table_close') { + tableCloseIdx = j; + break; + } + if (tokens[j].type === 'heading_open') break; + } + + if (tableOpenIdx !== -1 && tableCloseIdx !== -1) { + const rows = []; + let curRow = null; + let inTbody = false; + + for (let k = tableOpenIdx; k <= tableCloseIdx; k++) { + if (tokens[k].type === 'tbody_open') inTbody = true; + if (tokens[k].type === 'tbody_close') inTbody = false; + if (inTbody) { + if (tokens[k].type === 'tr_open') curRow = []; + if (tokens[k].type === 'tr_close' && curRow) { + rows.push(curRow); + curRow = null; + } + if (tokens[k].type === 'inline' && curRow) { + curRow.push(tokens[k].content); + } + } + } + + let statsHtml = '
    \n'; + for (const r of rows) { + const val = r[0] || ''; + const label = r[1] || ''; + const change = r[2] || ''; + const verdict = (r[3] || '').toLowerCase().trim(); + const changeClass = verdict ? verdict : (change.startsWith('+') ? 'good' : (change.startsWith('-') ? 'bad' : '')); + + statsHtml += '
    \n'; + statsHtml += `
    ${escapeHtml(val)}
    \n`; + statsHtml += `
    ${escapeHtml(label)}
    \n`; + if (change) { + statsHtml += `
    ${escapeHtml(change)}
    \n`; + } + statsHtml += '
    \n'; + } + statsHtml += '
    \n'; + + const replacement = new state.Token('html_block', '', 0); + replacement.content = statsHtml; + tokens.splice(tableOpenIdx, (tableCloseIdx - tableOpenIdx + 1), replacement); + } + } + } + } + + // D. Fenced Blocks: ```cards```, ```segments```, ```flow```, and diagram types + if (token.type === 'fence') { + const rawInfo = token.info.trim(); + const mainTag = rawInfo.split(/\s+/)[0].toLowerCase(); + + // Cards: ```cards cols=3 style=top``` + if (mainTag === 'cards') { + const lines = token.content.split('\n').filter(l => l.trim()); + let cardsHtml = '
    \n'; + for (const line of lines) { + const parts = line.split('|').map(s => s.trim()); + if (parts.length >= 2) { + const title = parts[0]; + const desc = parts[1]; + const color = parts[2] || 'blue'; + cardsHtml += `
    \n`; + cardsHtml += `
    ${escapeHtml(title)}
    \n`; + cardsHtml += `
    ${escapeHtml(desc)}
    \n`; + cardsHtml += '
    \n'; + } + } + cardsHtml += '
    \n'; + token.type = 'html_block'; + token.content = cardsHtml; + continue; + } + + // Segments: ```segments``` + if (mainTag === 'segments') { + const lines = token.content.split('\n').filter(l => l.trim()); + let segTitle = ''; + const items = []; + for (const line of lines) { + if (line.toLowerCase().startsWith('title:')) { + segTitle = line.replace(/^title:\s*/i, ''); + } else { + const parts = line.split('|').map(s => s.trim()); + if (parts.length >= 2) { + items.push({ + weight: parts[0], + label: parts[1], + value: parts[2] || '', + color: parts[3] || 'blue' + }); + } + } + } + + let segHtml = '
    \n'; + if (segTitle) { + segHtml += `
    ${escapeHtml(segTitle)}
    \n`; + } + segHtml += '
    \n'; + for (const it of items) { + segHtml += `
    ${escapeHtml(it.label)}
    \n`; + } + segHtml += '
    \n'; + segHtml += '
    \n'; + token.type = 'html_block'; + token.content = segHtml; + continue; + } + + // Flow: ```flow``` + if (mainTag === 'flow') { + const lines = token.content.split('\n').filter(l => l.trim()); + let flowHtml = '
    \n'; + for (const line of lines) { + const parts = line.split(/-(.*?)->/).map(s => s.trim()); + flowHtml += '
    \n'; + for (let p = 0; p < parts.length; p++) { + const item = parts[p]; + if (p % 2 === 0) { + const nodeMatch = item.match(/^\[(.*?)(?:\|(.*?))?\]$/); + const nodeText = nodeMatch ? nodeMatch[1] : item; + const nodeColor = (nodeMatch && nodeMatch[2]) ? nodeMatch[2] : 'blue'; + flowHtml += ` ${escapeHtml(nodeText)}\n`; + } else { + flowHtml += ` βž” ${escapeHtml(item)}\n`; + } + } + flowHtml += '
    \n'; + } + flowHtml += '
    \n'; + token.type = 'html_block'; + token.content = flowHtml; + continue; + } + + // Other py-doc diagram types (plantuml, dot, ditaa, wavedrom, vega, bytefield, etc.) + if (DIAGRAM_TYPES.has(mainTag) && mainTag !== 'mermaid') { + const titleMatch = rawInfo.match(/(?:title|caption)=["']?([^"']+)["']?/i); + const diagTitle = titleMatch ? titleMatch[1] : ''; + const escapedContent = md.utils.escapeHtml(token.content); + + token.type = 'html_block'; + token.content = `
    \n` + + `
    ${mainTag.toUpperCase()}${diagTitle ? ` ${escapeHtml(diagTitle)}` : ''}
    \n` + + `
    ${escapedContent}
    \n` + + `
    \n`; + continue; + } + } + + // E. Process Paragraphs containing py-doc markers + if (token.type === 'paragraph_open') { + const next = tokens[i + 1]; + if (next && next.type === 'inline' && next.content) { + const rawLines = next.content.split('\n'); + let hasMarkers = false; + + for (const line of rawLines) { + const trimmed = line.trim(); + if ( + trimmed === '[[PAGEBREAK]]' || + trimmed === '::: {.pagebreak}' || + /^\[\[(LANDSCAPE|PORTRAIT)\]\]$/i.test(trimmed) || + /^\[\[CAPTION:[A-Za-z0-9 _-]+\]\]/i.test(trimmed) || + /^\[\[STYLE:[A-Za-z0-9 _-]+\]\]/i.test(trimmed) || + /^\[\[(INCLUDE|INCLUDE-CODE|IF|NOT|ENDIF)(?::.*)?\]\]$/i.test(trimmed) + ) { + hasMarkers = true; + break; + } + } + + if (hasMarkers) { + let renderedHtml = ''; + for (const line of rawLines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Page Break + if (trimmed === '[[PAGEBREAK]]' || trimmed === '::: {.pagebreak}') { + renderedHtml += '
    PAGE BREAK
    \n'; + continue; + } + + // Orientation + const orientMatch = trimmed.match(/^\[\[(LANDSCAPE|PORTRAIT)\]\]$/i); + if (orientMatch) { + const orient = orientMatch[1].toUpperCase(); + renderedHtml += `
    SECTION BREAK (${orient})
    \n`; + continue; + } + + // Directives + const directiveMatch = trimmed.match(/^\[\[(INCLUDE|INCLUDE-CODE|IF|NOT|ENDIF)(?::\s*([^\]]+))?\]\]$/i); + if (directiveMatch) { + const dType = directiveMatch[1].toUpperCase(); + const dArg = (directiveMatch[2] || '').trim(); + renderedHtml += `
    ${escapeHtml(dType)} ${escapeHtml(dArg)}
    \n`; + continue; + } + + // Numbered Caption + const capMatch = trimmed.match(/^\[\[CAPTION:([A-Za-z0-9 _-]+)\]\](?:\s*(.*))?$/); + if (capMatch) { + const label = capMatch[1].trim(); + const text = (capMatch[2] || '').trim(); + counters[label] = (counters[label] || 0) + 1; + const num = counters[label]; + renderedHtml += `
    ${escapeHtml(label)} ${num}: ${md.renderInline(text)}
    \n`; + continue; + } + + // Style + const styleMatch = trimmed.match(/^\[\[STYLE:([A-Za-z0-9 _-]+)\]\](?:\s*(.*))?$/); + if (styleMatch) { + const styleName = styleMatch[1].trim(); + const text = (styleMatch[2] || '').trim(); + renderedHtml += `
    ${escapeHtml(styleName)}${md.renderInline(text)}
    \n`; + continue; + } + + renderedHtml += `

    ${md.renderInline(line)}

    \n`; + } + + tokens[i] = new state.Token('html_block', '', 0); + tokens[i].content = renderedHtml; + tokens.splice(i + 1, 2); + continue; + } + } + } + + // F. Process Blockquotes for Callouts / Admonitions + if (token.type === 'blockquote_open') { + let firstInlineIdx = -1; + for (let j = i + 1; j < tokens.length; j++) { + if (tokens[j].type === 'blockquote_close') break; + if (tokens[j].type === 'inline') { + firstInlineIdx = j; + break; + } + } + + if (firstInlineIdx !== -1) { + const inlineToken = tokens[firstInlineIdx]; + const rawContent = inlineToken.content.trim(); + const ghMatch = rawContent.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i); + const boldMatch = rawContent.match(/^\*\*(Note|Tip|Important|Warning|Caution)\s*:\*\*/i) + || rawContent.match(/^\*\*(Note|Tip|Important|Warning|Caution)\*\*\s*:/i); + + const kindRaw = ghMatch ? ghMatch[1] : (boldMatch ? boldMatch[1] : null); + + if (kindRaw) { + const kindKey = kindRaw.toUpperCase(); + const info = KINDS[kindKey] || { title: kindKey, icon: 'ℹ️', class: 'note' }; + + token.attrs = [['class', `pydoc-callout pydoc-callout-${info.class}`]]; + + let bodyText = rawContent; + if (ghMatch) { + bodyText = bodyText.replace(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i, ''); + } else if (boldMatch) { + bodyText = bodyText.replace(/^\*\*(Note|Tip|Important|Warning|Caution)\s*:\*\*\s*/i, '') + .replace(/^\*\*(Note|Tip|Important|Warning|Caution)\*\*\s*:\s*/i, ''); + } + + const headerBlock = new state.Token('html_block', '', 0); + headerBlock.content = `
    ${info.icon}${info.title}
    \n`; + + const pOpenIdx = firstInlineIdx - 1; + tokens.splice(pOpenIdx, 0, headerBlock); + i++; + + inlineToken.content = bodyText; + if (!bodyText) { + tokens.splice(pOpenIdx + 1, 3); + } else { + inlineToken.children = [new state.Token('html_inline', '', 0)]; + inlineToken.children[0].content = md.renderInline(bodyText); + } + } + } + } + } + + // G. If in Slide Deck mode, group tokens into presentation slide cards + if (docMode === 'slide') { + groupTokensIntoSlides(state, docTitle, Number.parseInt(asText(frontMeta && frontMeta.split_level), 10) || 2); + } + + // H. If in Poster mode, append poster footer if defined + if (docMode === 'poster' && frontMeta && frontMeta.footer) { + const footer = frontMeta.footer; + let footerHtml = '
    \n'; + footerHtml += ` \n`; + footerHtml += ` \n`; + if (footer.qr || footer['qr-label']) { + footerHtml += `
    πŸ“± ${escapeHtml(footer['qr-label'] || 'QR Code')}
    \n`; + } + footerHtml += '
    \n'; + + const footToken = new state.Token('html_block', '', 0); + footToken.content = footerHtml; + tokens.push(footToken); + } + }); +} + +// A heading at splitLevel or above starts a slide, as pandoc's --slide-level does; one above it is a section slide +function groupTokensIntoSlides(state, docTitle, splitLevel) { + const tokens = state.tokens; + const newTokens = []; + let currentSlideTokens = []; + let slideNum = 0; + let isCurrentSection = false; + + function flushSlide(isSection) { + if (currentSlideTokens.length === 0) return; + slideNum++; + const cardOpen = new state.Token('html_block', '', 0); + const slideClass = isSection ? 'pydoc-slide pydoc-slide-section' : 'pydoc-slide pydoc-slide-content'; + cardOpen.content = `
    \n`; + newTokens.push(cardOpen); + + for (const t of currentSlideTokens) { + newTokens.push(t); + } + + const cardClose = new state.Token('html_block', '', 0); + cardClose.content = `
    ${escapeHtml(docTitle)}Slide ${slideNum}
    \n`; + newTokens.push(cardClose); + currentSlideTokens = []; + } + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + + if (token.type === 'pydoc_front_matter') { + newTokens.push(token); + continue; + } + + if (token.type === 'heading_open') { + const level = parseInt(token.tag.slice(1), 10); + if (level <= splitLevel) { + flushSlide(isCurrentSection); + isCurrentSection = (level < splitLevel); + } + } + + if (token.type === 'html_block' && token.content && token.content.includes('pydoc-pagebreak')) { + flushSlide(isCurrentSection); + isCurrentSection = false; + continue; + } + + currentSlideTokens.push(token); + } + + flushSlide(isCurrentSection); + state.tokens = newTokens; +} + +module.exports = { pyDocPlugin }; diff --git a/editors/vscode/src/text.js b/editors/vscode/src/text.js new file mode 100644 index 0000000..4215a9e --- /dev/null +++ b/editors/vscode/src/text.js @@ -0,0 +1,25 @@ +'use strict'; + +// Front matter values may be lists (author: [- Jane, - Joe]) or mappings, so anything is turned into text first +function asText(val) { + if (val === null || val === undefined) return ''; + if (Array.isArray(val)) return val.map(asText).join(', '); + if (typeof val === 'object') return Object.values(val).map(asText).join(', '); + return String(val); +} + +// A colour name written in the Markdown, made safe to use as part of a class name +function cssName(val) { + return asText(val).toLowerCase().replace(/[^a-z0-9_-]/g, ''); +} + +function escapeHtml(val) { + const str = asText(val); + if (!str) return ''; + return str.replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +module.exports = { asText, cssName, escapeHtml }; diff --git a/editors/vscode/src/watch.js b/editors/vscode/src/watch.js new file mode 100644 index 0000000..6af92c8 --- /dev/null +++ b/editors/vscode/src/watch.js @@ -0,0 +1,121 @@ +'use strict'; + +// The live preview: runs `py-doc FILE --watch --no-open` as a child process, shows what it prints +// in an output channel, reads the address it serves from its "serving http://..." line, and opens that in +// VS Code's Simple Browser. py-doc picks a free port unless the pydoc.watch.port setting names one, so two +// windows never fight over a port. One preview runs at a time: starting another stops it first. + +const path = require('path'); +const { spawn, execFile } = require('child_process'); + +const START_TIMEOUT = 180000; // ms: a first build may have to start the tool container +const STOP_TIMEOUT = 5000; +const SERVING = /^serving (http:\/\/127\.0\.0\.1:\d+\/)/m; + +class LiveWatch { + /** + * @param vscode the vscode module + * @param options.command the program and its first arguments, in place of the pydoc.executable setting (tests) + */ + constructor(vscode, options = {}) { + this.vscode = vscode; + this.command = options.command || null; + this.child = null; + this.output = vscode.window.createOutputChannel('py-doc live preview'); + } + + get running() { + return this.child !== null; + } + + /** Start the preview of filePath with py-doc's `command` (docx, slides or poster). Resolves to its address, or null. */ + async start(command, filePath, cwd) { + await this.stop(); + const config = this.vscode.workspace.getConfiguration('pydoc'); + const [program, ...first] = this.command || [config.get('executable', 'py-doc')]; + const port = config.get('watch.port', 0); + const args = [...first, command, filePath, '--watch', '--no-open', ...(port ? ['--port', String(port)] : [])]; + + this.output.clear(); + this.output.appendLine(`> ${program} ${args.join(' ')}`); + const child = spawn(program, args, { + cwd: cwd || path.dirname(filePath), + env: { ...process.env, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' }, + windowsHide: true, + }); + this.child = child; + + const started = new Promise((resolve) => { + let seen = ''; + const timer = setTimeout(() => resolve(null), START_TIMEOUT); + const done = (address) => { + clearTimeout(timer); + resolve(address); + }; + child.stdout.on('data', (chunk) => { + const text = chunk.toString(); + this.output.append(text); + if (seen !== null) { + seen += text; + const m = seen.match(SERVING); + if (m) { + seen = null; // found: the rest of the output is only shown + done(m[1]); + } + } + }); + child.stderr.on('data', (chunk) => this.output.append(chunk.toString())); + child.on('error', (err) => { + this.output.appendLine(`Cannot start ${program}: ${err.message}. Set pydoc.executable to the py-doc program.`); + done(null); + }); + child.on('exit', (code, signal) => { + this.output.appendLine(`py-doc stopped (${signal || `exit status ${code}`})`); + if (this.child === child) this.child = null; + done(null); + }); + }); + + const address = await this.vscode.window.withProgress( + { location: this.vscode.ProgressLocation.Window, title: `py-doc: building the ${command} preview` }, + () => started + ); + if (child.stoppedByUs) return null; // stopped, or replaced by another preview, while it started + if (address) { + await this.vscode.commands.executeCommand('simpleBrowser.show', address); + return address; + } + await this.stop(); + this.output.show(true); + this.vscode.window.showErrorMessage(`The py-doc ${command} preview did not start. See the "py-doc live preview" output.`); + return null; + } + + /** Stop the running preview, and its whole process tree. Resolves to whether one was running. */ + stop() { + const child = this.child; + this.child = null; + if (child) child.stoppedByUs = true; + if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve(false); + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(true), STOP_TIMEOUT); + child.once('exit', () => { + clearTimeout(timer); + resolve(true); + }); + if (process.platform === 'win32') { + // py-doc.exe is a launcher that starts Python: /T ends the tree, not only the launcher + execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {}); + } else { + child.kill('SIGTERM'); + } + }); + } + + dispose() { + this.stop(); + this.output.dispose(); + } +} + +module.exports = { LiveWatch, SERVING }; diff --git a/editors/vscode/test/cases.test.js b/editors/vscode/test/cases.test.js new file mode 100644 index 0000000..fb7e248 --- /dev/null +++ b/editors/vscode/test/cases.test.js @@ -0,0 +1,39 @@ +'use strict'; + +// The shared preview cases of tests/fixtures/preview/cases.json, through the instant preview. +// tests/test_preview_cases.py runs the same cases through the HTML twin that --watch builds, so the +// instant preview cannot drift from the build without one of the two failing. + +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const MarkdownIt = require('markdown-it'); +const { pyDocPlugin, detectDocumentFormat } = require('../extension'); + +const CASES = path.join(__dirname, '..', '..', '..', 'tests', 'fixtures', 'preview', 'cases.json'); +const { cases } = JSON.parse(fs.readFileSync(CASES, 'utf8')); + +const count = (html, needle) => html.split(needle).length - 1; + +for (const c of cases) { + test(c.name, () => { + // The build takes the split level from the command line; the instant preview from the front matter + const markdown = c.split_level ? `---\nsplit_level: ${c.split_level}\n---\n\n${c.markdown}` : c.markdown; + assert.strictEqual(detectDocumentFormat(markdown), c.command, 'detected command'); + + const html = new MarkdownIt({ html: true }).use(pyDocPlugin).render(markdown); + const expect = c.expect; + for (const kind of expect.callouts || []) { + assert.ok(html.includes(`pydoc-callout-${kind}`), `callout ${kind}`); + } + assert.strictEqual(count(html, 'class="pydoc-pagebreak"'), expect.pagebreaks || 0, 'page breaks'); + assert.strictEqual(count(html, '
    ({ + append: (t) => calls.output.push(t), + appendLine: (t) => calls.output.push(t + '\n'), + clear: () => {}, + show: () => {}, + dispose: () => {}, + }), + withProgress: (options, task) => task(), + showErrorMessage: (m) => calls.errors.push(m), + }, + workspace: { getConfiguration: () => ({ get: (key, fallback) => (key in settings ? settings[key] : fallback) }) }, + commands: { executeCommand: async (id, arg) => { if (id === 'simpleBrowser.show') calls.browser.push(arg); } }, + ProgressLocation: { Window: 10 }, + }; + return vscode; +} + +// A fake py-doc: prints what --watch prints, then keeps running (or fails, with FAIL set) +const script = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'pydoc-watch-')), 'fake-py-doc.js'); +fs.writeFileSync(script, ` +const args = process.argv.slice(2); +if (process.env.FAKE_FAIL) { console.error('Error: pandoc failed'); process.exit(1); } +const port = args.includes('--port') ? args[args.indexOf('--port') + 1] : '54321'; +console.log('ARGS ' + JSON.stringify(args)); +console.log('wrote ' + args[1]); +console.log('serving http://127.0.0.1:' + port + '/'); +setInterval(() => {}, 1000); +`); + +test('the serving line gives the address', () => { + assert.strictEqual('built in 1.2 s\nserving http://127.0.0.1:61234/\n'.match(SERVING)[1], 'http://127.0.0.1:61234/'); +}); + +test('a preview opens the address py-doc serves, and stops', async (t) => { + const vscode = fakeVscode(); + const live = new LiveWatch(vscode, { command: [process.execPath, script] }); + t.after(() => live.stop()); + const address = await live.start('docx', path.join(os.tmpdir(), 'guide.md')); + assert.strictEqual(address, 'http://127.0.0.1:54321/'); + assert.deepStrictEqual(vscode.calls.browser, ['http://127.0.0.1:54321/']); + assert.ok(live.running); + assert.strictEqual(await live.stop(), true); + assert.ok(!live.running); + assert.strictEqual(await live.stop(), false); +}); + +test('the port setting is passed on, and a new preview replaces the running one', async (t) => { + const vscode = fakeVscode({ 'watch.port': 8899 }); + const live = new LiveWatch(vscode, { command: [process.execPath, script] }); + t.after(() => live.stop()); + await live.start('docx', path.join(os.tmpdir(), 'a.md')); + const first = live.child; + await live.start('poster', path.join(os.tmpdir(), 'b.md')); + assert.ok(first.exitCode !== null || first.signalCode !== null, 'the first preview was stopped'); + assert.deepStrictEqual(vscode.calls.browser, ['http://127.0.0.1:8899/', 'http://127.0.0.1:8899/']); + const argsLine = vscode.calls.output.join('').split('\n').filter((l) => l.startsWith('ARGS ')).pop(); + assert.deepStrictEqual(JSON.parse(argsLine.slice(5)), ['poster', path.join(os.tmpdir(), 'b.md'), '--watch', '--no-open', '--port', '8899']); + await live.stop(); +}); + +test('a preview that fails says so, and opens nothing', async () => { + process.env.FAKE_FAIL = '1'; + try { + const vscode = fakeVscode(); + const live = new LiveWatch(vscode, { command: [process.execPath, script] }); + assert.strictEqual(await live.start('slides', path.join(os.tmpdir(), 'deck.md')), null); + assert.deepStrictEqual(vscode.calls.browser, []); + assert.strictEqual(vscode.calls.errors.length, 1); + assert.ok(vscode.calls.output.join('').includes('pandoc failed')); + assert.ok(!live.running); + } finally { + delete process.env.FAKE_FAIL; + } +}); + +test('a program that cannot be started says so', async () => { + const vscode = fakeVscode({ executable: path.join(os.tmpdir(), 'no-such-py-doc') }); + const live = new LiveWatch(vscode); + assert.strictEqual(await live.start('docx', path.join(os.tmpdir(), 'guide.md')), null); + assert.ok(vscode.calls.output.join('').includes('Cannot start')); + assert.strictEqual(vscode.calls.errors.length, 1); +}); diff --git a/src/py_doc/_cli.py b/src/py_doc/_cli.py index d2fa4d4..ef71f90 100644 --- a/src/py_doc/_cli.py +++ b/src/py_doc/_cli.py @@ -191,7 +191,7 @@ def build_all() -> tuple[dict[str, Path], set[Path]]: pages[name] = converter.convert_twin(src, base / PREVIEW_DIR / f"{name}.html").resolve() return pages, sources - from .poster_watch import watch + from .watch import watch return watch( build_all, diff --git a/src/py_doc/convert.py b/src/py_doc/convert.py index 8c6711b..8d03a7f 100644 --- a/src/py_doc/convert.py +++ b/src/py_doc/convert.py @@ -64,7 +64,8 @@ def convert_file(self, md_path: Path, output_path: Path) -> Path: def convert_twin(self, md_path: Path, output_path: Path) -> Path: """Write an HTML preview twin of the document or deck: the same Markdown, as a web page, for ``--watch``.""" - return self._render(md_path, output_path, "html", None, {**self.options, "preview": True}) + preview = "slides" if self.fmt == "pptx" else "document" + return self._render(md_path, output_path, "html", None, {**self.options, "preview": preview}) def _render(self, md_path: Path, output_path: Path, fmt: str, template: Path | None, options: dict) -> Path: md_path = Path(md_path).resolve() diff --git a/src/py_doc/pandoc/engine.py b/src/py_doc/pandoc/engine.py index e8441e7..8dc6c7f 100644 --- a/src/py_doc/pandoc/engine.py +++ b/src/py_doc/pandoc/engine.py @@ -160,8 +160,11 @@ def _command(job: PandocJob, runtime: Runtime, work: Path, reference: Path | Non elif fmt == "html": argv += ["--to=html5", "--standalone", "--embed-resources", "--mathml"] argv += ["--css", job.template if job.template is not None else _required(RESOURCES / "html.css", "stylesheet")] - if options.get("preview", False): # the HTML twin of a document or deck, for --watch + if options.get("preview"): # the HTML twin of a document or deck, for --watch argv += ["--css", _required(RESOURCES / "preview.css", "stylesheet")] + if options.get("preview") == "slides": # after pagebreak.lua, which the twin's slide breaks come from + argv += ["--lua-filter", _required(RESOURCES / "slides-preview.lua", "Lua filter")] + argv.append(f"--metadata=preview-slide-level:{options.get('split_level', 2)}") if options.get("toc", False): argv.append("--toc") else: # pdf diff --git a/src/py_doc/pandoc/resources/preview.css b/src/py_doc/pandoc/resources/preview.css index 3c04df4..055f204 100644 --- a/src/py_doc/pandoc/resources/preview.css +++ b/src/py_doc/pandoc/resources/preview.css @@ -1,5 +1,5 @@ /* Added after html.css for the HTML preview twin of `py-doc docx|slides --watch` only, so the twin shows - what a Word document or deck has that a web page does not: page breaks, speaker notes and columns. */ + what a Word document or deck has that a web page does not: page breaks, speaker notes, columns and slides. */ figure { margin: 1.5em 0; text-align: center; } figcaption { font-size: 0.9em; color: #4b5563; margin-top: 0.5em; font-style: italic; } caption { font-size: 0.9em; font-weight: 600; color: #003c64; padding-bottom: 0.5em; text-align: left; } @@ -9,4 +9,10 @@ div.columns { display: flex; gap: 1.5em; margin: 1em 0; } div.column { flex: 1; min-width: 0; } div.pagebreak { border-top: 2px dashed #cbd5e1; margin: 2em 0; position: relative; } div.pagebreak::after { content: "Page break"; position: absolute; right: 0; top: -0.8em; background: #fff; padding: 0 0.5em; font-size: 0.75em; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; } -@media print { div.pagebreak { border: none; margin: 0; } div.pagebreak::after { content: none; } } +body:has(.slide) { max-width: 64em; counter-reset: slide; background: #e5e9ee; } +.slide { counter-increment: slide; position: relative; aspect-ratio: 16 / 9; box-sizing: border-box; background: #fff; border-radius: 6px; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.18); padding: 1.5em 2.5em 2.5em; margin: 0 0 2em; } +.slide::after { content: "Slide " counter(slide); position: absolute; right: 1.2em; bottom: 0.8em; font-size: 0.75em; color: #94a3b8; } +.slide.part { display: flex; flex-direction: column; justify-content: center; background: #003c64; } +.slide.part h1, .slide.part h2 { color: #fff; font-size: 2.2em; } +.slide div.notes { margin-top: 1.5em; } +@media print { div.pagebreak { border: none; margin: 0; } div.pagebreak::after { content: none; } .slide { break-after: page; box-shadow: none; } } diff --git a/src/py_doc/pandoc/resources/slides-preview.lua b/src/py_doc/pandoc/resources/slides-preview.lua new file mode 100644 index 0000000..d8d85dc --- /dev/null +++ b/src/py_doc/pandoc/resources/slides-preview.lua @@ -0,0 +1,39 @@ +-- slides-preview.lua +-- The HTML twin of a deck (py-doc slides --watch): each slide's content goes in a
    , +-- starting at each heading up to the slide level and at each page break, as pandoc's PowerPoint writer +-- splits them. A heading above the slide level makes a part divider ("part": pandoc keeps no class named "section"). Runs after pagebreak.lua. + +local function is_pagebreak(block) + return block.t == "RawBlock" and block.format == "html" and block.text:find('class="pagebreak"', 1, true) ~= nil +end + +function Pandoc(doc) + local level = tonumber(pandoc.utils.stringify(doc.meta["preview-slide-level"] or "2")) or 2 + local out = pandoc.Blocks({}) + local current, kind = nil, nil + + local function flush() + if current ~= nil and #current > 0 then + out:insert(pandoc.Div(current, pandoc.Attr("", { "slide", kind }))) + end + current, kind = nil, nil + end + + for _, block in ipairs(doc.blocks) do + if block.t == "Header" and block.level <= level then + flush() + current, kind = pandoc.Blocks({ block }), (block.level < level) and "part" or "content" + elseif is_pagebreak(block) then + flush() + current, kind = pandoc.Blocks({}), "content" + else + if current == nil then + current, kind = pandoc.Blocks({}), "content" + end + current:insert(block) + end + end + flush() + doc.blocks = out + return doc +end diff --git a/src/py_doc/poster_cli.py b/src/py_doc/poster_cli.py index 44bf6cb..6cc8895 100644 --- a/src/py_doc/poster_cli.py +++ b/src/py_doc/poster_cli.py @@ -100,7 +100,7 @@ def build_all() -> tuple[dict[str, Path], set[Path]]: errors = (ConfigError, ConversionError, SettingsError) if args.watch: - from py_doc.poster_watch import watch + from py_doc.watch import watch return watch(build_all, errors, port=args.port, open_browser=not args.no_open) try: diff --git a/src/py_doc/poster_watch.py b/src/py_doc/watch.py similarity index 95% rename from src/py_doc/poster_watch.py rename to src/py_doc/watch.py index 2ce143f..b7f459d 100644 --- a/src/py_doc/poster_watch.py +++ b/src/py_doc/watch.py @@ -1,4 +1,4 @@ -"""``py-doc poster --watch``: rebuild the posters on every save, and reload them in the browser. +"""``--watch`` of ``py-doc poster``, ``docx`` and ``slides``: build again on every save, and reload in the browser. The pages are served on a local address, each with a small script that asks the server every 0.7 s whether a newer build is there, and reloads when it is. A build that fails keeps the last good page and @@ -24,7 +24,7 @@ INTERVAL = 0.5 # seconds between two looks at the files SKIPPED = {"node_modules", "__pycache__"} # folders never watched, as are hidden ones -# The kinds of file a poster is made from: its Markdown, includes, pictures, data, diagram sources and fonts. +# The kinds of file a page is made from: its Markdown, includes, pictures, data, diagram sources and fonts. # Any other file in the folder, such as a log, is not watched, or writing it would build again without end. SOURCES = {".md", ".markdown", ".yml", ".yaml", ".json", ".csv", ".txt", ".py", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".puml", ".iuml", ".plantuml", ".dot", ".gv", ".mmd", ".dsl", ".woff2", ".woff", ".ttf", ".otf"} # fmt: skip @@ -40,7 +40,7 @@ }).catch(function(){}).then(function(){setTimeout(poll,700);});} poll();})();""" -# What a build gives the watcher: the page of each poster, by name, and the files it was made from +# What a build gives the watcher: the page of each poster or HTML twin, by name, and the files it was made from Build = Callable[[], tuple[dict[str, Path], set[Path]]] diff --git a/tests/fixtures/preview/cases.json b/tests/fixtures/preview/cases.json new file mode 100644 index 0000000..93e2dc3 --- /dev/null +++ b/tests/fixtures/preview/cases.json @@ -0,0 +1,30 @@ +{ + "about": "Shared cases for the two previews of py-doc Markdown: the VS Code instant preview (editors/vscode/test/test.js) and the HTML twin that --watch builds with pandoc (tests/test_preview_cases.py). Both must find what each case expects, so the instant preview cannot drift from the build. 'command' is also what the VS Code extension must detect for the case.", + "cases": [ + { + "name": "callouts in both spellings", + "command": "docx", + "markdown": "# Installing\n\n> [!WARNING]\n> Disconnect the battery first.\n\n> **Tip:** Keep the screws.\n\n> [!NOTE]\n> Firmware 2 or later.\n", + "expect": { "callouts": ["warning", "tip", "note"], "text": ["Disconnect the battery first.", "Keep the screws.", "Firmware 2 or later."] } + }, + { + "name": "page breaks in a document", + "command": "docx", + "markdown": "---\ntitle: Breaks\nauthor:\n - Jane\n - Joe\n---\n\n# One\n\nFirst page.\n\n[[PAGEBREAK]]\n\nSecond page.\n\n::: {.pagebreak}\n:::\n\nThird page.\n", + "expect": { "pagebreaks": 2, "text": ["First page.", "Second page.", "Third page."] } + }, + { + "name": "a deck at the default split level", + "command": "slides", + "markdown": "# Deck\n\n## First\n\n- one\n\n\n\n## Second\n\n:::: columns\n::: column\nLeft side\n:::\n::: column\nRight side\n:::\n::::\n\n::: notes\nPause here\n:::\n", + "expect": { "slides": 3, "parts": 1, "notes": 2, "columns": 1, "text": ["Say hello", "Pause here", "Left side", "Right side"] } + }, + { + "name": "a deck split at level 1", + "command": "slides", + "split_level": 1, + "markdown": "# One\n\n## Detail\n\ntext\n\n# Two\n\n::: notes\nLast words\n:::\n", + "expect": { "slides": 2, "parts": 0, "notes": 1, "text": ["Detail", "Last words"] } + } + ] +} diff --git a/tests/test_cli.py b/tests/test_cli.py index f915f92..917a585 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -300,7 +300,7 @@ def test_compat_module_entry_point_runs(self, tmp_path): def test_docx_watch_mode(self, tmp_path, monkeypatch): from py_doc.cli import run_docx - from py_doc import poster_watch + from py_doc import watch as watcher src = tmp_path / "spec.md" src.write_text("# Spec\n\nText\n\n::: {.pagebreak}\n:::\n\nMore\n", encoding="utf-8") @@ -313,7 +313,7 @@ def fake_watch(build, errors, port=0, open_browser=True, stop=None, interval=0.5 called.append((pages, sources, port, open_browser, title)) return 0 - monkeypatch.setattr(poster_watch, "watch", fake_watch) + monkeypatch.setattr(watcher, "watch", fake_watch) code = run_docx([str(src), "--watch", "--port", "9876", "--no-open"]) assert code == 0 assert len(called) == 1 @@ -328,19 +328,20 @@ def fake_watch(build, errors, port=0, open_browser=True, stop=None, interval=0.5 assert 'class="pagebreak"' in twin and "Page break" in twin # preview.css is in the twin def test_docx_watch_keeps_files_of_the_same_name_apart(self, tmp_path, monkeypatch): - from py_doc import poster_watch + from py_doc import watch as watcher from py_doc.cli import run_docx for folder in ("a", "b"): (tmp_path / folder).mkdir() (tmp_path / folder / "readme.md").write_text(f"# {folder}\n", encoding="utf-8") called = [] - monkeypatch.setattr(poster_watch, "watch", lambda build, errors, **kw: called.append(build()[0]) or 0) + monkeypatch.setattr(watcher, "watch", lambda build, errors, **kw: called.append(build()[0]) or 0) assert run_docx([str(tmp_path), "--watch", "--no-open"]) == 0 assert sorted(called[0]) == ["a/readme", "b/readme"] def test_docx_watch_builds_the_twin_of_a_document_open_in_word(self, tmp_path, monkeypatch, capsys): - from py_doc import convert, poster_watch + from py_doc import convert + from py_doc import watch as watcher from py_doc.cli import run_docx from py_doc.errors import OutputLocked @@ -352,7 +353,7 @@ def locked(self, md_path, output_path): monkeypatch.setattr(convert.DocxConverter, "convert_file", locked) called = [] - monkeypatch.setattr(poster_watch, "watch", lambda build, errors, **kw: called.append(build()[0]) or 0) + monkeypatch.setattr(watcher, "watch", lambda build, errors, **kw: called.append(build()[0]) or 0) assert run_docx([str(src), "--watch", "--no-open"]) == 0 assert called[0]["spec"].is_file() assert "is open in Word" in capsys.readouterr().err diff --git a/tests/test_logging_policy.py b/tests/test_logging_policy.py index 96b82cb..e863fe7 100644 --- a/tests/test_logging_policy.py +++ b/tests/test_logging_policy.py @@ -17,7 +17,7 @@ # Modules that are command line front ends: they print results and errors for people and programs. FRONT_ENDS = { "app.py", "_cli.py", "cli.py", "slides_cli.py", "tools_cli.py", "template_cli.py", "import_cli.py", "__main__.py", "lint/cli.py", "c4/cli.py", "site/cli.py", "init.py", "new.py", - "analysis/cli.py", "generators/cli.py", "poster_cli.py", "poster_watch.py", + "analysis/cli.py", "generators/cli.py", "poster_cli.py", "watch.py", } # fmt: skip diff --git a/tests/test_poster.py b/tests/test_poster.py index ceff16b..71a703e 100644 --- a/tests/test_poster.py +++ b/tests/test_poster.py @@ -1396,7 +1396,7 @@ def test_watch_serves_the_page_and_builds_again_on_a_save(tmp_path, monkeypatch, import time import urllib.request - from py_doc import poster_watch + from py_doc import watch as watcher source = write(tmp_path, "## A\n\nFirst.\n") (tmp_path / "page.html").write_text("First", encoding="utf-8") @@ -1410,10 +1410,10 @@ def build(): return {"wall": tmp_path / "page.html"}, {source} servers = [] - serve = poster_watch.serve - monkeypatch.setattr(poster_watch, "serve", lambda state, port: servers.append(serve(state, port)) or servers[-1]) + serve = watcher.serve + monkeypatch.setattr(watcher, "serve", lambda state, port: servers.append(serve(state, port)) or servers[-1]) stop = threading.Event() - thread = threading.Thread(target=poster_watch.watch, args=(build, (ConfigError,)), + thread = threading.Thread(target=watcher.watch, args=(build, (ConfigError,)), kwargs={"open_browser": False, "stop": stop, "interval": 0.05}) # fmt: skip thread.start() try: @@ -1457,7 +1457,7 @@ def save(text): # as an editor does: all at once, never a half-written file def test_watch_stops_at_once_when_the_first_build_fails(capsys): - from py_doc.poster_watch import watch + from py_doc.watch import watch def build(): raise ConfigError("no posters") @@ -1477,7 +1477,7 @@ def test_watch_takes_no_pdf_and_output_needs_one_poster(tmp_path, monkeypatch, c def test_watch_watches_the_folder_of_the_markdown_and_the_files_its_settings_name(tmp_path): - from py_doc.poster_watch import watched_files + from py_doc.watch import watched_files (tmp_path / "shared").mkdir() (tmp_path / ".git").mkdir() diff --git a/tests/test_preview_cases.py b/tests/test_preview_cases.py new file mode 100644 index 0000000..b32c7f0 --- /dev/null +++ b/tests/test_preview_cases.py @@ -0,0 +1,41 @@ +"""The shared preview cases of tests/fixtures/preview/cases.json, through the HTML twin that --watch builds. + +editors/vscode/test/test.js runs the same cases through the VS Code instant preview, so the two previews +agree on what a document shows: callouts, page breaks, slides, speaker notes, columns and text. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from py_doc.convert import DocxConverter, SlidesConverter + +CASES = json.loads((Path(__file__).parent / "fixtures" / "preview" / "cases.json").read_text(encoding="utf-8"))["cases"] + +pytestmark = pytest.mark.usefixtures("pandoc_runtime") + + +@pytest.mark.parametrize("case", CASES, ids=[case["name"] for case in CASES]) +def test_the_html_twin_shows_what_the_instant_preview_shows(case, tmp_path): + md = tmp_path / "case.md" + md.write_text(case["markdown"], encoding="utf-8") + if case["command"] == "slides": + converter = SlidesConverter(split_level=case.get("split_level", 2)) + else: + converter = DocxConverter() + html = converter.convert_twin(md, tmp_path / "case.html").read_text(encoding="utf-8") + expect = case["expect"] + + for kind in expect.get("callouts", []): + assert f'class="admonition {kind}"' in html, kind + assert html.count('class="pagebreak"') == expect.get("pagebreaks", 0) + content, parts = html.count('class="slide content"'), html.count('class="slide part"') + assert content + parts == expect.get("slides", 0) + assert parts == expect.get("parts", 0) + assert html.count('class="notes"') == expect.get("notes", 0) + assert html.count('class="columns"') == expect.get("columns", 0) + for text in expect.get("text", []): + assert text in html, text diff --git a/tests/test_slides_cli.py b/tests/test_slides_cli.py index c77044a..60797e5 100644 --- a/tests/test_slides_cli.py +++ b/tests/test_slides_cli.py @@ -105,7 +105,7 @@ def test_module_entry_point_prints_help(): def test_slides_watch_mode(tmp_path, monkeypatch): from py_doc.slides_cli import run_slides - from py_doc import poster_watch + from py_doc import watch as watcher src = tmp_path / "deck.md" src.write_text("# Deck\n\n## S1\n\n- a\n\n\n", encoding="utf-8") @@ -117,7 +117,7 @@ def fake_watch(build, errors, port=0, open_browser=True, stop=None, interval=0.5 called.append((pages, sources, port, open_browser, title)) return 0 - monkeypatch.setattr(poster_watch, "watch", fake_watch) + monkeypatch.setattr(watcher, "watch", fake_watch) code = run_slides([str(src), "--watch", "--port", "9875", "--no-open"]) assert code == 0 assert len(called) == 1 @@ -129,3 +129,19 @@ def fake_watch(build, errors, port=0, open_browser=True, stop=None, interval=0.5 assert "PowerPoint Preview" in title twin = pages["deck"].read_text(encoding="utf-8") assert 'class="notes"' in twin and "Say this" in twin + assert twin.count('class="slide part"') == 1 # # Deck, above the slide level + assert twin.count('class="slide content"') == 1 # ## S1, with its notes on the same slide + + +def test_slides_watch_twin_follows_the_split_level(tmp_path, monkeypatch): + from py_doc import watch as watcher + from py_doc.slides_cli import run_slides + + src = tmp_path / "deck.md" + src.write_text("# One\n\n## Sub\n\ntext\n\n# Two\n\n::: {.pagebreak}\n:::\n\nAfter the break\n", encoding="utf-8") + called = [] + monkeypatch.setattr(watcher, "watch", lambda build, errors, **kw: called.append(build()[0]) or 0) + assert run_slides([str(src), "--watch", "--no-open", "--split-level", "1"]) == 0 + twin = called[0]["deck"].read_text(encoding="utf-8") + assert 'class="slide part"' not in twin + assert twin.count('class="slide content"') == 3 # # One (with ## Sub), # Two, and the page break From 3397cd98c04a00b9f646108301d03a0e00c8255d Mon Sep 17 00:00:00 2001 From: Mark Bacon Date: Wed, 21 Oct 2026 15:26:59 +0100 Subject: [PATCH 4/6] Format the Python code with ruff format, and check it in CI line-length = 120, the width the code was already written to, so the reformat changes layout only. examples/ and tests/fixtures/ are left out: example files are included into documents and posters, wrapped to fit. CI's lint job now runs `ruff format --check .`. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + pyproject.toml | 5 + src/py_doc/analysis/cli.py | 75 +++++- src/py_doc/analysis/diff.py | 8 +- src/py_doc/app.py | 104 ++++++-- src/py_doc/build.py | 18 +- src/py_doc/c4/check.py | 7 +- src/py_doc/c4/cli.py | 41 ++- src/py_doc/c4/generate.py | 57 +++- src/py_doc/c4/model.py | 11 +- src/py_doc/c4/render.py | 20 +- src/py_doc/c4/source.py | 20 +- src/py_doc/c4/stack.py | 11 +- src/py_doc/c4/structurizr.py | 12 +- src/py_doc/config.py | 142 ++++++++-- src/py_doc/convert.py | 8 +- src/py_doc/diagrams/registry.py | 41 ++- src/py_doc/diagrams/render.py | 14 +- src/py_doc/diagrams/scan.py | 7 +- src/py_doc/directives.py | 9 +- src/py_doc/docx_import.py | 38 ++- src/py_doc/generators/apidocs.py | 4 +- src/py_doc/generators/builtin.py | 77 ++++-- src/py_doc/generators/registry.py | 20 +- src/py_doc/import_cli.py | 10 +- src/py_doc/init.py | 35 ++- src/py_doc/lint/cli.py | 28 +- src/py_doc/lint/config.py | 22 +- src/py_doc/lint/formats.py | 22 +- src/py_doc/lint/linter.py | 22 +- src/py_doc/lint/rules.py | 22 +- src/py_doc/lint/structure.py | 16 +- src/py_doc/lint/vale.py | 29 ++- src/py_doc/lint/vale_config.py | 4 +- src/py_doc/markdown/parser.py | 4 +- src/py_doc/migrate.py | 13 +- src/py_doc/naming.py | 8 +- src/py_doc/new.py | 69 ++++- src/py_doc/pandoc/docx_finish.py | 10 +- src/py_doc/pandoc/docx_post.py | 162 +++++++----- src/py_doc/pandoc/engine.py | 21 +- src/py_doc/pandoc/pptx_finish.py | 8 +- src/py_doc/pandoc/reference.py | 12 +- src/py_doc/pandoc/source.py | 5 +- src/py_doc/poster.py | 143 +++++++--- src/py_doc/poster_barcode.py | 43 ++- src/py_doc/poster_blocks.py | 140 +++++++--- src/py_doc/poster_cli.py | 54 +++- src/py_doc/poster_fonts.py | 8 +- src/py_doc/poster_qr.py | 38 ++- src/py_doc/poster_settings.py | 10 +- src/py_doc/poster_themes.py | 10 +- src/py_doc/pptx_import.py | 12 +- src/py_doc/report.py | 6 +- src/py_doc/runtime/engine.py | 29 ++- src/py_doc/runtime/image.py | 6 +- src/py_doc/runtime/setup.py | 4 +- src/py_doc/site/cli.py | 42 ++- src/py_doc/site/engines.py | 33 ++- src/py_doc/site/pages.py | 28 +- src/py_doc/template_check.py | 37 ++- src/py_doc/template_cli.py | 34 ++- src/py_doc/tools_cli.py | 8 +- src/py_doc/watch.py | 21 +- tests/fake_vale.py | 26 +- tests/test_analysis.py | 13 +- tests/test_app.py | 420 +++++++++++++++--------------- tests/test_build.py | 21 +- tests/test_c4.py | 51 +++- tests/test_compat.py | 11 +- tests/test_config.py | 51 +++- tests/test_convert.py | 15 +- tests/test_diagrams.py | 105 ++++++-- tests/test_directives.py | 7 +- tests/test_docx_import.py | 12 +- tests/test_examples.py | 64 ++++- tests/test_front_matter.py | 4 +- tests/test_generators.py | 43 ++- tests/test_heading_numbers.py | 4 +- tests/test_lint.py | 13 +- tests/test_lint_cli.py | 5 +- tests/test_lint_rules.py | 13 +- tests/test_logging_policy.py | 30 ++- tests/test_markdownlint.py | 35 ++- tests/test_migrate.py | 9 +- tests/test_new.py | 38 ++- tests/test_pandoc.py | 16 +- tests/test_poster.py | 273 ++++++++++++++----- tests/test_poster_barcode.py | 29 ++- tests/test_poster_browser.py | 17 +- tests/test_poster_qr.py | 40 ++- tests/test_pptx_import.py | 4 +- tests/test_pptx_pandoc.py | 9 +- tests/test_report.py | 4 +- tests/test_runtime.py | 4 +- tests/test_setup.py | 18 +- tests/test_site.py | 183 ++++++++++--- tests/test_slides_cli.py | 6 +- tests/test_structure.py | 70 ++++- tests/test_template_check.py | 10 +- tests/test_vale.py | 38 ++- tests/test_word_control.py | 22 +- tests/test_word_layout.py | 15 +- 103 files changed, 2784 insertions(+), 942 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9efb7e..8130bd3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,7 @@ jobs: python-version: "3.14" - run: python -m pip install ruff - run: ruff check . + - run: ruff format --check . test: name: Test (${{ matrix.os }}, Python ${{ matrix.python-version }}) diff --git a/pyproject.toml b/pyproject.toml index 910e8ee..ef77bc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,11 @@ timeout = 120 [tool.ruff] src = ["src", "tests"] target-version = "py310" +line-length = 120 # the width the code is written to + +[tool.ruff.format] +# Example files are content: they are included into documents and posters, wrapped to fit them +exclude = ["examples/**", "tests/fixtures/**"] [tool.ruff.lint] select = ["E4", "E7", "E9", "F"] diff --git a/src/py_doc/analysis/cli.py b/src/py_doc/analysis/cli.py index 864a42a..d0bd20a 100644 --- a/src/py_doc/analysis/cli.py +++ b/src/py_doc/analysis/cli.py @@ -63,7 +63,12 @@ def run_stats(argv: list[str]) -> int: prog="py-doc stats", description="Count the words, headings, code blocks, tables, pictures and links of documents or files", epilog=STATS_EXAMPLES, formatter_class=argparse.RawDescriptionHelpFormatter, ) # fmt: skip - parser.add_argument("targets", nargs="*", metavar="TARGET", help="Documents of py-doc.yml, or Markdown files and folders (default: every document)") + parser.add_argument( + "targets", + nargs="*", + metavar="TARGET", + help="Documents of py-doc.yml, or Markdown files and folders (default: every document)", + ) parser.add_argument("-c", "--config", help="Configuration file (default: the nearest py-doc.yml)") parser.add_argument("--lint", action="store_true", help="Also count the lint issues of each") parser.add_argument("--format", choices=("text", "json"), default="text", help="text, a table (default), or json") @@ -89,12 +94,18 @@ def run_stats(argv: list[str]) -> int: print(f"Error: {target} is not a document of py-doc.yml, a file or a folder", file=sys.stderr) return 2 if not groups: - print("Error: nothing to count. Name files or folders, or run it beside a py-doc.yml with documents", file=sys.stderr) + print( + "Error: nothing to count. Name files or folders, or run it beside a py-doc.yml with documents", + file=sys.stderr, + ) return 2 issues: dict[Path, int] = {} if args.lint: - linter = DocLinter(config=project.lint if project else None, runtime=lambda: connect(project.runtime if project else RuntimeSettings())) + linter = DocLinter( + config=project.lint if project else None, + runtime=lambda: connect(project.runtime if project else RuntimeSettings()), + ) try: report = linter.lint_paths(sorted({f for files in groups.values() for f in files if f.is_file()})) except (LintTargetError, ConversionError) as exc: @@ -150,13 +161,19 @@ def check_project(project: ProjectConfig) -> list[str]: if template is not None and not template.exists() and not settings.options.get("allow_missing_template"): problems.append(f"documents.{name}: the {fmt} template {_shown(template)} is not there") for fmt, settings in project.outputs.items(): - if settings.template is not None and not settings.template.exists() and not settings.options.get("allow_missing_template"): + if ( + settings.template is not None + and not settings.template.exists() + and not settings.options.get("allow_missing_template") + ): problems.append(f"outputs.{fmt}: the template {_shown(settings.template)} is not there") if project.site is not None: from py_doc.site import engine_for if not engine_for(project.site.engine, project.site.dir).exists(): - problems.append(f"site: there is no {project.site.engine} site in {_shown(project.site.dir)}. Run `py-doc site init`") + problems.append( + f"site: there is no {project.site.engine} site in {_shown(project.site.dir)}. Run `py-doc site init`" + ) return problems @@ -168,8 +185,15 @@ def run_check(argv: list[str]) -> int: parser.add_argument("-c", "--config", help="Configuration file (default: the nearest py-doc.yml)") parser.add_argument("--strict", action="store_true", help="Fail on lint warnings too") parser.add_argument("--no-lint", action="store_true", help="Check only the project, not the writing") - parser.add_argument("--engine", choices=("auto", "builtin", "vale"), default="auto", help="Lint engine, as for py-doc lint") - parser.add_argument("--format", choices=("text", "json", "sarif", "github", "csv"), default="text", help="How lint issues are written") + parser.add_argument( + "--engine", choices=("auto", "builtin", "vale"), default="auto", help="Lint engine, as for py-doc lint" + ) + parser.add_argument( + "--format", + choices=("text", "json", "sarif", "github", "csv"), + default="text", + help="How lint issues are written", + ) parser.add_argument("--output", metavar="FILE", help="Write the lint issues to this file") _noise(parser) args = parser.parse_args(argv) @@ -188,7 +212,9 @@ def run_check(argv: list[str]) -> int: status = 1 if problems else 0 if not args.no_lint: generated = {spec.output.resolve() for spec in project.generators} # written by tools, not people - files = sorted({f.resolve() for name in project.own_documents for f in _sources(project, name) if f.exists()} - generated) + files = sorted( + {f.resolve() for name in project.own_documents for f in _sources(project, name) if f.exists()} - generated + ) if files: linter = DocLinter(config=project.lint, runtime=lambda: connect(project.runtime), engine=args.engine) try: @@ -210,7 +236,10 @@ def run_check(argv: list[str]) -> int: lint_summary = "no sources to lint" else: lint_summary = "lint skipped" - print(f"\n[SUMMARY] {len(project.documents)} document(s), {len(problems)} project problem(s), {lint_summary}", file=sys.stderr) + print( + f"\n[SUMMARY] {len(project.documents)} document(s), {len(problems)} project problem(s), {lint_summary}", + file=sys.stderr, + ) return status @@ -235,10 +264,17 @@ def run_diff(argv: list[str]) -> int: prog="py-doc diff", description="Compare two versions of Markdown documents by section: added, removed, renamed, moved and changed", epilog=DIFF_EXAMPLES, formatter_class=argparse.RawDescriptionHelpFormatter, ) # fmt: skip - parser.add_argument("paths", nargs="*", metavar="PATH", help="OLD NEW files, or with --rev the files and folders to compare (default: the sources of py-doc.yml)") + parser.add_argument( + "paths", + nargs="*", + metavar="PATH", + help="OLD NEW files, or with --rev the files and folders to compare (default: the sources of py-doc.yml)", + ) parser.add_argument("--rev", metavar="REVISION", help="Compare the working files with this Git revision") parser.add_argument("-c", "--config", help="Configuration file (default: the nearest py-doc.yml)") - parser.add_argument("--format", choices=("text", "json"), default="text", help="text, a list per file (default), or json") + parser.add_argument( + "--format", choices=("text", "json"), default="text", help="text, a list per file (default), or json" + ) parser.add_argument("--exit-code", action="store_true", help="Exit with status 1 when anything changed") _noise(parser) args = parser.parse_args(argv) @@ -264,7 +300,10 @@ def run_diff(argv: list[str]) -> int: print(name) for change in changes: print(f" {change.describe()}") - print(f"\n[SUMMARY] {len(pairs)} file(s) compared, {len(results)} changed, {sum(len(c) for _, c in results)} section change(s)", file=sys.stderr) + print( + f"\n[SUMMARY] {len(pairs)} file(s) compared, {len(results)} changed, {sum(len(c) for _, c in results)} section change(s)", + file=sys.stderr, + ) return 1 if args.exit_code and results else 0 @@ -288,7 +327,17 @@ def _diff_pairs(args: argparse.Namespace) -> list[tuple[str, str, str]]: files = [f.resolve() for name in project.own_documents for f in _sources(project, name)] # Files the revision had that are gone now: the ones under the same folders, listed by git folders = sorted({f.parent for f in files}) - listed = _git(["ls-tree", "-r", "--name-only", args.rev, "--", *[str(d.relative_to(top).as_posix()) for d in folders if d.is_relative_to(top)]], top) + listed = _git( + [ + "ls-tree", + "-r", + "--name-only", + args.rev, + "--", + *[str(d.relative_to(top).as_posix()) for d in folders if d.is_relative_to(top)], + ], + top, + ) files += [top / line for line in listed.splitlines() if line.lower().endswith((".md", ".markdown"))] pairs = [] for file in dict.fromkeys(files): diff --git a/src/py_doc/analysis/diff.py b/src/py_doc/analysis/diff.py index 20b64ea..082eca9 100644 --- a/src/py_doc/analysis/diff.py +++ b/src/py_doc/analysis/diff.py @@ -59,7 +59,13 @@ def as_dict(self) -> dict: def side(section: Section | None): return None if section is None else {"section": section.title, "level": section.level, "line": section.line} - return {"kind": self.kind, "old": side(self.old), "new": side(self.new), "words_added": self.words_added, "words_removed": self.words_removed} + return { + "kind": self.kind, + "old": side(self.old), + "new": side(self.new), + "words_added": self.words_added, + "words_removed": self.words_removed, + } def sections(text: str) -> list[Section]: diff --git a/src/py_doc/app.py b/src/py_doc/app.py index 436180c..7930d63 100644 --- a/src/py_doc/app.py +++ b/src/py_doc/app.py @@ -105,26 +105,53 @@ def build_parser() -> argparse.ArgumentParser: build.add_argument("documents", nargs="*", metavar="DOCUMENT", help="Documents and posters to build (default: all)") source = build.add_mutually_exclusive_group() source.add_argument("-c", "--config", help="Configuration file (default: the nearest py-doc.yml)") - source.add_argument("--legacy", metavar="FILE", help="Build from a legacy createdocs build file instead of py-doc.yml") + source.add_argument( + "--legacy", metavar="FILE", help="Build from a legacy createdocs build file instead of py-doc.yml" + ) build.add_argument( "-f", "--format", action="append", choices=[*sorted(FORMATS), "poster"], help="Build only this format; poster builds only the posters", ) # fmt: skip - build.add_argument("--var", action="append", default=[], metavar="NAME=VALUE", type=_assignment, help="Set a {{variable}}") build.add_argument( - "--condition", action="append", default=[], metavar="NAME=VALUE", type=_assignment, help="Set a [[IF:]] condition" + "--var", action="append", default=[], metavar="NAME=VALUE", type=_assignment, help="Set a {{variable}}" + ) + build.add_argument( + "--condition", + action="append", + default=[], + metavar="NAME=VALUE", + type=_assignment, + help="Set a [[IF:]] condition", + ) + build.add_argument( + "--report", metavar="FILE", help="Write a JSON report of the build (default: reports.path in py-doc.yml)" ) - build.add_argument("--report", metavar="FILE", help="Write a JSON report of the build (default: reports.path in py-doc.yml)") build.add_argument("--timestamps", action="store_true", help="Put the time and the render times in the report") build.add_argument("--keep-going", action="store_true", help="Report every broken diagram, not just the first") lint = build.add_mutually_exclusive_group() - lint.add_argument("--lint", dest="lint", action="store_const", const="on", help="Lint the sources first: an error stops the build") - lint.add_argument("--strict", dest="lint", action="store_const", const="strict", help="Lint the sources first: a warning stops the build too") - lint.add_argument("--no-lint", dest="lint", action="store_const", const="off", help="Do not lint, whatever build.lint says") - build.add_argument("--lint-report", metavar="FILE", help="Also write the lint issues to FILE (.sarif, .json or .csv)") + lint.add_argument( + "--lint", dest="lint", action="store_const", const="on", help="Lint the sources first: an error stops the build" + ) + lint.add_argument( + "--strict", + dest="lint", + action="store_const", + const="strict", + help="Lint the sources first: a warning stops the build too", + ) + lint.add_argument( + "--no-lint", dest="lint", action="store_const", const="off", help="Do not lint, whatever build.lint says" + ) + build.add_argument( + "--lint-report", metavar="FILE", help="Also write the lint issues to FILE (.sarif, .json or .csv)" + ) site = build.add_mutually_exclusive_group() - site.add_argument("--site", dest="site", action="store_true", default=None, help="Build the site after the documents") - site.add_argument("--no-site", dest="site", action="store_false", help="Do not build the site, whatever build.site says") + site.add_argument( + "--site", dest="site", action="store_true", default=None, help="Build the site after the documents" + ) + site.add_argument( + "--no-site", dest="site", action="store_false", help="Do not build the site, whatever build.site says" + ) build.add_argument("--no-generate", action="store_true", help="Do not run content.generators first") noise = build.add_mutually_exclusive_group() noise.add_argument("-q", "--quiet", action="store_true", help="Only show warnings and errors") @@ -140,7 +167,11 @@ def build_parser() -> argparse.ArgumentParser: move.add_argument("paths", nargs="*", default=["."], metavar="PATH", help="Build files or folders to search") move.add_argument("-o", "--output", default="py-doc.yml", help="Configuration file to write (default: py-doc.yml)") move.add_argument( - "--from", dest="source", choices=LEGACY_FORMATS, default=LEGACY_FORMATS[0], help="Legacy format (default: createdocs, the only one)" + "--from", + dest="source", + choices=LEGACY_FORMATS, + default=LEGACY_FORMATS[0], + help="Legacy format (default: createdocs, the only one)", ) move.add_argument("--check", action="store_true", help="Only report what would be migrated") move.add_argument("--backup", action="store_true", help="Keep copies (.bak) of the legacy files and any old output") @@ -207,7 +238,9 @@ def _build(args: argparse.Namespace) -> int: configure_logging(quiet=args.quiet, verbose=args.verbose) path = Path(args.legacy or args.config) if args.legacy or args.config else find_config(Path.cwd()) if path is None: - _error("No py-doc.yml found here or in a parent folder. Use --config, --legacy, or `py-doc migrate` to create one") + _error( + "No py-doc.yml found here or in a parent folder. Use --config, --legacy, or `py-doc migrate` to create one" + ) return 1 try: config = _load_legacy(path) if args.legacy else load_config(path) @@ -251,7 +284,9 @@ def runtime(): return status -def _pipeline(args: argparse.Namespace, config, names: list[str], runtime, report: BuildReport | None, posters=()) -> int: +def _pipeline( + args: argparse.Namespace, config, names: list[str], runtime, report: BuildReport | None, posters=() +) -> int: """Generate, lint, build the documents and bundles, the posters and the site, then check the outputs. A failed stage stops the ones after it, except that every document is tried. Returns the exit status. @@ -291,10 +326,19 @@ def step(name: str, state: str, detail: str = "") -> None: written: list[Path] = [] for name in names: try: - written += build_document( - config, config.documents[name], args.format, dict(args.var), _conditions(args.condition), runtime, - args.keep_going, report, - ) or [] + written += ( + build_document( + config, + config.documents[name], + args.format, + dict(args.var), + _conditions(args.condition), + runtime, + args.keep_going, + report, + ) + or [] + ) except (FileNotFoundError, ConversionError) as exc: _error(f"{name}: {exc}") failed += 1 @@ -351,7 +395,9 @@ def step(name: str, state: str, detail: str = "") -> None: return 1 if missing else 0 -def _build_posters(args: argparse.Namespace, config, names: list[str], runtime, report: BuildReport | None) -> tuple[int, list[Path]]: +def _build_posters( + args: argparse.Namespace, config, names: list[str], runtime, report: BuildReport | None +) -> tuple[int, list[Path]]: """Build each poster, and its PDF when it asks for one. One failure does not stop the others. Returns the exit status (0 when every poster was built) and the files written. @@ -359,12 +405,21 @@ def _build_posters(args: argparse.Namespace, config, names: list[str], runtime, from .poster import write_poster from .poster_settings import layer, merge - overrides = layer({"variables": dict(args.var), "conditions": _conditions(args.condition)}, Path.cwd(), "the command line") + overrides = layer( + {"variables": dict(args.var), "conditions": _conditions(args.condition)}, Path.cwd(), "the command line" + ) status, written = 0, [] for name in names: poster = config.posters[name] try: - ctx, files = write_poster(poster.source, poster.output, runtime, merge(poster.settings, overrides), config.poster, mermaid=config.mermaid) + ctx, files = write_poster( + poster.source, + poster.output, + runtime, + merge(poster.settings, overrides), + config.poster, + mermaid=config.mermaid, + ) except (FileNotFoundError, ConversionError) as exc: _error(f"{name}: {exc}") status = max(status, exit_status(exc) if isinstance(exc, ConversionError) else 1) @@ -372,7 +427,10 @@ def _build_posters(args: argparse.Namespace, config, names: list[str], runtime, report.failed(name, exc) continue for file in files: - logger.info(f"Wrote {_shown(file)}" + (f" ({ctx.n} diagram(s), {ctx.failed} not drawn)" if file.suffix == ".html" and ctx.n else "")) + logger.info( + f"Wrote {_shown(file)}" + + (f" ({ctx.n} diagram(s), {ctx.failed} not drawn)" if file.suffix == ".html" and ctx.n else "") + ) if report: report.output(name, "pdf" if file.suffix == ".pdf" else "html", file) written += files @@ -410,7 +468,9 @@ def _lint_sources(config, names: list[str], mode: str, output: Path | None, runt logger.info(f"Wrote the lint issues to {output}") for result in lint.errors: _error(f"{_shown(result.file)}: {result.error}") - logger.info(f"Lint: {len(lint.issues)} issue(s) in {lint.files_scanned} file(s), {lint.count('warning')} warning or error") + logger.info( + f"Lint: {len(lint.issues)} issue(s) in {lint.files_scanned} file(s), {lint.count('warning')} warning or error" + ) return lint_status(lint, mode == "strict") diff --git a/src/py_doc/build.py b/src/py_doc/build.py index 01ad27e..8a08e0e 100644 --- a/src/py_doc/build.py +++ b/src/py_doc/build.py @@ -21,6 +21,7 @@ logger = logging.getLogger(__name__) + def _combine_metadata(doc: DocumentConfig, first: Metadata, heading: str | None, root: Path | None = None) -> Metadata: """Properties from the configuration, then the first source's front matter, then its first heading. @@ -82,7 +83,18 @@ def build_document( def render_output( - fmt, joined, metadata, output, name, source_dirs, template, settings, runtime, keep_going=False, report=None, mermaid=None + fmt, + joined, + metadata, + output, + name, + source_dirs, + template, + settings, + runtime, + keep_going=False, + report=None, + mermaid=None, ) -> Path: """Draw the diagrams of ``joined`` and have pandoc write ``output``. ``name`` is used in messages.""" if runtime is None: @@ -112,7 +124,9 @@ def render_output( if _numbers_headings_itself(fmt, template, settings): stripped = strip_heading_numbers(markdown) if stripped != markdown: - logger.info(f"{name}.{fmt} numbers its headings itself, so the numbers written in the headings are left out") + logger.info( + f"{name}.{fmt} numbers its headings itself, so the numbers written in the headings are left out" + ) markdown = stripped job = PandocJob(fmt, markdown, metadata, output, source_dirs, template, settings, drawn.pictures) return convert(runtime(), job) diff --git a/src/py_doc/c4/check.py b/src/py_doc/c4/check.py index 6615d93..e60c236 100644 --- a/src/py_doc/c4/check.py +++ b/src/py_doc/c4/check.py @@ -51,5 +51,10 @@ def issue(rule: str, message: str) -> ModelIssue | None: found.append(issue("C4004", f"{element.kind} '{element.name}' has no relationships")) for rel in ws.relationships: if rel.linked_id is None and not rel.description: - found.append(issue("C4003", f"the relationship from '{ws.name_of(rel.source_id)}' to '{ws.name_of(rel.destination_id)}' has no description")) + found.append( + issue( + "C4003", + f"the relationship from '{ws.name_of(rel.source_id)}' to '{ws.name_of(rel.destination_id)}' has no description", + ) + ) return sorted((i for i in found if i), key=lambda i: (i.rule, i.message)) diff --git a/src/py_doc/c4/cli.py b/src/py_doc/c4/cli.py index 5c0fe59..b10b197 100644 --- a/src/py_doc/c4/cli.py +++ b/src/py_doc/c4/cli.py @@ -35,7 +35,12 @@ def build_parser() -> argparse.ArgumentParser: actions = parser.add_subparsers(dest="action", metavar="action", required=True) def common(sub: argparse.ArgumentParser, many: bool = True) -> None: - sub.add_argument("workspace", nargs="?", default=".", help="A .dsl or workspace.json file, or a folder to search (default: .)") + sub.add_argument( + "workspace", + nargs="?", + default=".", + help="A .dsl or workspace.json file, or a folder to search (default: .)", + ) sub.add_argument("-c", "--config", help="py-doc.yml to read (default: the nearest one)") noise = sub.add_mutually_exclusive_group() noise.add_argument("-q", "--quiet", action="store_true", help="Only show warnings and errors") @@ -48,10 +53,14 @@ def common(sub: argparse.ArgumentParser, many: bool = True) -> None: export = actions.add_parser("export", help="Write diagrams, diagram source or Markdown for workspaces") common(export) export.add_argument("-o", "--output", default="c4_exports", help="Output folder (default: c4_exports)") - export.add_argument("-f", "--formats", nargs="+", choices=FORMATS, default=["svg", "plantuml", "mermaid"], help="Formats to write") + export.add_argument( + "-f", "--formats", nargs="+", choices=FORMATS, default=["svg", "plantuml", "mermaid"], help="Formats to write" + ) export.add_argument("--views", nargs="+", metavar="KEY", help="Draw only these views") export.add_argument("--levels", nargs="+", choices=LEVELS, help="Levels of Markdown to write (default: all)") - export.add_argument("--url", help="A Structurizr Local or server that is already running, as the tool container can reach it") + export.add_argument( + "--url", help="A Structurizr Local or server that is already running, as the tool container can reach it" + ) export.add_argument("--dry-run", action="store_true", help="List what would be written and write nothing") lock = actions.add_parser("lock", help="Pin the Structurizr Local image by digest in c4-images.lock") @@ -120,7 +129,12 @@ def _stack(args: argparse.Namespace, files: list[Path], runtime, c4: C4Settings, if len(files) != 1: raise ConversionError(f"{len(files)} workspaces found. Name one: {', '.join(str(f) for f in files)}") stack = Stack( - runtime(), files[0], image=resolve_image(c4.image, root), port=c4.port or SERVER_PORT, live=True, state=root / ".py-doc" + runtime(), + files[0], + image=resolve_image(c4.image, root), + port=c4.port or SERVER_PORT, + live=True, + state=root / ".py-doc", ) if args.action == "status": print(stack.status()) @@ -195,7 +209,9 @@ def _export(args: argparse.Namespace, files: list[Path], runtime, c4: C4Settings return 0 -def _export_one(args: argparse.Namespace, file: Path, out: Path, runtime, c4: C4Settings, cache: Path, root: Path) -> None: +def _export_one( + args: argparse.Namespace, file: Path, out: Path, runtime, c4: C4Settings, cache: Path, root: Path +) -> None: formats = list(dict.fromkeys(args.formats)) cli = StructurizrCli(runtime, c4.cli) ws = load_workspace(workspace_json(file, cache, runtime, c4.cli)) @@ -204,13 +220,20 @@ def _export_one(args: argparse.Namespace, file: Path, out: Path, runtime, c4: C4 if args.dry_run: names = [f"{out}/{p.name}" for fmt in formats if fmt in DRAWN for p in plan(ws, out, (fmt,), args.views).files] names += [f"{out}/{fmt}/" for fmt in formats if fmt in TEXT_FORMATS] - names += [f"{out}/markdown/{page}" for page in (generate(ws, file.name, levels=args.levels) if "markdown" in formats else {})] + names += [ + f"{out}/markdown/{page}" + for page in (generate(ws, file.name, levels=args.levels) if "markdown" in formats else {}) + ] print("\n".join(sorted(names)) or "Nothing to write") return drawn = tuple(f for f in formats if f in DRAWN) pictures: dict[str, str] = {} if drawn: - stack = ExternalStack(args.url) if args.url else Stack(runtime(), file, image=resolve_image(c4.image, root), port=c4.port or SERVER_PORT) + stack = ( + ExternalStack(args.url) + if args.url + else Stack(runtime(), file, image=resolve_image(c4.image, root), port=c4.port or SERVER_PORT) + ) files = render_views(ws, file, out, runtime(), formats=drawn, keys=args.views, stack=stack) pictures = {p.stem: p.name for p in files if p.suffix == f".{drawn[0]}"} for fmt in (f for f in formats if f in TEXT_FORMATS): @@ -220,6 +243,8 @@ def _export_one(args: argparse.Namespace, file: Path, out: Path, runtime, c4: C4 if "markdown" in formats: folder = out / "markdown" folder.mkdir(parents=True, exist_ok=True) - for name, text in generate(ws, file.name, levels=args.levels, images={k: f"../{v}" for k, v in pictures.items()}).items(): + for name, text in generate( + ws, file.name, levels=args.levels, images={k: f"../{v}" for k, v in pictures.items()} + ).items(): (folder / name).write_text(text, encoding="utf-8") print(f"Exported '{ws.name}' to {out}") diff --git a/src/py_doc/c4/generate.py b/src/py_doc/c4/generate.py index 1f24112..d2db65f 100644 --- a/src/py_doc/c4/generate.py +++ b/src/py_doc/c4/generate.py @@ -9,7 +9,17 @@ import yaml -from .model import Component, Container, DeploymentNode, Element, InfrastructureNode, Person, SoftwareSystem, View, Workspace +from .model import ( + Component, + Container, + DeploymentNode, + Element, + InfrastructureNode, + Person, + SoftwareSystem, + View, + Workspace, +) LEVELS = ("context", "containers", "components", "relationships", "deployment", "dynamic", "catalogue", "documentation") PAGE_NAMES = { @@ -48,7 +58,9 @@ def _table(headers: list[str], rows: list[list[str]]) -> list[str]: def _tags(element: Element) -> str: - return ", ".join(t for t in element.tags if t not in ("Element", "Person", "Software System", "Container", "Component")) + return ", ".join( + t for t in element.tags if t not in ("Element", "Person", "Software System", "Container", "Component") + ) def _by_name(items: list[Element]) -> list[Element]: @@ -57,7 +69,12 @@ def _by_name(items: list[Element]) -> list[Element]: class _Writer: def __init__( - self, ws: Workspace, source: str, images: dict[str, str], templates: dict[str, str], systems: list[str] | None, + self, + ws: Workspace, + source: str, + images: dict[str, str], + templates: dict[str, str], + systems: list[str] | None, sources: dict[str, tuple[str, str]], ): self.ws, self.source, self.images, self.templates = ws, source, images, {**TEMPLATES, **templates} @@ -98,7 +115,14 @@ def figure(self, view: View) -> list[str]: out += [f"[[CAPTION:Figure]] {view.heading}", f"![{view.heading}]({image})", ""] elif view.key in self.sources: language, text = self.sources[view.key] - out += [f"Diagram source ({language}) of the view '{view.heading}':", "", f"```{language}", text.rstrip(), "```", ""] + out += [ + f"Diagram source ({language}) of the view '{view.heading}':", + "", + f"```{language}", + text.rstrip(), + "```", + "", + ] return out # -- the pages ------------------------------------------------------------------------------ @@ -162,14 +186,21 @@ def walk(node: Element, depth: int) -> list[str]: lines += walk(child, depth + 1) return lines - for node in _by_name([n for n in self.ws.of_kind(DeploymentNode) if n.parent_id is None and n.environment == environment]): + for node in _by_name( + [n for n in self.ws.of_kind(DeploymentNode) if n.parent_id is None and n.environment == environment] + ): body += walk(node, 0) body += [""] body += [line.replace("## View:", "### View:") for line in self.dep_views(environment)] return self.page("deployment", body) def dep_views(self, environment: str) -> list[str]: - return [line for v in self.ws.views if v.kind == "deployment" and v.environment == environment for line in self.figure(v)] + return [ + line + for v in self.ws.views + if v.kind == "deployment" and v.environment == environment + for line in self.figure(v) + ] def dynamic(self) -> str: body = ["# Dynamic behaviour", "", self.say("dynamic"), ""] @@ -192,10 +223,20 @@ def perspectives(self, elements: list[Element]) -> list[str]: def catalogue(self) -> str: ws = self.ws body = ["# Element catalogue", "", self.say("catalogue"), ""] - rows = [[e.name, e.kind, e.technology, e.description] for e in _by_name([e for e in ws.elements.values() if not e.kind.endswith("Instance")])] + rows = [ + [e.name, e.kind, e.technology, e.description] + for e in _by_name([e for e in ws.elements.values() if not e.kind.endswith("Instance")]) + ] body += _table(["Name", "Type", "Technology", "Description"], rows) body += ["## Glossary", ""] - terms = sorted({(e.name, e.description) for e in ws.elements.values() if e.description and not e.kind.endswith("Instance")}, key=lambda t: t[0].lower()) + terms = sorted( + { + (e.name, e.description) + for e in ws.elements.values() + if e.description and not e.kind.endswith("Instance") + }, + key=lambda t: t[0].lower(), + ) body += _table(["Term", "Meaning"], [[n, d] for n, d in terms]) return self.page("catalogue", body) diff --git a/src/py_doc/c4/model.py b/src/py_doc/c4/model.py index 7e6996b..016dae6 100644 --- a/src/py_doc/c4/model.py +++ b/src/py_doc/c4/model.py @@ -167,7 +167,12 @@ def _props(value: Any) -> dict[str, str]: def _sections(items: Any) -> list[Section]: return [ - Section(str(s.get("title") or ""), str(s.get("content") or ""), str(s.get("format") or "Markdown"), int(s.get("order") or 0)) + Section( + str(s.get("title") or ""), + str(s.get("content") or ""), + str(s.get("format") or "Markdown"), + int(s.get("order") or 0), + ) for s in sorted(items or [], key=lambda s: int(s.get("order") or 0)) ] @@ -234,7 +239,9 @@ def workspace_from_dict(data: dict) -> Workspace: for m, c in enumerate(raw.get("containers") or []): container = _element(c, "container", f"model.softwareSystems[{n}].containers[{m}]", system, ws) for k, comp in enumerate(c.get("components") or []): - _element(comp, "component", f"model.softwareSystems[{n}].containers[{m}].components[{k}]", container, ws) + _element( + comp, "component", f"model.softwareSystems[{n}].containers[{m}].components[{k}]", container, ws + ) def deployment(raw: dict, where: str, parent: Element | None) -> None: node = _element(raw, "deploymentNode", where, parent, ws) diff --git a/src/py_doc/c4/render.py b/src/py_doc/c4/render.py index 9413065..ae96441 100644 --- a/src/py_doc/c4/render.py +++ b/src/py_doc/c4/render.py @@ -39,7 +39,9 @@ def plan(ws: Workspace, out_dir: Path, formats: tuple[str, ...], keys: list[str] chosen = [v for v in ws.views if keys is None or v.key in keys] missing = sorted(set(keys or []) - {v.key for v in chosen}) if missing: - raise RenderError(f"The workspace has no view '{missing[0]}'. Views: {', '.join(v.key for v in ws.views) or 'none'}") + raise RenderError( + f"The workspace has no view '{missing[0]}'. Views: {', '.join(v.key for v in ws.views) or 'none'}" + ) empty = [v.key for v in chosen if not v.element_ids and v.kind not in ("filtered", "image")] if empty: raise RenderError(f"These views are empty, so they cannot be drawn: {', '.join(empty)}") @@ -68,7 +70,9 @@ def render_views( out_dir.mkdir(parents=True, exist_ok=True) for old in planned.files: old.unlink(missing_ok=True) # a leftover from an earlier run must not pass for a new picture - stack = stack or Stack(runtime, workspace_file, **({"image": image} if image else {}), **({"port": port} if port else {})) + stack = stack or Stack( + runtime, workspace_file, **({"image": image} if image else {}), **({"port": port} if port else {}) + ) started = stack.up() failures: dict[str, str] = {} try: @@ -76,11 +80,19 @@ def render_views( argv = ["node", SCRIPT, stack.url, fmt, "--output", out_dir.resolve()] if keys and len(keys) == 1: argv += ["--view", keys[0]] - result = runtime.run(argv, mounts=[Mount(out_dir.resolve(), writable=True)], network=stack.network, timeout=TIMEOUT, check=False) + result = runtime.run( + argv, + mounts=[Mount(out_dir.resolve(), writable=True)], + network=stack.network, + timeout=TIMEOUT, + check=False, + ) output = f"{result.stdout}\n{result.stderr}" named = _FAILED.search(output) if result.returncode == 1 or (result.returncode not in (0, 2)): - raise RenderError(f"Exporting {fmt} pictures failed (exit status {result.returncode}):\n{output.strip()}") + raise RenderError( + f"Exporting {fmt} pictures failed (exit status {result.returncode}):\n{output.strip()}" + ) if named: for key in (k.strip() for k in named.group(1).split(",")): failures[f"{key}.{fmt}"] = "the browser could not draw it" diff --git a/src/py_doc/c4/source.py b/src/py_doc/c4/source.py index a1bea42..19fc37d 100644 --- a/src/py_doc/c4/source.py +++ b/src/py_doc/c4/source.py @@ -53,7 +53,16 @@ def build_pages( raise ConversionError(f"The Structurizr workspace '{dsl}' does not exist") image = resolve_image(settings.image, cache.parent.parent) key = hashlib.sha256( - json.dumps([workspace_hash(dsl) if dsl.suffix == ".dsl" else dsl.read_text("utf-8"), spec.levels, spec.systems, spec.engine, spec.format, image]).encode() + json.dumps( + [ + workspace_hash(dsl) if dsl.suffix == ".dsl" else dsl.read_text("utf-8"), + spec.levels, + spec.systems, + spec.engine, + spec.format, + image, + ] + ).encode() ).hexdigest()[:16] marker = out / ".hash" listed = sorted(out.glob("*.md")) if out.is_dir() else [] @@ -79,7 +88,9 @@ def build_pages( for old in out.glob("*.md"): old.unlink() - pages = generate(ws, dsl.name, levels=spec.levels or None, systems=spec.systems or None, images=images, sources=sources) + pages = generate( + ws, dsl.name, levels=spec.levels or None, systems=spec.systems or None, images=images, sources=sources + ) written = [] for name, text in pages.items(): (out / name).write_text(text, encoding="utf-8") @@ -102,7 +113,10 @@ def expand( for index, page in enumerate(pages): expanded.append( replace( - source, file=page, structurizr=None, title=source.title if index == 0 else None, + source, + file=page, + structurizr=None, + title=source.title if index == 0 else None, page_break=source.page_break and index == 0, ) ) diff --git a/src/py_doc/c4/stack.py b/src/py_doc/c4/stack.py index 7a7905e..796e1cc 100644 --- a/src/py_doc/c4/stack.py +++ b/src/py_doc/c4/stack.py @@ -162,7 +162,10 @@ def _wait(self) -> None: elapsed = time.monotonic() - started if self.status() != "running": self._fail("Structurizr Local stopped while starting.") - if self.runtime.run(["node", "-e", PROBE, url], network=self.network, check=False, timeout=60).returncode == 0: + if ( + self.runtime.run(["node", "-e", PROBE, url], network=self.network, check=False, timeout=60).returncode + == 0 + ): logger.info("Structurizr Local is ready after %d seconds", elapsed) return if elapsed >= READY_SECONDS: @@ -203,7 +206,11 @@ def _end_wsl_process(pid: int) -> None: """Stop process ``pid`` if it is still a ``wsl.exe``: a recorded number may since belong to another program.""" try: listing = subprocess.run( - ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], capture_output=True, text=True, timeout=30, check=False + ["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"], + capture_output=True, + text=True, + timeout=30, + check=False, ).stdout except (OSError, subprocess.SubprocessError): return diff --git a/src/py_doc/c4/structurizr.py b/src/py_doc/c4/structurizr.py index fae0b4d..a055436 100644 --- a/src/py_doc/c4/structurizr.py +++ b/src/py_doc/c4/structurizr.py @@ -99,7 +99,9 @@ def _run(self, args: list, folder: Path, out: Path | None) -> tuple[str, str]: if self.cli: command = [self.cli, *[str(a) for a in args]] try: - done = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", timeout=TIMEOUT, check=False) + done = subprocess.run( + command, capture_output=True, text=True, encoding="utf-8", timeout=TIMEOUT, check=False + ) except FileNotFoundError as exc: raise EnvironmentProblem( f"c4.cli '{self.cli}' was not found. It needs Java 17 or newer and structurizr-cli from " @@ -119,7 +121,9 @@ def version(self) -> str: match = re.search(r"\d{4}\.\d+\.\d+", out + err) found = match.group(0) if match else "unknown" if found not in TESTED_VERSIONS: - logger.warning("structurizr-cli %s was not tested with py-doc. Tested: %s", found, ", ".join(TESTED_VERSIONS)) + logger.warning( + "structurizr-cli %s was not tested with py-doc. Tested: %s", found, ", ".join(TESTED_VERSIONS) + ) return found def export(self, dsl: Path, fmt: str, output: Path) -> list[Path]: @@ -151,7 +155,9 @@ def workspace_json( return cached StructurizrCli(runtime, cli).version() with tempfile.TemporaryDirectory(prefix="py-doc-c4-") as tmp: - (made,) = [p for p in StructurizrCli(runtime, cli).export(workspace, "json", Path(tmp)) if p.suffix == ".json"][:1] or [None] + (made,) = [p for p in StructurizrCli(runtime, cli).export(workspace, "json", Path(tmp)) if p.suffix == ".json"][ + :1 + ] or [None] if made is None: raise ConversionError(f"structurizr-cli wrote no JSON for '{workspace}'") cache.mkdir(parents=True, exist_ok=True) diff --git a/src/py_doc/config.py b/src/py_doc/config.py index 03365ac..c196b34 100644 --- a/src/py_doc/config.py +++ b/src/py_doc/config.py @@ -115,7 +115,11 @@ class DocumentConfig: def metadata(self) -> Metadata: """The document properties set in the configuration. Unset ones stay None.""" meta = self.meta - extra = {k: v for k, v in meta.items() if k not in ("title", "subtitle", "author", "date", "version", "status", "document_number", "keywords")} + extra = { + k: v + for k, v in meta.items() + if k not in ("title", "subtitle", "author", "date", "version", "status", "document_number", "keywords") + } return Metadata( title=_text(meta.get("title")), subtitle=_text(meta.get("subtitle")), @@ -146,10 +150,18 @@ class SiteConfig: dir: Path title: str | None = None documents: list[str] = field(default_factory=list) # empty: every document, in the order they are defined - downloads: list[str] = field(default_factory=list) # built outputs (pdf, docx, ...) linked from each document's pages - targets: dict[str, str] = field(default_factory=dict) # MkDocs: a site per config file in site.dir, sharing the pages - pages: dict[str, str] = field(default_factory=dict) # hand-written pages for the navigation, after the documents: title -> path - groups: dict[str, list[str]] = field(default_factory=dict) # a navigation section of several documents: title -> documents + downloads: list[str] = field( + default_factory=list + ) # built outputs (pdf, docx, ...) linked from each document's pages + targets: dict[str, str] = field( + default_factory=dict + ) # MkDocs: a site per config file in site.dir, sharing the pages + pages: dict[str, str] = field( + default_factory=dict + ) # hand-written pages for the navigation, after the documents: title -> path + groups: dict[str, list[str]] = field( + default_factory=dict + ) # a navigation section of several documents: title -> documents posters: list[str] = field(default_factory=list) # posters of py-doc.yml on the site, linked from a Posters page @@ -294,7 +306,28 @@ def _path(root: Path, value: Any, where: str) -> Path: def parse_config(data: Any, root: Path) -> ProjectConfig: top = _mapping(data, "the configuration") - _only(top, ("project", "documents", "outputs", "lint", "build", "runtime", "reports", "c4", "site", "collections", "bundles", "content", "poster", "posters", "diagrams", *RESERVED_SECTIONS), "the top level") + _only( + top, + ( + "project", + "documents", + "outputs", + "lint", + "build", + "runtime", + "reports", + "c4", + "site", + "collections", + "bundles", + "content", + "poster", + "posters", + "diagrams", + *RESERVED_SECTIONS, + ), + "the top level", + ) config = ProjectConfig(root=root) project = _mapping(top.get("project", {}), "project") @@ -344,9 +377,13 @@ def parse_config(data: Any, root: Path) -> ProjectConfig: raise ConfigError(str(exc)) from None for name in config.site.posters if config.site else (): if name not in config.posters: - raise ConfigError(f"site.posters names '{name}', which is not under posters. Defined: {', '.join(config.posters) or 'none'}") + raise ConfigError( + f"site.posters names '{name}', which is not under posters. Defined: {', '.join(config.posters) or 'none'}" + ) if config.site and config.site.posters and POSTERS_FOLDER in (config.site.documents or config.own_documents): - raise ConfigError(f"site.posters puts the posters in a '{POSTERS_FOLDER}' folder, and a document of the site has that name. Rename the document") + raise ConfigError( + f"site.posters puts the posters in a '{POSTERS_FOLDER}' folder, and a document of the site has that name. Rename the document" + ) for name in RESERVED_SECTIONS: if name in top: @@ -361,14 +398,20 @@ def _parse_poster(name: str, raw: Any, root: Path) -> PosterConfig: if "source" not in data: raise ConfigError(f"{where} needs a source: the Markdown file of the poster") source = _path(root, data["source"], f"{where}.source") - output = _path(root, data["output"], f"{where}.output") if "output" in data else root / DEFAULT_OUTPUT_DIR / f"{name}.html" + output = ( + _path(root, data["output"], f"{where}.output") + if "output" in data + else root / DEFAULT_OUTPUT_DIR / f"{name}.html" + ) rest = {key: value for key, value in data.items() if key not in poster_settings.ENTRY_KEYS} return PosterConfig(name, source, output, poster_settings.layer(rest, root, where)) def _parse_build(data: dict, root: Path) -> BuildSettings: _only(data, ("generate", "lint", "lint_report", "site"), "build") - settings = BuildSettings(generate=_typed(data, "generate", bool, "build", True), site=_typed(data, "site", bool, "build", False)) + settings = BuildSettings( + generate=_typed(data, "generate", bool, "build", True), site=_typed(data, "site", bool, "build", False) + ) lint = data.get("lint", "off") lint = {False: "off", True: "on"}.get(lint, lint) # YAML reads a bare off or on as a boolean if lint not in LINT_IN_BUILD: @@ -421,7 +464,9 @@ def _parse_site(data: dict, root: Path, config: ProjectConfig) -> SiteConfig: documents = [str(name) for name in _typed(data, "documents", list, "site", [])] for name in documents: if name not in config.documents: - raise ConfigError(f"site.documents names '{name}', which is not under documents. Defined: {', '.join(config.documents)}") + raise ConfigError( + f"site.documents names '{name}', which is not under documents. Defined: {', '.join(config.documents)}" + ) downloads = [str(fmt) for fmt in _typed(data, "downloads", list, "site", [])] for fmt in downloads: if fmt not in FORMATS: @@ -534,9 +579,13 @@ def _parse_bundle(name: str, raw: Any, root: Path, config: ProjectConfig) -> Doc for doc_name in bundle.bundle: doc = config.documents.get(doc_name) if doc is None or doc.bundle: - raise ConfigError(f"{where}.documents names '{doc_name}', which is not under documents. Defined: {', '.join(config.own_documents)}") + raise ConfigError( + f"{where}.documents names '{doc_name}', which is not under documents. Defined: {', '.join(config.own_documents)}" + ) for index, source in enumerate(doc.sources): - bundle.sources.append(replace(source, page_break=source.page_break or (index == 0 and bool(bundle.sources)))) + bundle.sources.append( + replace(source, page_break=source.page_break or (index == 0 and bool(bundle.sources))) + ) bundle.variables.update(doc.variables) bundle.conditions.update(doc.conditions) bundle.variables.update(_mapping(data.get("variables", {}), f"{where}.variables")) @@ -545,7 +594,9 @@ def _parse_bundle(name: str, raw: Any, root: Path, config: ProjectConfig) -> Doc return bundle -def _parse_document(doc_id: str, raw: Any, root: Path, collections: dict[str, list[Path]] | None = None) -> DocumentConfig: +def _parse_document( + doc_id: str, raw: Any, root: Path, collections: dict[str, list[Path]] | None = None +) -> DocumentConfig: where = f"documents.{doc_id}" data = _mapping(raw, where) _only(data, (*_META_KEYS, "sources", "outputs", "variables", "conditions"), where) @@ -567,10 +618,27 @@ def _parse_document(doc_id: str, raw: Any, root: Path, collections: dict[str, li continue spec = None if "structurizr" in entry: - _only(entry, ("structurizr", "levels", "systems", "diagrams", "heading_offset", "title", "page_break"), place) + _only( + entry, ("structurizr", "levels", "systems", "diagrams", "heading_offset", "title", "page_break"), place + ) spec = _parse_structurizr(entry, place) entry = {**entry, "file": entry["structurizr"]} - _only(entry, ("file", "structurizr", "levels", "systems", "diagrams", "heading_offset", "title", "page_break", "include", "site_path"), place) + _only( + entry, + ( + "file", + "structurizr", + "levels", + "systems", + "diagrams", + "heading_offset", + "title", + "page_break", + "include", + "site_path", + ), + place, + ) if "file" not in entry: raise ConfigError(f"{place} needs a 'file' or a 'structurizr' workspace") doc.sources.append( @@ -595,10 +663,15 @@ def _collection_sources(entry: dict, place: str, collections: dict[str, list[Pat _only(entry, ("collection", "heading_offset", "page_break"), place) name = entry["collection"] if name not in collections: - raise ConfigError(f"{place}.collection names '{name}', which is not under collections. Defined: {', '.join(collections) or 'none'}") + raise ConfigError( + f"{place}.collection names '{name}', which is not under collections. Defined: {', '.join(collections) or 'none'}" + ) offset = _typed(entry, "heading_offset", int, place, 0) page_break = _typed(entry, "page_break", bool, place, False) - return [Source(file=file, heading_offset=offset, page_break=page_break and i == 0) for i, file in enumerate(collections[name])] + return [ + Source(file=file, heading_offset=offset, page_break=page_break and i == 0) + for i, file in enumerate(collections[name]) + ] def _site_path(entry: dict, place: str) -> str | None: @@ -652,7 +725,22 @@ def _parse_markdownlint(data: dict, root: Path) -> MarkdownlintSettings: def _parse_lint(data: dict, root: Path) -> LintConfig: - _only(data, ("exclude", "rules", "glossary", "phrases", "acronyms", "limits", "required_sections", "structure", "markdownlint", "vale"), "lint") + _only( + data, + ( + "exclude", + "rules", + "glossary", + "phrases", + "acronyms", + "limits", + "required_sections", + "structure", + "markdownlint", + "vale", + ), + "lint", + ) config = LintConfig() if "exclude" in data: @@ -677,7 +765,9 @@ def _parse_lint(data: dict, root: Path) -> LintConfig: if "structure" in data: raw = data["structure"] try: - config.structure = load_rules(_path(root, raw, "lint.structure")) if isinstance(raw, str) else parse_rules(raw) + config.structure = ( + load_rules(_path(root, raw, "lint.structure")) if isinstance(raw, str) else parse_rules(raw) + ) except StructureRuleError as exc: raise ConfigError(str(exc)) from None config.root = root @@ -699,9 +789,13 @@ def _parse_lint(data: dict, root: Path) -> LintConfig: for term, variants in config.glossary.items(): for variant in variants: if variant in config.glossary and variant != term: - raise ConfigError(f"lint.glossary lists '{variant}' as a wrong spelling of '{term}', but it is a term of its own") + raise ConfigError( + f"lint.glossary lists '{variant}' as a wrong spelling of '{term}', but it is a term of its own" + ) if owner.setdefault(variant, term) != term: - raise ConfigError(f"lint.glossary lists '{variant}' as a wrong spelling of both '{owner[variant]}' and '{term}'") + raise ConfigError( + f"lint.glossary lists '{variant}' as a wrong spelling of both '{owner[variant]}' and '{term}'" + ) if "phrases" in data: for phrase, suggestion in _mapping(data["phrases"], "lint.phrases").items(): config.redundant_phrases[str(phrase)] = "" if suggestion is None else str(suggestion) @@ -723,7 +817,9 @@ def _parse_lint(data: dict, root: Path) -> LintConfig: config.acronym_require_definition = _typed(acronyms, "require_definition", bool, "lint.acronyms", True) config.acronym_scope = _typed(acronyms, "scope", str, "lint.acronyms", "file") if config.acronym_scope not in ACRONYM_SCOPES: - raise ConfigError(f"lint.acronyms.scope must be one of {', '.join(ACRONYM_SCOPES)}, not {config.acronym_scope!r}") + raise ConfigError( + f"lint.acronyms.scope must be one of {', '.join(ACRONYM_SCOPES)}, not {config.acronym_scope!r}" + ) if "suggestion" in acronyms: config.acronym_suggestion = "" if acronyms["suggestion"] is None else str(acronyms["suggestion"]) if "min_length" in acronyms: diff --git a/src/py_doc/convert.py b/src/py_doc/convert.py index 8d03a7f..b2ba7ff 100644 --- a/src/py_doc/convert.py +++ b/src/py_doc/convert.py @@ -55,9 +55,13 @@ def validate_template(self) -> None: return raise FileNotFoundError(f"{self.kind} template not found: {path}") if path.suffix.lower() in self.template_suffixes and not zipfile.is_zipfile(path): - raise ConversionError(f"Cannot read {self.kind} template '{path}': it is not a valid {' or '.join(self.template_suffixes)} file") + raise ConversionError( + f"Cannot read {self.kind} template '{path}': it is not a valid {' or '.join(self.template_suffixes)} file" + ) if path.suffix.lower() not in self.template_suffixes: - raise ConversionError(f"The {self.kind} template must be one of {', '.join(self.template_suffixes)}: {path}") + raise ConversionError( + f"The {self.kind} template must be one of {', '.join(self.template_suffixes)}: {path}" + ) def convert_file(self, md_path: Path, output_path: Path) -> Path: return self._render(md_path, output_path, self.fmt, self.template_path, self.options) diff --git a/src/py_doc/diagrams/registry.py b/src/py_doc/diagrams/registry.py index c9c630d..a1696e2 100644 --- a/src/py_doc/diagrams/registry.py +++ b/src/py_doc/diagrams/registry.py @@ -34,9 +34,9 @@ class Job: # which is also in the tool image, draws it instead and says so. _SVG_TO_PNG = ( 'inkscape "$1" --export-type=png --export-filename="$2" --export-dpi=' + str(DPI) + " --export-background=white" - ' --export-background-opacity=1 --export-area-drawing;' + " --export-background-opacity=1 --export-area-drawing;" ' test -s "$2" || { echo "inkscape wrote no picture, so librsvg draws it" >&2;' - ' rsvg-convert --dpi-x=' + str(DPI) + " --dpi-y=" + str(DPI) + ' --background-color=white -o "$2" "$1"; }' + " rsvg-convert --dpi-x=" + str(DPI) + " --dpi-y=" + str(DPI) + ' --background-color=white -o "$2" "$1"; }' ) @@ -58,7 +58,9 @@ class Renderer: def commands(self, job: Job) -> list[Command]: """The commands for ``job``. An SVG-only tool is followed by Inkscape when the document needs PNG.""" native = job.kind if job.kind in self.kinds else self.kinds[0] - commands = self.steps(Job(job.source, job.output.with_suffix(f".{native}"), native, job.landscape, job.source_dir)) + commands = self.steps( + Job(job.source, job.output.with_suffix(f".{native}"), native, job.landscape, job.source_dir) + ) if native == "svg" and job.kind == "png": commands.append(_svg_to_png(job)) return commands @@ -96,7 +98,12 @@ def _vega(tool: str) -> Callable[[Job], list[Command]]: RENDERERS: tuple[Renderer, ...] = ( Renderer("mermaid", ("mermaid",), ".mmd", ("png", "svg"), _mermaid), Renderer( - "plantuml", ("plantuml", "puml"), ".puml", ("png", "svg"), _plantuml, needs_source_dir=True, + "plantuml", + ("plantuml", "puml"), + ".puml", + ("png", "svg"), + _plantuml, + needs_source_dir=True, environment=lambda folder: {"JAVA_TOOL_OPTIONS": f"-Dplantuml.include.path={folder}"}, cacheable=lambda text: "!include" not in text, # an included file can change without the diagram changing ), # fmt: skip @@ -106,14 +113,32 @@ def _vega(tool: str) -> Callable[[Job], list[Command]]: Renderer(tool, (tool,), ".diag", ("png",), _blockdiag(tool)) for tool in ("blockdiag", "seqdiag", "actdiag", "nwdiag", "packetdiag") ), - Renderer("wavedrom", ("wavedrom",), ".json", ("svg",), lambda j: [["wavedrom-cli", "--input", j.source, "--svg", j.output]]), Renderer( - "bytefield-svg", ("bytefield-svg",), ".edn", ("svg",), + "wavedrom", + ("wavedrom",), + ".json", + ("svg",), + lambda j: [["wavedrom-cli", "--input", j.source, "--svg", j.output]], + ), + Renderer( + "bytefield-svg", + ("bytefield-svg",), + ".edn", + ("svg",), lambda j: [["bytefield-svg", "--source", j.source, "--output", j.output]], ), # fmt: skip - Renderer("pikchr", ("pikchr",), ".pikchr", ("svg",), lambda j: [["python3", TOOLS / "pikchr_render.py", j.source, j.output]]), Renderer( - "railroad", ("railroad",), ".railroad", ("svg",), + "pikchr", + ("pikchr",), + ".pikchr", + ("svg",), + lambda j: [["python3", TOOLS / "pikchr_render.py", j.source, j.output]], + ), + Renderer( + "railroad", + ("railroad",), + ".railroad", + ("svg",), lambda j: [["python3", TOOLS / "railroad_render.py", j.source, j.output]], ), # fmt: skip Renderer("wireviz", ("wireviz",), ".yml", ("svg",), lambda j: [["wireviz", "-f", "s", j.source]]), diff --git a/src/py_doc/diagrams/render.py b/src/py_doc/diagrams/render.py index 3584262..a46d695 100644 --- a/src/py_doc/diagrams/render.py +++ b/src/py_doc/diagrams/render.py @@ -103,7 +103,13 @@ def _draw(runtime: Runtime, renderer: Renderer, job_text: str, kind: str, landsc def draw_cached( - runtime: Runtime, renderer: Renderer, text: str, kind: str, landscape: bool, source_dir: Path, info: dict | None = None + runtime: Runtime, + renderer: Renderer, + text: str, + kind: str, + landscape: bool, + source_dir: Path, + info: dict | None = None, ) -> Path: """The picture for a diagram: from the cache when the same one was drawn before. ``info`` gets ``cached``.""" key = _key(runtime, renderer, Job(Path(), Path(), kind, landscape), text) @@ -219,7 +225,9 @@ def render_parts( events.append(event) except (ToolFailed, DiagramError) as exc: message = f"{file}:{block.line}: {block.fence} diagram failed: {_reason(exc)}" - event.update(status="failed", cached=False, seconds=round(time.monotonic() - started, 3), error=_reason(exc)) + event.update( + status="failed", cached=False, seconds=round(time.monotonic() - started, 3), error=_reason(exc) + ) if events is not None: events.append(event) if not keep_going: @@ -233,7 +241,7 @@ def render_parts( width = block.options.get("width") if width: if not _WIDTH.match(width): - raise DiagramError(f"{file}:{block.line}: width=\"{width}\" is not a size such as 100%, 12cm or 4in") + raise DiagramError(f'{file}:{block.line}: width="{width}" is not a size such as 100%, 12cm or 4in') picture_line += f"{{width={width}}}" if fmt == "docx" and _captioned(lines, block): picture_line += "\\ " # a no-break space: not alone in its paragraph, so pandoc adds no caption diff --git a/src/py_doc/diagrams/scan.py b/src/py_doc/diagrams/scan.py index a68b57f..be97f76 100644 --- a/src/py_doc/diagrams/scan.py +++ b/src/py_doc/diagrams/scan.py @@ -45,7 +45,12 @@ def find_blocks(text: str) -> list[Block]: end = index + 1 while end < len(lines): closing = _OPEN.match(lines[end]) - if closing and closing["fence"][0] == marker[0] and len(closing["fence"]) >= len(marker) and not closing["info"]: + if ( + closing + and closing["fence"][0] == marker[0] + and len(closing["fence"]) >= len(marker) + and not closing["info"] + ): break end += 1 body = "\n".join(lines[index + 1 : min(end, len(lines))]) diff --git a/src/py_doc/directives.py b/src/py_doc/directives.py index 9a11f4f..5af5d3d 100644 --- a/src/py_doc/directives.py +++ b/src/py_doc/directives.py @@ -42,7 +42,14 @@ _FENCE = re.compile(r"^ {0,3}(?P`{3,}|~{3,})") _INLINE_CODE = re.compile(r"(?P`+)(?P.+?)(?P=ticks)") -_LANGUAGES = {".yml": "yaml", ".md": "markdown", ".sh": "bash", ".py": "python", ".js": "javascript", ".ts": "typescript"} +_LANGUAGES = { + ".yml": "yaml", + ".md": "markdown", + ".sh": "bash", + ".py": "python", + ".js": "javascript", + ".ts": "typescript", +} @dataclass(frozen=True) diff --git a/src/py_doc/docx_import.py b/src/py_doc/docx_import.py index 08853e8..8c07940 100644 --- a/src/py_doc/docx_import.py +++ b/src/py_doc/docx_import.py @@ -29,7 +29,16 @@ logger = logging.getLogger(__name__) MAX_LIST_NESTING = 6 -MONOSPACE = {"consolas", "courier new", "courier", "lucida console", "menlo", "monaco", "source code pro", "cascadia code"} +MONOSPACE = { + "consolas", + "courier new", + "courier", + "lucida console", + "menlo", + "monaco", + "source code pro", + "cascadia code", +} CALLOUTS = {"note", "tip", "important", "warning", "caution"} QUOTE_STYLES = {"quote", "block text", "intense quote"} BITMAP_TYPES = {"image/jpeg", "image/jpg", "image/gif", "image/bmp", "image/tiff", "image/webp", "image/x-ms-bmp"} @@ -165,7 +174,9 @@ def _blocks(self, parent) -> None: elif child.tag == W_SDT: gallery = child.find(f"{qn('w:sdtPr')}/{qn('w:docPartObj')}/{qn('w:docPartGallery')}") content = child.find(qn("w:sdtContent")) - if content is not None and not (gallery is not None and "table of contents" in gallery.get(W_VAL, "").lower()): + if content is not None and not ( + gallery is not None and "table of contents" in gallery.get(W_VAL, "").lower() + ): self._blocks(content) elif child.tag == W_INS: self._blocks(child) @@ -188,7 +199,9 @@ def _paragraph(self, p, following) -> None: text = self._plain(tokens).strip() if self._is_toc(p, lower): return - only_images = tokens and all(isinstance(t, _Image) or (isinstance(t, _Piece) and not t.text.strip()) for t in tokens) + only_images = tokens and all( + isinstance(t, _Image) or (isinstance(t, _Piece) and not t.text.strip()) for t in tokens + ) if lower in ("title", "subtitle", "author", "date") and text: setattr(self, lower, text) return @@ -247,7 +260,7 @@ def _caption(self, text: str, following) -> None: self._close_list() match = re.match(r"(?i)^(figure|table)\s+\d+\s*[:.\-–—]?\s*", text) label = match.group(1).capitalize() if match else "" - text = text[match.end():] if match else text + text = text[match.end() :] if match else text previous = self.blocks[-1].kind if self.blocks else "" if not label: label = "Table" if (following is not None and following.tag == W_TBL) or previous == "table" else "Figure" @@ -305,12 +318,18 @@ def _number_format(self, num_id: str, depth: int) -> str: abstract = self._numbering.xpath(f"w:num[@w:numId='{num_id}']/w:abstractNumId/@w:val") if not abstract: return "decimal" - fmt = self._numbering.xpath(f"w:abstractNum[@w:abstractNumId='{abstract[0]}']/w:lvl[@w:ilvl='{depth}']/w:numFmt/@w:val") + fmt = self._numbering.xpath( + f"w:abstractNum[@w:abstractNumId='{abstract[0]}']/w:lvl[@w:ilvl='{depth}']/w:numFmt/@w:val" + ) return fmt[0] if fmt else "decimal" def _list_item(self, depth: int, marker: str, text: str) -> None: depth = min(depth, len(self._list_stack), MAX_LIST_NESTING) # no deeper than one below the item above - same_list = bool(self.blocks) and self.blocks[-1].kind == "list" and not (depth == 0 and self._list_stack[:1] not in ([], [marker])) + same_list = ( + bool(self.blocks) + and self.blocks[-1].kind == "list" + and not (depth == 0 and self._list_stack[:1] not in ([], [marker])) + ) del self._list_stack[depth:] indent = "".join(" " * (len(m) + 1) for m in self._list_stack) self._list_stack.append(marker) @@ -345,6 +364,7 @@ def _table(self, tbl) -> None: return columns = max(len(r) for r in rows) rows = [r + [""] * (columns - len(r)) for r in rows] + def line(cells: list[str]) -> str: return "|" + "|".join(f" {c} " if c else " " for c in cells) + "|" @@ -453,7 +473,9 @@ def _plain(self, tokens) -> str: return "".join(out) def _raw(self, tokens) -> str: - return "".join("\n" if isinstance(t, _Break) else t.text if isinstance(t, _Piece) else "" for t in tokens).rstrip() + return "".join( + "\n" if isinstance(t, _Break) else t.text if isinstance(t, _Piece) else "" for t in tokens + ).rstrip() def _monospace(self, tokens) -> bool: pieces = [t for t in tokens if isinstance(t, _Piece) and t.text.strip()] @@ -486,7 +508,7 @@ def _piece(self, piece: _Piece) -> str: core = text.strip() if not core: return text - lead, trail = text[: len(text) - len(text.lstrip())], text[len(text.rstrip()):] + lead, trail = text[: len(text) - len(text.lstrip())], text[len(text.rstrip()) :] if piece.code: ticks = "``" if "`" in core else "`" pad = " " if "`" in core else "" diff --git a/src/py_doc/generators/apidocs.py b/src/py_doc/generators/apidocs.py index 03c8daa..03afd68 100644 --- a/src/py_doc/generators/apidocs.py +++ b/src/py_doc/generators/apidocs.py @@ -55,7 +55,9 @@ def clean_markdown(text: str, heading_offset: int = 0, title: str | None = None, title. Then the headings move by ``heading_offset``, and ``title`` replaces the first top heading. Code blocks are left as they are. """ - text = _HTML_HEADING.sub(lambda m: f"{'#' * int(m.group(1))} {html.unescape(_TAG.sub('', m.group(2))).strip()}", text) + text = _HTML_HEADING.sub( + lambda m: f"{'#' * int(m.group(1))} {html.unescape(_TAG.sub('', m.group(2))).strip()}", text + ) text = _ASIDE.sub(lambda m: _callout(m.group(1), m.group(2)), text) text = _ANCHOR.sub(lambda m: f"[]{{#{m.group(1)}}}", text) text = _LINK.sub(lambda m: f"[{html.unescape(_TAG.sub('', m.group(2))).strip()}]({m.group(1)})", text) diff --git a/src/py_doc/generators/builtin.py b/src/py_doc/generators/builtin.py index 90ad384..ffefc70 100644 --- a/src/py_doc/generators/builtin.py +++ b/src/py_doc/generators/builtin.py @@ -38,7 +38,15 @@ "command": ("*run", "timeout"), "python": ("*callable",), } -DIAGRAM_LANGUAGES = {".mmd": "mermaid", ".mermaid": "mermaid", ".puml": "plantuml", ".plantuml": "plantuml", ".dot": "dot", ".gv": "dot", ".d2": "d2"} +DIAGRAM_LANGUAGES = { + ".mmd": "mermaid", + ".mermaid": "mermaid", + ".puml": "plantuml", + ".plantuml": "plantuml", + ".dot": "dot", + ".gv": "dot", + ".d2": "d2", +} # --- helpers ------------------------------------------------------------------------------------- @@ -126,7 +134,10 @@ def glossary(spec: GeneratorSpec, project: ProjectConfig) -> str: raise GeneratorError(f"{spec.options['source']} must map each term to its definition") rows = [[term, definition] for term, definition in sorted(data.items(), key=lambda item: str(item[0]).lower())] return _heading(spec, "Glossary") + _table(["Term", "Definition"], rows) - rows = [[term, ", ".join(variants)] for term, variants in sorted(project.lint.glossary.items(), key=lambda item: item[0].lower())] + rows = [ + [term, ", ".join(variants)] + for term, variants in sorted(project.lint.glossary.items(), key=lambda item: item[0].lower()) + ] return _heading(spec, "Glossary") + _table(["Term", "Not written as"], rows) @@ -145,7 +156,9 @@ def documents(spec: GeneratorSpec, project: ProjectConfig) -> str: doc = project.documents[name] first = next((s.file for s in doc.sources if s.include and s.structurizr is None), None) tree = _parsed(first) if first is not None and first.is_file() else None - meta = _combine_metadata(doc, read_metadata(tree) if tree else Metadata(), _first_heading(tree) if tree else None, project.root) + meta = _combine_metadata( + doc, read_metadata(tree) if tree else Metadata(), _first_heading(tree) if tree else None, project.root + ) target = first if link == "source" else project.output_path(doc, link, meta.version) title = f"[{_cell(meta.title)}]({_link(spec, target)})" if target is not None else _cell(meta.title) rows.append([title, meta.document_id or "", meta.version or "", meta.status or ""]) @@ -183,7 +196,11 @@ def adr_index(spec: GeneratorSpec, project: ProjectConfig) -> str: if not folder.is_dir(): raise GeneratorError(f"{folder} is not a folder") rows = [] - for file in sorted(p for p in folder.iterdir() if p.suffix.lower() in (".md", ".markdown") and p.stem.lower() not in ("readme", "index")): + for file in sorted( + p + for p in folder.iterdir() + if p.suffix.lower() in (".md", ".markdown") and p.stem.lower() not in ("readme", "index") + ): text = file.read_text(encoding="utf-8-sig") tree = parse(text) meta = read_metadata(tree) @@ -193,7 +210,9 @@ def adr_index(spec: GeneratorSpec, project: ProjectConfig) -> str: status = found.group(1) if found else _under_heading(tree, "status") number = re.match(r"^(\d+)", file.stem) title = meta.title or _first_heading(tree) or file.stem - rows.append([number.group(1) if number else "", f"[{_cell(title)}]({_link(spec, file)})", status or "", meta.date or ""]) + rows.append( + [number.group(1) if number else "", f"[{_cell(title)}]({_link(spec, file)})", status or "", meta.date or ""] + ) return _heading(spec, "Architecture decisions") + _table(["Number", "Decision", "Status", "Date"], rows) @@ -300,12 +319,15 @@ def openapi_summary(spec: GeneratorSpec, project: ProjectConfig) -> str: for method in _METHODS: operation = (item or {}).get(method) if isinstance(operation, dict): - rows.append([f"`{method.upper()}`", f"`{path}`", operation.get("summary") or operation.get("operationId") or ""]) + rows.append( + [f"`{method.upper()}`", f"`{path}`", operation.get("summary") or operation.get("operationId") or ""] + ) out += "## Operations\n\n" + (_table(["Method", "Path", "Summary"], rows) if rows else "None.") - schemas = ((api.get("components") or {}).get("schemas") or api.get("definitions") or {}) + schemas = (api.get("components") or {}).get("schemas") or api.get("definitions") or {} if schemas: out += "\n\n## Schemas\n\n" + "\n".join( - f"- `{name}`" + (f": {_cell((body or {}).get('description'))}" if (body or {}).get("description") else "") for name, body in schemas.items() + f"- `{name}`" + (f": {_cell((body or {}).get('description'))}" if (body or {}).get("description") else "") + for name, body in schemas.items() ) return out @@ -323,10 +345,19 @@ def asyncapi_summary(spec: GeneratorSpec, project: ProjectConfig) -> str: for name, channel in (api.get("channels") or {}).items(): channel = channel or {} address = channel.get("address") or name - operations = [(kind, channel[kind]) for kind in ("publish", "subscribe") if isinstance(channel.get(kind), dict)] # version 2 + operations = [ + (kind, channel[kind]) for kind in ("publish", "subscribe") if isinstance(channel.get(kind), dict) + ] # version 2 for kind, operation in operations: - message = (operation.get("message") or {}) - rows.append([f"`{address}`", kind, message.get("name") or message.get("title") or _ref(message), operation.get("summary") or channel.get("description") or ""]) + message = operation.get("message") or {} + rows.append( + [ + f"`{address}`", + kind, + message.get("name") or message.get("title") or _ref(message), + operation.get("summary") or channel.get("description") or "", + ] + ) if not operations: messages = ", ".join(str(m) for m in (channel.get("messages") or {})) rows.append([f"`{address}`", "", messages, channel.get("description") or channel.get("summary") or ""]) @@ -353,7 +384,9 @@ def python_api(spec: GeneratorSpec, project: ProjectConfig) -> str: out = _heading(spec, f"{source.stem} reference") for file in files: module = ".".join(file.relative_to(base).with_suffix("").parts).removesuffix(".__init__") - if not private and any(part.startswith("_") and part != "__init__" for part in file.relative_to(base).with_suffix("").parts): + if not private and any( + part.startswith("_") and part != "__init__" for part in file.relative_to(base).with_suffix("").parts + ): continue try: tree = ast.parse(file.read_text(encoding="utf-8-sig"), str(file)) @@ -361,7 +394,9 @@ def python_api(spec: GeneratorSpec, project: ProjectConfig) -> str: raise GeneratorError(f"{file} is not valid Python: {exc}") from exc body = [] for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and (private or not node.name.startswith("_")): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) and ( + private or not node.name.startswith("_") + ): body.append(_describe(node, 3, private)) doc = _summary(ast.get_docstring(tree)) if body or doc: @@ -380,7 +415,8 @@ def _describe(node: ast.AST, level: int, private: bool) -> str: methods = [ _describe(child, level + 1, private) for child in node.body - if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) and (private or not child.name.startswith("_") or child.name == "__init__") + if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)) + and (private or not child.name.startswith("_") or child.name == "__init__") ] return "\n\n".join([text, *methods]) prefix = "async " if isinstance(node, ast.AsyncFunctionDef) else "" @@ -413,13 +449,22 @@ def command(spec: GeneratorSpec, project: ProjectConfig) -> str: if not argv: raise GeneratorError(f"The generator {spec.name}: run must be a list, such as [python, tools/make_table.py]") try: - result = subprocess.run(argv, cwd=project.root, capture_output=True, encoding="utf-8", errors="replace", timeout=int(spec.options.get("timeout", 300))) + result = subprocess.run( + argv, + cwd=project.root, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=int(spec.options.get("timeout", 300)), + ) except FileNotFoundError as exc: raise GeneratorError(f"The generator {spec.name}: {argv[0]} is not found") from exc except subprocess.TimeoutExpired as exc: raise GeneratorError(f"The generator {spec.name}: {argv[0]} took longer than {exc.timeout} s") from exc if result.returncode != 0: - raise GeneratorError(f"The generator {spec.name}: {argv[0]} exited with status {result.returncode}: {result.stderr.strip()}") + raise GeneratorError( + f"The generator {spec.name}: {argv[0]} exited with status {result.returncode}: {result.stderr.strip()}" + ) return result.stdout diff --git a/src/py_doc/generators/registry.py b/src/py_doc/generators/registry.py index a13d0c0..7e3b111 100644 --- a/src/py_doc/generators/registry.py +++ b/src/py_doc/generators/registry.py @@ -88,7 +88,9 @@ def for_documents(project: ProjectConfig, documents: list[str]) -> list[str]: return [spec.name for spec in project.generators if spec.output.resolve() in sources] -def run_generators(project: ProjectConfig, names: list[str] | None = None, runtime: Callable | None = None) -> list[Path]: +def run_generators( + project: ProjectConfig, names: list[str] | None = None, runtime: Callable | None = None +) -> list[Path]: """Run the generators of ``project`` (or the ones named), in order. Returns the files that changed. ``runtime`` returns the tool container, for the generators that run a tool. Without it, one is connected @@ -106,23 +108,31 @@ def _run_all(project: ProjectConfig, names: list[str] | None) -> list[Path]: if names: unknown = [name for name in names if name not in {spec.name for spec in specs}] if unknown: - raise GeneratorError(f"Unknown generator(s): {', '.join(unknown)}. Defined: {', '.join(s.name for s in specs) or 'none'}") + raise GeneratorError( + f"Unknown generator(s): {', '.join(unknown)}. Defined: {', '.join(s.name for s in specs) or 'none'}" + ) specs = [spec for spec in specs if spec.name in names] registry = available() changed: list[Path] = [] for spec in specs: generate = load_callable(spec.options["callable"]) if spec.type == "python" else registry.get(spec.type) if generate is None: - raise GeneratorError(f"content.generators: '{spec.name}' has the unknown type '{spec.type}'. Types: {', '.join(sorted(registry))}") + raise GeneratorError( + f"content.generators: '{spec.name}' has the unknown type '{spec.type}'. Types: {', '.join(sorted(registry))}" + ) logger.info(f"Generating {spec.name} ({spec.type})") try: text = generate(spec, project) except GeneratorError: raise except Exception as exc: # a generator of someone else's must not hide where it failed - raise GeneratorError(f"The generator {spec.name} ({spec.type}) failed: {type(exc).__name__}: {exc}") from exc + raise GeneratorError( + f"The generator {spec.name} ({spec.type}) failed: {type(exc).__name__}: {exc}" + ) from exc if not isinstance(text, str): - raise GeneratorError(f"The generator {spec.name} ({spec.type}) returned {type(text).__name__}, not Markdown text") + raise GeneratorError( + f"The generator {spec.name} ({spec.type}) returned {type(text).__name__}, not Markdown text" + ) text = text.rstrip() + "\n" old = spec.output.read_text(encoding="utf-8") if spec.output.is_file() else None if old != text: diff --git a/src/py_doc/import_cli.py b/src/py_doc/import_cli.py index 800f865..5a98ed1 100644 --- a/src/py_doc/import_cli.py +++ b/src/py_doc/import_cli.py @@ -18,9 +18,15 @@ def build_parser() -> argparse.ArgumentParser: prog="py-doc import", description="Convert a Word document (.docx) or a PowerPoint deck (.pptx) to Markdown" ) parser.add_argument("input", type=Path, help="The .docx or .pptx file") - parser.add_argument("-o", "--output", type=Path, help="Folder for the .md file and its media folder (default: next to the input)") + parser.add_argument( + "-o", "--output", type=Path, help="Folder for the .md file and its media folder (default: next to the input)" + ) parser.add_argument("--force", action="store_true", help="Replace an existing .md file") - parser.add_argument("--no-defloat-images", action="store_true", help="Word: leave floating pictures out instead of placing them inline") + parser.add_argument( + "--no-defloat-images", + action="store_true", + help="Word: leave floating pictures out instead of placing them inline", + ) parser.add_argument("--print-output-paths", action="store_true", help="Print where the Markdown and the media went") noise = parser.add_mutually_exclusive_group() noise.add_argument("-q", "--quiet", action="store_true", help="Only show warnings and errors") diff --git a/src/py_doc/init.py b/src/py_doc/init.py index aa56043..2760802 100644 --- a/src/py_doc/init.py +++ b/src/py_doc/init.py @@ -38,12 +38,21 @@ class Choices: def build_parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(prog="py-doc init", description="Start a documentation project: py-doc.yml, a first document and more") + parser = argparse.ArgumentParser( + prog="py-doc init", description="Start a documentation project: py-doc.yml, a first document and more" + ) parser.add_argument("folder", nargs="?", default=".", type=Path, help="Where to make it (default: here)") parser.add_argument("--name", help="The project name (default: the folder name)") parser.add_argument("--site", choices=(*SITE_ENGINES, "none"), help="Also make a web site project") - parser.add_argument("--c4", action="store_true", default=None, help="Also make a Structurizr C4 model and a document from it") - parser.add_argument("--templates", action="store_true", default=None, help="Also write Word and PowerPoint templates (needs the tool container)") + parser.add_argument( + "--c4", action="store_true", default=None, help="Also make a Structurizr C4 model and a document from it" + ) + parser.add_argument( + "--templates", + action="store_true", + default=None, + help="Also write Word and PowerPoint templates (needs the tool container)", + ) parser.add_argument("-y", "--yes", action="store_true", help="Ask nothing: take the defaults for what is not given") noise = parser.add_mutually_exclusive_group() noise.add_argument("-q", "--quiet", action="store_true", help="Only show warnings and errors") @@ -69,7 +78,11 @@ def choose(args: argparse.Namespace, folder: Path, ask: Callable[[str], str] | N while site not in (*SITE_ENGINES, "none"): site = _ask(f"Web site: {', '.join(SITE_ENGINES)} or none", "none", ask).lower() c4 = args.c4 if args.c4 is not None else (_yes("A C4 architecture model (Structurizr)?", ask) if ask else False) - templates = args.templates if args.templates is not None else (_yes("Word and PowerPoint templates?", ask) if ask else False) + templates = ( + args.templates + if args.templates is not None + else (_yes("Word and PowerPoint templates?", ask) if ask else False) + ) return Choices(name, None if site in (None, "none") else site, c4, templates) @@ -88,7 +101,9 @@ def config_text(choices: Choices) -> str: text = text.replace(" # template: templates/reference.docx", " template: templates/reference.docx") text = text.replace(" # template: templates/reference.pptx", " template: templates/reference.pptx") if choices.c4: - text = text.replace("\n# A JSON report", "\n" + ARCHITECTURE_DOC.replace("{name}", choices.name) + "# A JSON report", 1) + text = text.replace( + "\n# A JSON report", "\n" + ARCHITECTURE_DOC.replace("{name}", choices.name) + "# A JSON report", 1 + ) if choices.site: text += SITE.format(engine=choices.site) return text @@ -99,7 +114,9 @@ def make_project(folder: Path, choices: Choices, runtime=connect) -> list[Path]: folder = Path(folder) for name in CONFIG_NAMES: if (folder / name).exists(): - raise ConversionError(f"{folder / name} is already there: this folder is a py-doc project. Nothing was changed") + raise ConversionError( + f"{folder / name} is already there: this folder is a py-doc project. Nothing was changed" + ) files = project_files(choices) try: # a configuration that would not load is a bug: say so before anything is written parse_config(yaml.safe_load(files["py-doc.yml"]), folder.resolve()) @@ -161,7 +178,11 @@ def _templates(folder: Path, runtime) -> list[Path]: written.append(wanted[0]) if not wanted[1].exists(): made = work / "reference.pptx" - box.run(["pandoc", "-o", made, "--print-default-data-file", "reference.pptx"], mounts=[Mount(work, writable=True)], workdir=work) + box.run( + ["pandoc", "-o", made, "--print-default-data-file", "reference.pptx"], + mounts=[Mount(work, writable=True)], + workdir=work, + ) wanted[1].write_bytes(made.read_bytes()) written.append(wanted[1]) return written diff --git a/src/py_doc/lint/cli.py b/src/py_doc/lint/cli.py index f4f3abd..2a4e66a 100644 --- a/src/py_doc/lint/cli.py +++ b/src/py_doc/lint/cli.py @@ -64,7 +64,9 @@ def run(argv: list[str] | None = None, legacy: bool = False) -> int: parser.add_argument( "--exclude", action="append", default=[], metavar="DIR", help="Also skip directories with this name" ) - parser.add_argument("--config", metavar="FILE", help="py-doc.yml to read (default: the nearest one above the first target)") + parser.add_argument( + "--config", metavar="FILE", help="py-doc.yml to read (default: the nearest one above the first target)" + ) parser.add_argument( "--engine", choices=("auto", "builtin", "vale"), default="auto", help="auto runs the built-in rules and Vale when it is installed, builtin skips Vale, vale uses Vale for the text rules", @@ -74,9 +76,15 @@ def run(argv: list[str] | None = None, legacy: bool = False) -> int: help="text (default), csv for a spreadsheet, github for workflow annotations, json, or sarif for code scanning", ) # fmt: skip parser.add_argument("--output", metavar="FILE", help="Write the report to this file instead of standard output") - parser.add_argument("--show-vale-config", action="store_true", help="Print the Vale configuration py-doc would use and exit") - parser.add_argument("--check-engines", action="store_true", help="Report whether Vale is found, its version and its folder") - parser.add_argument("--sync", action="store_true", help="Download the Vale packages in lint.vale.packages (vale sync)") + parser.add_argument( + "--show-vale-config", action="store_true", help="Print the Vale configuration py-doc would use and exit" + ) + parser.add_argument( + "--check-engines", action="store_true", help="Report whether Vale is found, its version and its folder" + ) + parser.add_argument( + "--sync", action="store_true", help="Download the Vale packages in lint.vale.packages (vale sync)" + ) parser.add_argument( "--print-example-rules", action="store_true", help="Print an example set of structure rules and exit" ) @@ -98,7 +106,10 @@ def run(argv: list[str] | None = None, legacy: bool = False) -> int: return 2 config.exclude_dirs = config.exclude_dirs | frozenset(args.exclude) linter = DocLinter( - config=config, runtime=lambda: connect(settings), engine=args.engine, extra_checks=_model_checks(args, config, settings) + config=config, + runtime=lambda: connect(settings), + engine=args.engine, + extra_checks=_model_checks(args, config, settings), ) try: if args.show_vale_config or args.check_engines or args.sync: @@ -135,12 +146,15 @@ def runtime(): for file in files: try: - ws = load_workspace(workspace_json(file, project.root / ".py-doc" / "c4" / "json", runtime, project.c4.cli)) + ws = load_workspace( + workspace_json(file, project.root / ".py-doc" / "c4" / "json", runtime, project.c4.cli) + ) except EnvironmentProblem as exc: logger.warning("The model rules for %s were skipped: %s", file.name, exc) continue found += [ - LintIssue(file.resolve(), 1, i.rule, "C4 model", i.message, i.severity) for i in check_model(ws, file, config) + LintIssue(file.resolve(), 1, i.rule, "C4 model", i.message, i.severity) + for i in check_model(ws, file, config) ] return found diff --git a/src/py_doc/lint/config.py b/src/py_doc/lint/config.py index 505f908..10b95fe 100644 --- a/src/py_doc/lint/config.py +++ b/src/py_doc/lint/config.py @@ -82,10 +82,20 @@ class LintConfig: disabled_rules: frozenset[str] = frozenset() severities: dict[str, str] = field( default_factory=lambda: { - RULE_GLOSSARY: "warning", RULE_REDUNDANT: "warning", RULE_ACRONYM: "info", RULE_DIRECTIVE: "error", - RULE_HEADING_JUMP: "warning", RULE_BROKEN_LINK: "warning", RULE_IMAGE_ALT: "warning", - RULE_SENTENCE_LENGTH: "info", RULE_HEADING_LENGTH: "info", RULE_DUPLICATE_HEADING: "info", - RULE_UNCLOSED_FENCE: "error", RULE_REQUIRED_SECTION: "warning", RULE_TERMINOLOGY: "info", **C4_RULES, + RULE_GLOSSARY: "warning", + RULE_REDUNDANT: "warning", + RULE_ACRONYM: "info", + RULE_DIRECTIVE: "error", + RULE_HEADING_JUMP: "warning", + RULE_BROKEN_LINK: "warning", + RULE_IMAGE_ALT: "warning", + RULE_SENTENCE_LENGTH: "info", + RULE_HEADING_LENGTH: "info", + RULE_DUPLICATE_HEADING: "info", + RULE_UNCLOSED_FENCE: "error", + RULE_REQUIRED_SECTION: "warning", + RULE_TERMINOLOGY: "info", + **C4_RULES, } # fmt: skip ) structure: list = field(default_factory=list) # StructureRule entries, see lint.structure @@ -94,4 +104,6 @@ class LintConfig: root: Path = field(default_factory=Path.cwd) # the project folder: where the managed Vale folder lives documents: dict[str, list[Path]] = field(default_factory=dict) # document id -> its source files, in order generated: frozenset[Path] = frozenset() # files content generators write: not missing before they run - posters: frozenset[Path] = frozenset() # poster sources: their prose is linted, but not by the document structure rules + posters: frozenset[Path] = ( + frozenset() + ) # poster sources: their prose is linted, but not by the document structure rules diff --git a/src/py_doc/lint/formats.py b/src/py_doc/lint/formats.py index dac69b8..545baf6 100644 --- a/src/py_doc/lint/formats.py +++ b/src/py_doc/lint/formats.py @@ -38,7 +38,12 @@ def summary(report: LintReport) -> dict: counts = {severity: 0 for severity in ("error", "warning", "info")} for issue in report.issues: counts[issue.severity] = counts.get(issue.severity, 0) + 1 - return {"files_scanned": report.files_scanned, "issues": len(report.issues), **counts, "files_failed": len(report.errors)} + return { + "files_scanned": report.files_scanned, + "issues": len(report.issues), + **counts, + "files_failed": len(report.errors), + } def to_json(report: LintReport, issues: list[LintIssue], errors: list[FileResult]) -> str: @@ -75,13 +80,24 @@ def to_sarif(issues: list[LintIssue], errors: list[FileResult]) -> str: } ) run = { - "tool": {"driver": {"name": "py-doc", "version": __version__, "informationUri": "https://github.com/markbac/py-doc", "rules": list(rules.values())}}, + "tool": { + "driver": { + "name": "py-doc", + "version": __version__, + "informationUri": "https://github.com/markbac/py-doc", + "rules": list(rules.values()), + } + }, "results": results, "invocations": [ { "executionSuccessful": not errors, "toolExecutionNotifications": [ - {"level": "error", "message": {"text": result.error}, "locations": [{"physicalLocation": {"artifactLocation": {"uri": _path(result.file)}}}]} + { + "level": "error", + "message": {"text": result.error}, + "locations": [{"physicalLocation": {"artifactLocation": {"uri": _path(result.file)}}}], + } for result in errors ], } diff --git a/src/py_doc/lint/linter.py b/src/py_doc/lint/linter.py index 9ea7cf4..e2062f0 100644 --- a/src/py_doc/lint/linter.py +++ b/src/py_doc/lint/linter.py @@ -71,7 +71,9 @@ def __init__( """ self.runtime = runtime self.engine = engine - self.extra_checks = list(extra_checks or []) # checks of things other than Markdown, such as a Structurizr model + self.extra_checks = list( + extra_checks or [] + ) # checks of things other than Markdown, such as a Structurizr model self.config = config or LintConfig() if glossary is not None: self.config.glossary = glossary @@ -128,7 +130,9 @@ def _required_sections(self, report: LintReport) -> None: files = [s.resolve() for s in sources if s.exists()] if not files or not scanned.intersection(files): continue - titles = [title for file in files if (tree := self._parsed(file)) is not None for title in rules.headings(tree)] + titles = [ + title for file in files if (tree := self._parsed(file)) is not None for title in rules.headings(tree) + ] found += rules.required_sections(doc_id, files, titles, self.config) report.add([self._graded(issue) for issue in found]) @@ -136,7 +140,11 @@ def _terminology(self, report: LintReport) -> None: """One spelling of each hyphenated word over every file linted together.""" if RULE_TERMINOLOGY in self.config.disabled_rules: return - prose = [(r.file, segments(tree)) for r in report.results if r.error is None and (tree := self._parsed(r.file)) is not None] + prose = [ + (r.file, segments(tree)) + for r in report.results + if r.error is None and (tree := self._parsed(r.file)) is not None + ] report.add([self._graded(issue) for issue in rules.terminology(prose, self.config)]) def _document_acronyms(self, report: LintReport) -> None: @@ -241,13 +249,17 @@ def _vale(self, report: LintReport, target: Path) -> None: files = [result.file for result in report.results if result.error is None] try: if settings.config is not None: - logger.info("Using %s as it is: lint.vale rules, terms and avoid patterns are not applied", settings.config) + logger.info( + "Using %s as it is: lint.vale rules, terms and avoid patterns are not applied", settings.config + ) ini = settings.config else: ini = vale.build_workspace(settings, self.config, self.vale_folder()).ini absent = vale.missing_packages(settings, self.vale_folder()) if absent: - raise ConversionError(f"Vale packages not downloaded: {', '.join(absent)}. Run `py-doc lint --sync`.") + raise ConversionError( + f"Vale packages not downloaded: {', '.join(absent)}. Run `py-doc lint --sync`." + ) found = vale.run_vale(files, runner, ini, settings, Path.cwd()) except vale.ValeError as exc: # a broken run is an error on every file, never a clean result for result in report.results: diff --git a/src/py_doc/lint/rules.py b/src/py_doc/lint/rules.py index 9498b8c..04c5fbb 100644 --- a/src/py_doc/lint/rules.py +++ b/src/py_doc/lint/rules.py @@ -89,7 +89,9 @@ def _expands(words: list[str], acronym: str) -> bool: letters = iter(acronym.lower()) if words[0][0].lower() != acronym[0].lower(): return False - return all(any(letter == word[0].lower() for letter in letters) for word in words if word.lower() not in _STOP_WORDS) + return all( + any(letter == word[0].lower() for letter in letters) for word in words if word.lower() not in _STOP_WORDS + ) def _definitions(text: str) -> list[tuple[str, int]]: @@ -206,7 +208,11 @@ def image_alt(file: Path, document: n.Document, config: LintConfig) -> list[Lint """A picture without alt text, which a screen reader cannot describe and a caption cannot come from.""" return [ LintIssue( - file, node.line, RULE_IMAGE_ALT, "Accessibility", f"Picture '{node.url}' has no alt text.", + file, + node.line, + RULE_IMAGE_ALT, + "Accessibility", + f"Picture '{node.url}' has no alt text.", suggestion="Describe the picture between the brackets: ![What it shows](file.png)", ) # fmt: skip for node in n.walk(document) @@ -280,7 +286,11 @@ def unclosed_fences(file: Path, document: n.Document, config: LintConfig) -> lis """A code fence that is never closed, so the rest of the file becomes code.""" return [ LintIssue( - file, node.line, RULE_UNCLOSED_FENCE, "Structure", "Code fence is never closed: the rest of the file is code.", + file, + node.line, + RULE_UNCLOSED_FENCE, + "Structure", + "Code fence is never closed: the rest of the file is code.", suggestion="Close it with the same fence (``` or ~~~) on a line of its own", ) # fmt: skip for node in n.walk(document) @@ -299,7 +309,11 @@ def required_sections(doc_id: str, files: list[Path], found: list[str], config: have = {_bare(text) for text in found} return [ LintIssue( - files[0], 1, RULE_REQUIRED_SECTION, "Structure", f"Document '{doc_id}' has no '{section}' section.", + files[0], + 1, + RULE_REQUIRED_SECTION, + "Structure", + f"Document '{doc_id}' has no '{section}' section.", suggestion=f"Add a heading '{section}'", ) # fmt: skip for section in wanted diff --git a/src/py_doc/lint/structure.py b/src/py_doc/lint/structure.py index 0c73b54..a410af0 100644 --- a/src/py_doc/lint/structure.py +++ b/src/py_doc/lint/structure.py @@ -280,15 +280,21 @@ def _balanced(file: Path, lines: list[str], rule: StructureRule) -> list[LintIss issues: list[LintIssue] = [] for number, line in live_lines(lines): spans = _spans(line) - events = [(m.start(), "open", m.group(0)) for m in rule.open_pattern.finditer(line) if not _inside(m.start(), spans)] - events += [(m.start(), "close", m.group(0)) for m in rule.close_pattern.finditer(line) if not _inside(m.start(), spans)] + events = [ + (m.start(), "open", m.group(0)) for m in rule.open_pattern.finditer(line) if not _inside(m.start(), spans) + ] + events += [ + (m.start(), "close", m.group(0)) for m in rule.close_pattern.finditer(line) if not _inside(m.start(), spans) + ] for _, kind, marker in sorted(events): if kind == "open": stack.append((number, marker)) elif stack: stack.pop() else: - issues.append(_issue(rule, file, number, (rule.unmatched_close_message or rule.message).format(marker=marker))) + issues.append( + _issue(rule, file, number, (rule.unmatched_close_message or rule.message).format(marker=marker)) + ) for number, marker in stack: issues.append(_issue(rule, file, number, (rule.unmatched_open_message or rule.message).format(marker=marker))) return issues @@ -323,7 +329,9 @@ def captioned(after: int, rule: StructureRule, first: int | None = None) -> bool if code := _CODE.match(line): marker, language = code.group(1), code.group(2).lower() end = i + 1 - while end < count and not (lines[end].lstrip().startswith(marker[0] * len(marker)) and not lines[end].strip(marker[0])): + while end < count and not ( + lines[end].lstrip().startswith(marker[0] * len(marker)) and not lines[end].strip(marker[0]) + ): end += 1 wanted = fenced is not None and (not fenced.languages or language in fenced.languages) if wanted and not captioned(end + 1, fenced, i): diff --git a/src/py_doc/lint/vale.py b/src/py_doc/lint/vale.py index c8ee7bf..d007ffc 100644 --- a/src/py_doc/lint/vale.py +++ b/src/py_doc/lint/vale.py @@ -71,7 +71,9 @@ def __init__(self, binary: str, folder: Path, base: Path) -> None: def run(self, argv, *, writable=False, network=False): command = [self.binary, *[str(a) for a in argv]] try: - done = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", timeout=TIMEOUT, check=False) + done = subprocess.run( + command, capture_output=True, text=True, encoding="utf-8", timeout=TIMEOUT, check=False + ) except FileNotFoundError as exc: raise EnvironmentProblem(f"Vale was not found at '{self.binary}'. {INSTALL_HELP}") from exc except subprocess.TimeoutExpired as exc: @@ -116,7 +118,11 @@ class Workspace: def _rule_levels(settings: ValeSettings) -> dict[str, str]: - return {rule_id: ("NO" if rule.off else rule.level) for rule_id, rule in settings.rules.items() if rule.off or rule.level} + return { + rule_id: ("NO" if rule.off else rule.level) + for rule_id, rule in settings.rules.items() + if rule.off or rule.level + } def build_workspace(settings: ValeSettings, config: LintConfig, folder: Path) -> Workspace: @@ -137,7 +143,12 @@ def put(name: str, data: dict | str) -> None: # DOC001 to DOC003 turned off in py-doc.yml also turn off their Vale rules. off = { stem - for stem, rule in (("Terms", RULE_GLOSSARY), ("Wordy", RULE_REDUNDANT), ("Redundant", RULE_REDUNDANT), ("Acronyms", RULE_ACRONYM)) + for stem, rule in ( + ("Terms", RULE_GLOSSARY), + ("Wordy", RULE_REDUNDANT), + ("Redundant", RULE_REDUNDANT), + ("Acronyms", RULE_ACRONYM), + ) if rule in config.disabled_rules } for name, text in _bundled(): @@ -249,7 +260,9 @@ def check_version(runner: Runner) -> tuple[int, ...]: raise ValeError(f"Vale did not report its version: {(out + err).strip() or 'no output'}") if version < MIN_VERSION: needed = ".".join(map(str, MIN_VERSION)) - raise EnvironmentProblem(f"Vale {'.'.join(map(str, version))} is too old. py-doc needs {needed} or newer. {INSTALL_HELP}") + raise EnvironmentProblem( + f"Vale {'.'.join(map(str, version))} is too old. py-doc needs {needed} or newer. {INSTALL_HELP}" + ) return version @@ -291,15 +304,11 @@ def parse_output(text: str, files: list[Path]) -> list[LintIssue]: suggestion = str(params[0]) if action.get("Name") == "replace" and params else None span = alert.get("Span") or [] column = int(span[0]) if span else None - issues.append( - LintIssue(path, line, check, check.split(".")[0], message, severity, suggestion, column) - ) + issues.append(LintIssue(path, line, check, check.split(".")[0], message, severity, suggestion, column)) return issues -def run_vale( - files: list[Path], runner: Runner, ini: Path, settings: ValeSettings, base: Path -) -> list[LintIssue]: +def run_vale(files: list[Path], runner: Runner, ini: Path, settings: ValeSettings, base: Path) -> list[LintIssue]: """Run Vale on ``files``. Raises :class:`ValeError` when it fails, so a broken run is never a clean one.""" files = selected([f.resolve() for f in files], settings, base) if not files: diff --git a/src/py_doc/lint/vale_config.py b/src/py_doc/lint/vale_config.py index 9412d44..651fd2d 100644 --- a/src/py_doc/lint/vale_config.py +++ b/src/py_doc/lint/vale_config.py @@ -148,7 +148,9 @@ def _merge(settings: ValeSettings, data: dict, where: str, root: Path, depth: in settings.min_alert_level = _level(data["min_alert_level"], f"{where}.min_alert_level") if "packages" in data: packages = data["packages"] - if not isinstance(packages, list) or not all(isinstance(p, str) and re.fullmatch(r"[\w.-]+", p) for p in packages): + if not isinstance(packages, list) or not all( + isinstance(p, str) and re.fullmatch(r"[\w.-]+", p) for p in packages + ): raise ValeConfigError(f"{where}.packages must be a list of package names") settings.packages = list(dict.fromkeys([*settings.packages, *packages])) if "spelling" in data: diff --git a/src/py_doc/markdown/parser.py b/src/py_doc/markdown/parser.py index 9432639..ef9fe84 100644 --- a/src/py_doc/markdown/parser.py +++ b/src/py_doc/markdown/parser.py @@ -218,7 +218,9 @@ def _inline_run( alt_nodes, _ = self._inline_run(token.children or [], 0, None, [line]) title = token.attrs.get("title") nodes.append( - n.Image(line, str(token.attrs.get("src", "")), str(title) if title else None, n.plain_text(alt_nodes)) + n.Image( + line, str(token.attrs.get("src", "")), str(title) if title else None, n.plain_text(alt_nodes) + ) ) else: raise ParseError(f"Unsupported inline Markdown '{kind}' at line {line}") diff --git a/src/py_doc/migrate.py b/src/py_doc/migrate.py index b67bef0..b18dfbb 100644 --- a/src/py_doc/migrate.py +++ b/src/py_doc/migrate.py @@ -178,9 +178,18 @@ def rel(path: Path) -> str: for key, reason in _UNUSED_CLI.items(): if cli.get(key): result.note("warning", yaml_path, f"cli.{key} was left out: {reason}") - built = {"docx": flags["emit-docx"], "pptx": flags["emit-pptx"], "pdf": flags["emit-pdf"], "html": flags["emit-html"]} + built = { + "docx": flags["emit-docx"], + "pptx": flags["emit-pptx"], + "pdf": flags["emit-pdf"], + "html": flags["emit-html"], + } if not any(built.values()): - result.note("warning", yaml_path, "createdocs built no Word, PowerPoint, PDF or HTML file for this document. py-doc builds a Word file") + result.note( + "warning", + yaml_path, + "createdocs built no Word, PowerPoint, PDF or HTML file for this document. py-doc builds a Word file", + ) built["docx"] = True outputs: dict[str, Any] = {} for fmt, wanted in built.items(): diff --git a/src/py_doc/naming.py b/src/py_doc/naming.py index 0eaaf21..c27728e 100644 --- a/src/py_doc/naming.py +++ b/src/py_doc/naming.py @@ -53,7 +53,9 @@ def status_template(base: Path | None, status: str | None) -> Path | None: if candidate.is_file(): logger.info(f"Using the template for status '{status}': {candidate}") return candidate - logger.info(f"No template for status '{status}' (looked for {', '.join(c.name for c in candidates)}), so {base} is used") + logger.info( + f"No template for status '{status}' (looked for {', '.join(c.name for c in candidates)}), so {base} is used" + ) return base @@ -68,7 +70,9 @@ def check_name_pattern(pattern: str, where: str) -> None: """Raise ValueError for a pattern with an unknown field or a stray brace.""" unknown = [field for field in _FIELD.findall(pattern) if field not in FIELDS] if unknown: - raise ValueError(f"{where} uses unknown field {{{unknown[0]}}}. Fields: {', '.join('{' + f + '}' for f in FIELDS)}") + raise ValueError( + f"{where} uses unknown field {{{unknown[0]}}}. Fields: {', '.join('{' + f + '}' for f in FIELDS)}" + ) if "{" in _FIELD.sub("", pattern) or "}" in _FIELD.sub("", pattern): raise ValueError(f"{where} has a brace that is not part of a field") diff --git a/src/py_doc/new.py b/src/py_doc/new.py index 8a00deb..b8f50a7 100644 --- a/src/py_doc/new.py +++ b/src/py_doc/new.py @@ -45,7 +45,11 @@ FORMATS = {"docx": "Word", "pptx": "PowerPoint"} _SEQUENCE = re.compile(r"(\d+)$") _CODE = re.compile(r"^[A-Za-z0-9]*$") -LISTS = {"prefixes": "G460, G470", "types": "TN, OV, SP", "classifications": "Confidential, Internal"} # with an example +LISTS = { + "prefixes": "G460, G470", + "types": "TN, OV, SP", + "classifications": "Confidential, Internal", +} # with an example class NewError(Exception): @@ -165,7 +169,15 @@ def build_file(meta: dict[str, str], output: str, fmt: str, source: str) -> str: lines += ["", "cli:"] if fmt == "docx": lines.append(" emit-docx: true") - lines += [f" output: {output}", f" # template: path/to/Template.{fmt}", "", "entries:", f" - file: {source}", " heading_offset: 0", ""] + lines += [ + f" output: {output}", + f" # template: path/to/Template.{fmt}", + "", + "entries:", + f" - file: {source}", + " heading_offset: 0", + "", + ] return "\n".join(lines) @@ -190,19 +202,37 @@ def build_parser() -> argparse.ArgumentParser: "markdown", nargs="?", type=Path, metavar="FILE.md", help="An existing Markdown file to number: its build file is written beside it, and the file is not changed", ) # fmt: skip - parser.add_argument("--title", help="The document title (needed; default for FILE.md: its front matter title or first heading)") - parser.add_argument("--type", dest="kind", help="The type code in the number, such as TN: one of the register's types when it lists them, else anything or empty") + parser.add_argument( + "--title", help="The document title (needed; default for FILE.md: its front matter title or first heading)" + ) + parser.add_argument( + "--type", + dest="kind", + help="The type code in the number, such as TN: one of the register's types when it lists them, else anything or empty", + ) parser.add_argument("--format", choices=sorted(FORMATS), help="Word (docx) or PowerPoint (pptx). Default: docx") parser.add_argument("--author", help="Default: git config user.name") - parser.add_argument("--classification", help="Such as Confidential: one of the register's classifications when it lists them, else anything") + parser.add_argument( + "--classification", + help="Such as Confidential: one of the register's classifications when it lists them, else anything", + ) parser.add_argument("--description", help="One sentence on what the document covers") parser.add_argument( "--prefix", help="The number prefix, such as G460 (default: the register's prefix). One of its prefixes when it lists them. " "When the register is made, it becomes its default", ) - parser.add_argument("--scan", action="store_true", help="Add the legacy build files not in the register yet (clashes included), and make no document") - parser.add_argument("--start", type=int, metavar="N", help="New numbers begin at N, skipping any taken (kept in the register), and make no document") + parser.add_argument( + "--scan", + action="store_true", + help="Add the legacy build files not in the register yet (clashes included), and make no document", + ) + parser.add_argument( + "--start", + type=int, + metavar="N", + help="New numbers begin at N, skipping any taken (kept in the register), and make no document", + ) parser.add_argument("-y", "--yes", action="store_true", help="Ask nothing: take the defaults for what is not given") noise = parser.add_mutually_exclusive_group() noise.add_argument("-q", "--quiet", action="store_true", help="Only show warnings and errors") @@ -279,7 +309,12 @@ def extra(key: str) -> str: return str(meta.extra.get(key) or "").strip() return ExistingMarkdown( - path, meta.title or first_heading(body) or "", meta.author or "", meta.version or "", extra("classification"), extra("description") + path, + meta.title or first_heading(body) or "", + meta.author or "", + meta.version or "", + extra("classification"), + extra("description"), ) @@ -292,7 +327,9 @@ def _not_registered(markdown: Path, register: Register, root: Path) -> None: raise NewError(f"{markdown.name} is already a source of {entry.file}, number {entry.number}") -def make(folder: Path, args: argparse.Namespace, ask: Callable[[str], str] | None, today: datetime.date) -> tuple[list[Path], Register, bool]: +def make( + folder: Path, args: argparse.Namespace, ask: Callable[[str], str] | None, today: datetime.date +) -> tuple[list[Path], Register, bool]: """Write the build file and its Markdown in ``folder`` and record the number. Returns what was written. With ``args.markdown``, the build file goes beside that file and names it; the Markdown is not written. @@ -330,7 +367,11 @@ def make(folder: Path, args: argparse.Namespace, ask: Callable[[str], str] | Non raise NewError(f"'{fmt}' is not docx or pptx") author = value("Author", args.author, (existing.author if existing else "") or _git_name()).strip() classification = pick( - "Classification", args.classification, existing.classification if existing else "", allowed.get("classifications"), "--classification" + "Classification", + args.classification, + existing.classification if existing else "", + allowed.get("classifications"), + "--classification", ) description = value("Description", args.description, existing.description if existing else "").strip() @@ -399,7 +440,9 @@ def run_new(argv: Sequence[str] | None = None) -> int: for entry in added: print(f"added {entry.number} {entry.file}") _report_clashes(register) - print(f"register {os.path.relpath(register.path)}: {len(register.entries or [])} number(s), the next ends in {register.next_number('').rsplit('-', 1)[-1]}") + print( + f"register {os.path.relpath(register.path)}: {len(register.entries or [])} number(s), the next ends in {register.next_number('').rsplit('-', 1)[-1]}" + ) return 0 try: written, register, created = make(Path.cwd(), args, ask, datetime.date.today()) @@ -416,5 +459,7 @@ def run_new(argv: Sequence[str] | None = None) -> int: for path in written: print(f"written {Path(os.path.relpath(path))}") print(f"number {register.entries[-1].number}, recorded in {shown}") - print(f"\nNext: commit {REGISTER} with the new files, then `py-doc build --legacy {Path(os.path.relpath(written[0]))}`") + print( + f"\nNext: commit {REGISTER} with the new files, then `py-doc build --legacy {Path(os.path.relpath(written[0]))}`" + ) return 0 diff --git a/src/py_doc/pandoc/docx_finish.py b/src/py_doc/pandoc/docx_finish.py index e85d1ab..be7da02 100644 --- a/src/py_doc/pandoc/docx_finish.py +++ b/src/py_doc/pandoc/docx_finish.py @@ -66,7 +66,9 @@ def finish_docx(body_path: Path, template: Path | None, metadata: Metadata, opti value = text(read(metadata)) replacements[prefix + name] = value if not value and prefix + name in present: - logger.warning(f"The template has {prefix + name}, but the document has no {name.lower().replace('-', ' ')}") + logger.warning( + f"The template has {prefix + name}, but the document has no {name.lower().replace('-', ' ')}" + ) post.replace_placeholders(base, replacements) index = post.find_and_remove_marker(base, options.get("marker", DEFAULT_MARKER)) body = Document(str(body_path)) @@ -78,7 +80,11 @@ def finish_docx(body_path: Path, template: Path | None, metadata: Metadata, opti marker = options.get("marker", DEFAULT_MARKER) kept = [p.text.strip() for p in base.paragraphs if p.text.strip()] if kept or base.tables: # template text in a deliverable must never pass unnoticed (#60) - shown = f"'{kept[0][:60]}'" + (f" and {len(kept) - 1} more paragraph(s)" if len(kept) > 1 else "") if kept else "a table" + shown = ( + f"'{kept[0][:60]}'" + (f" and {len(kept) - 1} more paragraph(s)" if len(kept) > 1 else "") + if kept + else "a table" + ) logger.warning( f"The template {template.name} has text in its body ({shown}) and no {marker} line, so that text " f"comes before the document. Put {marker} where the document goes, or remove the text" diff --git a/src/py_doc/pandoc/docx_post.py b/src/py_doc/pandoc/docx_post.py index 921ee0f..3fd88cc 100644 --- a/src/py_doc/pandoc/docx_post.py +++ b/src/py_doc/pandoc/docx_post.py @@ -13,6 +13,7 @@ * Core property update. * TOC update flag. """ + from __future__ import annotations import copy @@ -39,18 +40,19 @@ def snippet(text: str, n: int = 80) -> str: """A single-line preview of text for messages.""" return (text or "").replace("\n", " ").replace("\r", " ")[:n] + # --------------------------------------------------------------------------- # Orientation markers # --------------------------------------------------------------------------- MARKERS_LANDSCAPE = {"[[LANDSCAPE]]", "__LANDSCAPE__", "__LANDSCAPE_START__"} -MARKERS_PORTRAIT = {"[[PORTRAIT]]", "__PORTRAIT__", "__LANDSCAPE_END__"} +MARKERS_PORTRAIT = {"[[PORTRAIT]]", "__PORTRAIT__", "__LANDSCAPE_END__"} # --------------------------------------------------------------------------- # Style / caption marker patterns # --------------------------------------------------------------------------- -STYLE_PATTERN = re.compile(r"\[\[STYLE:(?P", 1)[1], "every picture and link is embedded or rendered" + assert "image not found" not in page and "](" not in page.split("", 1)[1], ( + "every picture and link is embedded or rendered" + ) def test_an_example_poster_built_on_its_own_has_the_layout_it_was_written_for(drawn): @@ -389,7 +402,9 @@ def test_settings_win_over_the_front_matter_and_the_defaults_come_last(tmp_path, text = "---\ntheme: dark\n---\n## A\n\nText.\n" assert "--page:#ffffff;" in build(write(tmp_path, text), lambda: object(), settings={"theme": "print"})[0] assert "--page:#141923;" in build(write(tmp_path, text), lambda: object(), defaults={"theme": "print"})[0] - assert "--page:#ffffff;" in build(write(tmp_path, "## A\n\nText.\n"), lambda: object(), defaults={"theme": "print"})[0] + assert ( + "--page:#ffffff;" in build(write(tmp_path, "## A\n\nText.\n"), lambda: object(), defaults={"theme": "print"})[0] + ) @pytest.mark.parametrize( @@ -440,7 +455,9 @@ def test_the_poster_section_takes_every_poster_setting(tmp_path): parse_config({"poster": {"columns": 2.5}}, tmp_path) with pytest.raises(ConfigError, match="unknown key 'middle' in poster.footer"): parse_config({"poster": {"footer": {"middle": "x"}}}, tmp_path) - config = parse_config({"poster": {"theme": "brand.yml", "logo": "logo.svg", "footer": {"logos": ["a.png"]}}}, tmp_path) + config = parse_config( + {"poster": {"theme": "brand.yml", "logo": "logo.svg", "footer": {"logos": ["a.png"]}}}, tmp_path + ) assert config.poster["theme"] == tmp_path / "brand.yml" and config.poster["logo"] == tmp_path / "logo.svg" assert config.poster["footer"]["logos"] == [tmp_path / "a.png"] assert parse_config({"poster": {"theme": "dark"}}, tmp_path).poster["theme"] == "dark" @@ -457,7 +474,10 @@ def project(tmp_path, posters, defaults=None): def test_a_poster_of_py_doc_yml_has_its_source_output_and_settings(tmp_path): - config = parse_config({"posters": {"sn100": {"source": "p.md", "orientation": "landscape", "colours": {"primary": "#123456"}}}}, tmp_path) + config = parse_config( + {"posters": {"sn100": {"source": "p.md", "orientation": "landscape", "colours": {"primary": "#123456"}}}}, + tmp_path, + ) poster = config.posters["sn100"] assert (poster.source, poster.output) == (tmp_path / "p.md", tmp_path / "build" / "sn100.html") assert poster.settings == {"orientation": "landscape", "colours": {"primary": "#123456"}} @@ -470,7 +490,9 @@ def test_a_poster_of_py_doc_yml_has_its_source_output_and_settings(tmp_path): def test_every_poster_of_py_doc_yml_is_built_with_its_entry_over_the_front_matter(tmp_path, drawn, monkeypatch, capsys): monkeypatch.setattr("py_doc.poster_cli.connect", lambda settings: object()) monkeypatch.chdir(tmp_path) - write(tmp_path, "---\norientation: portrait\ncolours: {primary: '#111111', accent: '#222222'}\n---\n## A\n\nText.\n") + write( + tmp_path, "---\norientation: portrait\ncolours: {primary: '#111111', accent: '#222222'}\n---\n## A\n\nText.\n" + ) project( tmp_path, {"wall": {"source": "poster.md", "output": "out/wall.html", "orientation": "landscape", "colours": {"primary": "#333333"}}, @@ -485,7 +507,10 @@ def test_every_poster_of_py_doc_yml_is_built_with_its_entry_over_the_front_matte assert 'class="poster portrait"' in desk and "--primary:#111111;" in desk assert main(["poster", "desk"]) == 0 assert main(["poster", "nothing"]) == 1 - assert "is not a Markdown file or a poster of py-doc.yml. The posters of py-doc.yml are: wall, desk" in capsys.readouterr().err + assert ( + "is not a Markdown file or a poster of py-doc.yml. The posters of py-doc.yml are: wall, desk" + in capsys.readouterr().err + ) assert main(["poster", "-o", "x.html"]) == 1 @@ -512,7 +537,7 @@ def test_includes_conditions_and_variables_from_the_settings(tmp_path, drawn): text = "---\nvariables: {name: text}\nconditions: {internal: true}\n---\n## A\n\n[[INCLUDE: part.md]]\n\n[[INCLUDE-CODE: code.py]]\n\n[[NOT: internal]]\nPublic.\n[[ENDIF]]\n" page, _ = build(write(tmp_path, text), lambda: object()) assert "Included text." in page and "Public." not in page - assert '
    print(\'hi\')' in page
    +    assert "
    print('hi')" in page
     
     
     def test_a_missing_include_is_an_error_with_the_file(tmp_path):
    @@ -550,7 +575,10 @@ def test_captions_callouts_and_word_markers(tmp_path, drawn):
         )
         page, _ = build(write(tmp_path, text), lambda: object())
         assert '

    Figure 1: The valve.

    ' in page - assert '

    Warning

    Disconnect the battery first.

    ' in page + assert ( + '

    Warning

    Disconnect the battery first.

    ' + in page + ) assert '
    \n

    Tip: use the app.

    ' in page assert "
    \n

    A plain quote.

    " in page assert "

    Lead text.

    " in page @@ -604,15 +632,26 @@ def test_every_kind_of_section_takes_a_style_and_a_colour(tmp_path, drawn): "## In short {.summary style=line colour=navy}\n\nThe point.\n\n### Then\n\nMore.\n\n" "## Sources {.references style=list colour=grey}\n\n1. A paper.\n" ) - page = build(write(tmp_path, "---\npalette: {teal: '#0f5e5e', coral: '#e8684a'}\n---\n" + text), lambda: object())[0] + page = build(write(tmp_path, "---\npalette: {teal: '#0f5e5e', coral: '#e8684a'}\n---\n" + text), lambda: object())[ + 0 + ] body = page.split("
    ", 1)[1] assert '
    ' in body assert '

    Because.' in body - assert '

    1. A

    ' in body and '

    2. B

    ' in body + assert ( + '

    1. A

    ' in body + and '

    2. B

    ' in body + ) assert '
    ' in body assert '
    Sources' in body css = page.split("