diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d18dc23..f03caf0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -28,7 +28,7 @@ body: attributes: label: DocSprout version description: Output of `docsprout --version`. - placeholder: docsprout 1.3.1 + placeholder: docsprout 1.3.2 validations: required: true - type: input diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index 13d4475..1d1aef8 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -32,7 +32,7 @@ jobs: - uses: actions/setup-python@v7 with: python-version: '3.11' - - run: python -m pip install "git+https://github.com/ikelaiah/docsprout.git@v1.3.1" + - run: python -m pip install "git+https://github.com/ikelaiah/docsprout.git@v1.3.2" - run: docsprout check - if: inputs.versioned run: docsprout check-release diff --git a/CHANGELOG.md b/CHANGELOG.md index 6793992..a69cc6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 1.3.2 + +### Fixed + +- Inline Markdown keeps generated elements out of image alternative-text + attributes, preventing attribute injection through code or math in image + descriptions. Formatting no longer changes link destinations. +- Failed single-site rendering preserves the previous output, and a failed + first build leaves no partial site. Output ownership is checked before + rendering and again before publication. +- Local preview rebuilds when a configured logo, banner or custom stylesheet + changes outside `docs/`, including after changing the configured path. +- Scrolling updates the current section without reopening an outline that the + reader closed. + +### Documentation + +- Add an editable SVG build diagram, diagram-authoring guidance and contributor + instructions for testing Markdown context boundaries and browser behaviour. + ## 1.3.1 A diagnostics and release-validation patch. No configuration, route, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e96826e..2e4494f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,8 @@ docsprout build --output build/docs-site | `docsprout/config.py` | configuration discovery, validation and the legacy `dockit.json` filename | `test_config`, `test_compatibility_corpus` | | `docsprout/build.py` | page rendering, routes, assets and the search index | `test_build`, `test_route_collisions` | | `docsprout/markdown.py` | safe Markdown subset and heading slugs | `test_markdown` | +| `docsprout/layout_markdown.py` | Markdown navigation outlines and line-specific diagnostics | `test_layout_markdown` | +| `docsprout/palette.py` | derived colours and contrast checks | `test_palette` | | `docsprout/highlight.py` | offline syntax highlighting | `test_markdown` | | `docsprout/audit.py` | read-only publication diagnostics | `test_audit` | | `docsprout/github_pages.py` | managed Pages workflow rendering and inspection | `test_github_pages`, `test_github_pages_cli` | @@ -52,10 +54,34 @@ and `tests/qualification_artifacts.py` verify packaged installs and archives. Preview documentation changes with `docsprout serve`, and follow [Troubleshooting](docs/troubleshooting.md) when a command reports an error. +## Make a small, verifiable improvement + +For a renderer change, start with a failing example and run the focused tests: + +```powershell +python -m unittest tests.test_markdown tests.test_inline_contexts -v +``` + +Keep raw Markdown, generated HTML and HTML attributes separate. Add cases for +quotes, code, math, image descriptions and link targets when changing inline +syntax. The [rendering boundary](docs/architecture.md#keep-rendering-contexts-separate) +explains why those combinations matter. + +For a browser change, build `examples/visual-fixtures` and follow the +[visual inspection matrix](docs/visual-fixtures.md). Exercise a phone width, +keyboard navigation, light and dark modes, and the relevant empty or error +state. Generated-string assertions cannot prove that scrolling, focus or +layout works in a browser. Record what you actually observed, including any +tools or platforms you could not check. + +Finish with the complete tests and lint, then the documentation checks above +when content or generated output changes. Explain the reader-visible result +and its verification in the change description. + Before a release, build and inspect both wheel and sdist, qualify each from a fresh environment outside the checkout, run the historical `check-release` and `build-all` rehearsal, and follow [the pre-publish checklist](docs/pre-publish-checklist.md). -Release branches are named `release/vX.Y.Z`; tags are annotated and are never -moved after publication. The maintainer opens the release PR against `main`, -waits for all required CI, merges only a green candidate, then tags the merge -commit and publishes the GitHub Release. +Release branches are named `dev/vX.Y.Z`. Tags are annotated and never moved +after publication. The maintainer opens the release PR against `main`, waits +for all required CI, merges only a green candidate, then tags the merge commit +and publishes the GitHub Release. diff --git a/README.md b/README.md index 320cc1c..31dc512 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🌱 DocSprout -[![CI](https://github.com/ikelaiah/docsprout/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ikelaiah/docsprout/actions/workflows/ci.yml?query=branch%3Amain) [![Docs](https://img.shields.io/website?url=https%3A%2F%2Fikelaiah.github.io%2Fdocsprout%2F1.3.1%2F&label=docs)](https://ikelaiah.github.io/docsprout/1.3.1/) [![Latest release](https://img.shields.io/github/v/release/ikelaiah/docsprout?display_name=tag&sort=semver)](https://github.com/ikelaiah/docsprout/releases/latest) [![Python 3.10–3.14](https://img.shields.io/badge/python-3.10%E2%80%933.14-3776AB?logo=python&logoColor=white)](https://github.com/ikelaiah/docsprout/blob/main/docs/qualification.md#supported-python-versions) [![License: MIT](https://img.shields.io/github/license/ikelaiah/docsprout)](https://github.com/ikelaiah/docsprout/blob/main/LICENSE) +[![CI](https://github.com/ikelaiah/docsprout/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ikelaiah/docsprout/actions/workflows/ci.yml?query=branch%3Amain) [![Docs](https://img.shields.io/website?url=https%3A%2F%2Fikelaiah.github.io%2Fdocsprout%2F1.3.2%2F&label=docs)](https://ikelaiah.github.io/docsprout/1.3.2/) [![Latest release](https://img.shields.io/github/v/release/ikelaiah/docsprout?display_name=tag&sort=semver)](https://github.com/ikelaiah/docsprout/releases/latest) [![Python 3.10–3.14](https://img.shields.io/badge/python-3.10%E2%80%933.14-3776AB?logo=python&logoColor=white)](https://github.com/ikelaiah/docsprout/blob/main/docs/qualification.md#supported-python-versions) [![License: MIT](https://img.shields.io/github/license/ikelaiah/docsprout)](https://github.com/ikelaiah/docsprout/blob/main/LICENSE) ![DocSprout banner — Build documentation people can use.](docs/assets/docsprout-banner.svg) @@ -23,7 +23,7 @@ Open a terminal in your project's top-level folder—the folder that normally contains files such as `README.md`, `src/` or `.git/`—and run: ```bash -python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.1.zip" +python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.2.zip" docsprout init docsprout serve ``` diff --git a/ROADMAP.md b/ROADMAP.md index f71d465..bc5e482 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -340,6 +340,18 @@ dependency, or syntax change. - [x] Add an existing-repository adoption guide and a CI/CD guide covering the portable check/audit gates and static-host deployment. +## v1.3.2 — Rendering and build safety + +- [x] Keep Markdown code, math, image descriptions and link destinations in + their correct output contexts, with regression coverage for composed + syntax. +- [x] Preserve the last working single-site build on render failure, and + rebuild the local preview when configured repository assets change. +- [x] Keep responsive outline tracking independent of the reader's disclosure + choice. +- [x] Improve the architecture and contributor guides with a readable build + diagram, Markdown safety notes and a concrete browser-check workflow. + ## Quality bar No release is complete unless it is easy to start, easy to understand, easy to diff --git a/docs/architecture.md b/docs/architecture.md index 5dcd90c..8175000 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,6 +6,12 @@ identity; the installed package supplies the renderer, CSS and browser code. ## Follow one build +![Project files enter DocSprout for validation and rendering; a successful build replaces the static site.](assets/build-pipeline.svg) + +The three stages are **project-owned inputs**, **DocSprout's build**, and +**a complete static site**. The image is a local SVG; the steps below provide +the same information in text and name the modules to explore. + 1. `cli.py` chooses a command and project root. `check` calls the same builder as `build`, but writes into a temporary directory. 2. `config.py` loads `docsprout.json` and the navigation layout, validates @@ -19,6 +25,9 @@ identity; the installed package supplies the renderer, CSS and browser code. 5. `build.py` writes each page in the shared HTML shell, then writes the search index and release metadata. `assets.py` supplies the site CSS and JavaScript; the package also carries local KaTeX assets. +6. `safety.py` replaces the owned output directory only after the single-site + build succeeds. A failed render leaves the previous site available. Staging + beside the destination keeps directory renames on the same filesystem. `serve` rebuilds that same site when source files change. `audit.py` adds publication diagnostics, including warnings about image descriptions and @@ -32,6 +41,21 @@ repository's `CONTRIBUTING.md` code map. Run the focused test while editing, then the full suite. Changes to routes, configuration, generated formats or command behavior also need a review of the contract below. +## Keep rendering contexts separate + +The inline renderer treats prose, image descriptions and URL destinations as +different contexts. Code and math become elements in prose, plain text in an +image's alternative description, and exact source characters in a URL. +Completed elements are protected from later formatting passes. This prevents +emphasis from changing a destination and keeps generated markup out of HTML +attributes. + +When extending Markdown, start with `tests/test_inline_contexts.py` and +`tests/test_markdown.py`. Exercise the new syntax inside a link label, an image +description, code and math, as well as ordinary prose. Parse the resulting HTML +to check element and attribute boundaries; a string containing `"` alone +does not prove that the final element is safe. + ## Compatibility surface The public compatibility contract consists of: @@ -77,6 +101,6 @@ needs no custom CSS; the custom stylesheet mechanism is a deliberately bounded escape hatch whose inclusion mechanics DocSprout owns and whose accessibility the author owns. -Downstream projects should pin released tags such as `v1.3.1`, never the main +Downstream projects should pin released tags such as `v1.3.2`, never the main branch. The stable boundary and deprecation policy are in [Machine-readable contracts](machine-contracts.md). diff --git a/docs/assets/build-pipeline.svg b/docs/assets/build-pipeline.svg new file mode 100644 index 0000000..32614fd --- /dev/null +++ b/docs/assets/build-pipeline.svg @@ -0,0 +1,40 @@ + + From project files to a documentation site + Project-owned Markdown, navigation and identity enter DocSprout. It validates paths, renders pages and resolves links in a staging directory. Only a successful build replaces the published HTML, search index and local assets. + + + + + + + + From source to site + + + + + 1 + Your project + Markdown + navigation + Identity + local assets + + + + + + + 2 + DocSprout + Validate, render, resolve + Prepare a complete site + + + + + + 3 + Your built site + HTML + search + assets + Publish after success + + diff --git a/docs/beginners-guide.md b/docs/beginners-guide.md index aa7f487..eef4be5 100644 --- a/docs/beginners-guide.md +++ b/docs/beginners-guide.md @@ -26,7 +26,7 @@ that normally contains `README.md` or `src/`. Open a terminal in your project folder and run: ```bash -python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.1.zip" +python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.2.zip" ``` Then check that the command is available: @@ -254,7 +254,7 @@ appear in the navigation. in [How DocSprout renders Markdown](markdown-showcase.md). - Learn the three configuration files in [Configuration](configuration.md). - Copy a small working project from the - [minimal example](https://github.com/ikelaiah/docsprout/tree/v1.3.1/examples/minimal). + [minimal example](https://github.com/ikelaiah/docsprout/tree/v1.3.2/examples/minimal). - When you truly want a public site, choose the simpler or historical path in [GitHub Pages](github-pages.md). - Look up unfamiliar words in the [glossary](glossary.md). diff --git a/docs/building.md b/docs/building.md index 701f107..16cb41b 100644 --- a/docs/building.md +++ b/docs/building.md @@ -14,6 +14,15 @@ The build validates the configuration first, then writes the complete site to `build/docs-site` and prints the page count. The output is offline-ready: every asset is local, and `index.html` opens directly in a browser. +Single-site builds prepare their files beside the output folder and replace it +only after rendering succeeds. If a link, configuration or rendering error +stops a rebuild, the previous site stays available. A first build that fails +does not leave a partial site. This also keeps a working `serve` preview +readable while you fix the source. + +The preview watches `README.md`, `docs/`, and the configured logo, banner and +custom stylesheet even when those assets live elsewhere in the repository. + | Option | Effect | | --- | --- | | `--output ` | Write somewhere else, such as `dist/site`. DocSprout marks generated folders with `.docsprout-site` and refuses to replace a folder it does not own. | @@ -24,7 +33,7 @@ asset is local, and `index.html` opens directly in a browser. ## Ship a ZIP ```bash -docsprout build --release 1.3.1 --offline-archive dist/docsprout-docs-1.3.1.zip +docsprout build --release 1.3.2 --offline-archive dist/docsprout-docs-1.3.2.zip ``` The release label used for an offline archive must start with a letter or @@ -41,6 +50,10 @@ documentation and every declared historical release into one site, each under its own path. The [historical documentation guide](historical-docs.md) covers the release order, and `docsprout check-release` validates the manifest first. +Unlike a single-site build, `build-all` currently replaces its output before +building releases. Rehearse historical builds in a separate output folder; +keep the previous published artifact until the entire command succeeds. + ## Start with `doctor` `docsprout doctor` inspects the project without changing files. Its output is a diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 66ff3c5..cbabae2 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -40,7 +40,7 @@ jobs: with: python-version: "3.12" - name: Install DocSprout - run: python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.1.zip" + run: python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.2.zip" - name: Validate the site run: docsprout check - name: Audit publications @@ -52,7 +52,7 @@ jobs: path: audit.json ``` -Pin a released tag (`v1.3.1` above), never a branch. `--format json` writes the +Pin a released tag (`v1.3.2` above), never a branch. `--format json` writes the machine-readable report; the artifact keeps it after the job, including when the audit fails. @@ -76,7 +76,7 @@ is yours. The same checks in GitLab CI: docs-checks: image: python:3.12 script: - - python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.1.zip" + - python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.2.zip" - docsprout check - docsprout audit --strict ``` diff --git a/docs/customisation.md b/docs/customisation.md index c678a42..04c7831 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -70,8 +70,8 @@ identity asset: Fields that are not relevant here, such as `banner` and `homepage`, are omitted for brevity. You can inspect the complete maintained -[`docs/docsprout.json`](https://github.com/ikelaiah/docsprout/blob/v1.3.1/docs/docsprout.json) -and its [local SVG asset](https://github.com/ikelaiah/docsprout/blob/v1.3.1/docs/assets/docsprout-mark.svg). +[`docs/docsprout.json`](https://github.com/ikelaiah/docsprout/blob/v1.3.2/docs/docsprout.json) +and its [local SVG asset](https://github.com/ikelaiah/docsprout/blob/v1.3.2/docs/assets/docsprout-mark.svg). On generated DocSprout pages, purple is the accent and highlight colour; the potted sprout mark (green leaves, brown soil and a beige pot) appears immediately before the project name in the top header; diff --git a/docs/existing-repository.md b/docs/existing-repository.md index bb4674b..7209dad 100644 --- a/docs/existing-repository.md +++ b/docs/existing-repository.md @@ -18,7 +18,7 @@ Run both commands from the repository's top-level folder—the one that contains `README.md` or `.git/`: ```bash -python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.1.zip" +python -m pip install "https://github.com/ikelaiah/docsprout/archive/refs/tags/v1.3.2.zip" docsprout init ``` diff --git a/docs/github-pages.md b/docs/github-pages.md index 8535751..fadd2d3 100644 --- a/docs/github-pages.md +++ b/docs/github-pages.md @@ -48,7 +48,7 @@ from feature branches do not deploy Pages. workflow are current, it makes no repository changes. The generated workflow carries a DocSprout ownership marker and pins the reusable -workflow to the installed release, such as `@v1.3.1`. It never follows +workflow to the installed release, such as `@v1.3.2`. It never follows `@main`. If a recognised managed workflow is older, ordinary setup reports the version and leaves it unchanged. Update only that workflow deliberately: @@ -101,13 +101,13 @@ jobs: contents: read pages: write id-token: write - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.1 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.2 with: versioned: false release: latest ``` -The maintained [single-version example](https://github.com/ikelaiah/docsprout/tree/v1.3.1/examples/single-version) +The maintained [single-version example](https://github.com/ikelaiah/docsprout/tree/v1.3.2/examples/single-version) uses this form. ## Historical site for versioned projects @@ -129,7 +129,7 @@ jobs: contents: read pages: write id-token: write - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.1 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.2 ``` Check a historical release locally after creating its tag and before pushing: @@ -140,7 +140,7 @@ docsprout check-release docsprout build-all --output build/docs-site ``` -The maintained [historical example](https://github.com/ikelaiah/docsprout/tree/v1.3.1/examples/historical) +The maintained [historical example](https://github.com/ikelaiah/docsprout/tree/v1.3.2/examples/historical) uses this form. Follow the [pre-publish checklist](pre-publish-checklist.md) for the exact release order. diff --git a/docs/historical-docs.md b/docs/historical-docs.md index 540269b..29ac68a 100644 --- a/docs/historical-docs.md +++ b/docs/historical-docs.md @@ -71,6 +71,6 @@ the release check and full build, then push the tag. Use the exact commands in t [pre-publish checklist](pre-publish-checklist.md). The maintained -[historical example](https://github.com/ikelaiah/docsprout/tree/v1.3.1/examples/historical) +[historical example](https://github.com/ikelaiah/docsprout/tree/v1.3.2/examples/historical) demonstrates two small releases. Its automated test builds the same Git history twice and compares every generated byte. diff --git a/docs/homepage-recipes.md b/docs/homepage-recipes.md index db848c9..43c5182 100644 --- a/docs/homepage-recipes.md +++ b/docs/homepage-recipes.md @@ -18,7 +18,7 @@ which is a top-level setting, are `homepage` values. ## See it in DocSprout DocSprout uses the same configuration on this site. Its -[`docs/docsprout.json`](https://github.com/ikelaiah/docsprout/blob/v1.3.1/docs/docsprout.json) +[`docs/docsprout.json`](https://github.com/ikelaiah/docsprout/blob/v1.3.2/docs/docsprout.json) uses these exact values for its home-page banner and capability cards: ```json diff --git a/docs/markdown-showcase.md b/docs/markdown-showcase.md index d8b81d3..7a33d6b 100644 --- a/docs/markdown-showcase.md +++ b/docs/markdown-showcase.md @@ -5,6 +5,10 @@ then the same content rendered by DocSprout underneath. Write the source in a file under `docs/`, save, and reload your preview to see the same result on your own site. +For diagrams, use an exported SVG or raster image with descriptive alternative +text. [Draw one idea at a time](writing-great-docs.md#draw-one-idea-at-a-time) +shows the workflow; Mermaid and Graphviz fences display as code. + The examples use only the Markdown that DocSprout supports. Common syntax it leaves alone is listed under [What DocSprout does not render](#what-docsprout-does-not-render). diff --git a/docs/migration.md b/docs/migration.md index b744277..54843e8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -3,7 +3,7 @@ The 1.x upgrades are deliberately boring: v1.0.0 froze the qualified v0.18.1 surface, and every later 1.x release adds no configuration schema change. Pin the released package and workflow, then run the same checks you already use. -Current guidance targets `v1.3.1`; each section below remains authoritative for +Current guidance targets `v1.3.2`; each section below remains authoritative for its own step. ## DocKit to DocSprout (1.x rebrand) @@ -64,7 +64,7 @@ each published release manifest on immutable tags while upgrading. ## Upgrade directly from any supported 0.x release All released 0.x configurations use schema version 1. Upgrade the pinned package -and workflow to the latest 1.x release (`v1.3.1`), then use this table before +and workflow to the latest 1.x release (`v1.3.2`), then use this table before running `doctor`, `check`, and the appropriate publish build. | Starting release | Required compatibility work | @@ -96,6 +96,15 @@ segments and option-like refs, requires the current source to match `HEAD`, and requires documentation changes to be committed. These checks make existing valid manifests more dependable; they do not change generated routes. +## v1.3.1 to v1.3.2 + +No configuration or content migration is required. Update package and workflow +pins to `v1.3.2`, then rebuild. The patch protects inline image attributes and +link destinations, preserves the previous output when a single-site build +fails, watches configured local assets during preview, and keeps the mobile +outline under reader control. It introduces no configuration schema, generated +route or public CSS-token changes. + ## v1.3.0 to v1.3.1 No configuration or content migration is required. Update package and workflow @@ -317,7 +326,7 @@ the contract qualified by v0.18.1: 1. Pin the package archive or source installation to `v1.0.0` and pin the reusable Pages workflow to `@v1.0.0`; never use `main`. For a new upgrade - today, pin the latest 1.x release (`v1.3.1`) instead. + today, pin the latest 1.x release (`v1.3.2`) instead. 2. Keep `"schema_version": 1` in `docsprout.json`, `layout.json` and `versions.json`. There is no schema rewrite or generated-route migration. 3. If custom CSS still uses pre-v0.18 generic names such as `--bg`, `--text` @@ -449,9 +458,9 @@ safe, readable plain code. Version 1.0 is the formal commitment point for the contracts that v0.18.1 qualified, and later 1.x releases keep that contract. There is no schema change. -Before adopting the latest 1.x release (`v1.3.1`): +Before adopting the latest 1.x release (`v1.3.2`): -1. Upgrade the pinned package and Pages workflow to `v1.3.1` following this +1. Upgrade the pinned package and Pages workflow to `v1.3.2` following this guide's patterns (config-compatible, then rebuild). 2. Remove references to any pre-v0.18 generic theme token names (`--bg`, `--text`, `--interactive`, …) in custom CSS; the `--dk-*` names diff --git a/docs/pre-publish-checklist.md b/docs/pre-publish-checklist.md index 1c01fc9..cae5781 100644 --- a/docs/pre-publish-checklist.md +++ b/docs/pre-publish-checklist.md @@ -62,18 +62,18 @@ This path updates the public site from a branch such as `main`. ## Historical site This path publishes an immutable site for every listed release. In the example -below, replace `1.3.1` with your version. +below, replace `1.3.2` with your version. -1. Add release `1.3.1` and source ref `v1.3.1` to `docs/versions.json`. Set - `current` to `1.3.1`. +1. Add release `1.3.2` and source ref `v1.3.2` to `docs/versions.json`. Set + `current` to `1.3.2`. 2. Run `docsprout check`. 3. Commit all release files, including package metadata, documentation, - examples and workflows, on a branch named `release/vX.Y.Z`: + examples and workflows, on a branch named `dev/vX.Y.Z`: ```bash - git add pyproject.toml src/docsprout/__init__.py README.md CHANGELOG.md docs .github examples tests + git add pyproject.toml src/docsprout README.md CHANGELOG.md CONTRIBUTING.md docs .github examples tests tasks git diff --cached --check - git commit -m "Prepare v1.3.1 documentation" + git commit -m "Prepare v1.3.2 documentation" ``` 4. Push the qualified branch and open a PR against `main`. Wait for every @@ -83,7 +83,7 @@ below, replace `1.3.1` with your version. 5. Check out the merged `main` commit and create the tag on that exact commit: ```bash - git tag -a v1.3.1 -m "v1.3.1" + git tag -a v1.3.2 -m "v1.3.2" ``` 6. Run the release checks and historical build: @@ -100,20 +100,21 @@ below, replace `1.3.1` with your version. 8. Push the annotated tag (the merge commit is already on `main`): ```bash - git push origin v1.3.1 + git push origin v1.3.2 ``` -9. Create the GitHub Release from the tag. Confirm both CI and the Documentation - workflow are green on GitHub. +9. Create the GitHub Release from the tag. Put the release notes in the body + without repeating the release title there. Confirm both CI and the + Documentation workflow are green on GitHub. 10. Open the public site, switch between two releases, and confirm their content is different where expected. ## If you find a mistake If the tag exists only on your computer, correct and commit the files, delete -the local tag with `git tag -d v1.3.1`, then create it again on the corrected +the local tag with `git tag -d v1.3.2`, then create it again on the corrected commit. If the tag has been pushed, do not move or replace it. Publish the correction -as a new patch release, such as `v1.3.1`, so links and historical documentation +as a new patch release, such as `v1.3.3`, so links and historical documentation remain trustworthy. diff --git a/docs/qualification.md b/docs/qualification.md index 546a54f..13592e9 100644 --- a/docs/qualification.md +++ b/docs/qualification.md @@ -1,6 +1,6 @@ -# Qualification evidence for DocSprout v1.3.1 +# Qualification evidence for DocSprout v1.3.2 -DocSprout v1.3.1 is a diagnostics and release-validation patch that preserves +DocSprout v1.3.2 is a code-safety and documentation-quality patch that preserves JSON and Markdown layout compatibility and the v1.0.0 stable contract. The claims below are the contract that CI and the maintained fixtures exercise. Every row names how it is verified. "Supported" means the combination is run by automated qualification on every pull request and release, not merely @@ -296,6 +296,18 @@ site configuration; no schema, route or token value changes: names reserved for generated output (`versions.json`, `index.html`) and names that differ only by case on case-insensitive filesystems. +## v1.3.2 additions + +- Inline rendering tests cover generated HTML attribute boundaries, plain image + descriptions, unchanged link targets and composed Markdown contexts. +- Build and safety tests cover staging a complete site, preserving previous + output after a failed rebuild, and refusing unowned output directories. +- Preview tests cover local logo, banner and CSS edits outside `docs/`, path + changes, and restoration of deleted assets. +- Reading-layout tests ensure scroll tracking leaves disclosure state to the + reader. Browser verification covered four styles, two colour modes and four + viewports locally; these checks are manual release evidence, not CI jobs. + ## v1.3.0 additions - Generated-page tests cover the glassmorphic hero glow, specular panel diff --git a/docs/themes.md b/docs/themes.md index 7a02c68..720da8f 100644 --- a/docs/themes.md +++ b/docs/themes.md @@ -46,7 +46,7 @@ footer—never underlines, signalling hover with a background tint or colour shift instead, and buttons never underline. The maintained -[single-version example](https://github.com/ikelaiah/docsprout/tree/v1.3.1/examples/single-version) +[single-version example](https://github.com/ikelaiah/docsprout/tree/v1.3.2/examples/single-version) starts in Paper (`"style": "paper"`), so its built opening page shows the warm reading surface before a visitor changes the control. @@ -119,7 +119,7 @@ hexadecimal values: Test custom colours in both Light and Dark mode. Links, selected navigation and keyboard focus must remain easy to see. A preset is safer when you are unsure. The maintained -[minimal example](https://github.com/ikelaiah/docsprout/tree/v1.3.1/examples/minimal) +[minimal example](https://github.com/ikelaiah/docsprout/tree/v1.3.2/examples/minimal) uses the exact teal values shown above; its built links, selected navigation and focus state use that accent. diff --git a/docs/versions.json b/docs/versions.json index 3238463..2178c4a 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,7 +1,8 @@ { "schema_version": 1, - "current": "1.3.1", + "current": "1.3.2", "versions": [ + {"release": "1.3.2", "source_ref": "v1.3.2"}, {"release": "1.3.1", "source_ref": "v1.3.1"}, {"release": "1.3.0", "source_ref": "v1.3.0"}, {"release": "1.2.3", "source_ref": "v1.2.3"}, diff --git a/docs/visual-fixtures.md b/docs/visual-fixtures.md index 722924e..bb116e3 100644 --- a/docs/visual-fixtures.md +++ b/docs/visual-fixtures.md @@ -48,7 +48,9 @@ that Previous names its destination's section. Expand the inline outline with the keyboard at 1024px and below, follow a nested heading link, and check that the heading is visible below the sticky header. Reopen the outline and check that the current section is marked, as it is in the desktop outline. On -`build/minimal/quick-start.html`, check that a page without section headings +scroll, the outline must keep the open or closed state you chose; tracking the +active section must not reopen it. On `build/minimal/quick-start.html`, check +that a page without section headings has no empty outline or reserved right column, and that Previous names the destination's section. Repeat these checks in print preview: the section label remains, while page navigation and both versions of the outline are omitted. @@ -71,6 +73,11 @@ cover the semantic width contract. The checked-in banner is the canonical visual banner state; use a separate temporary project when you need to inspect the optional no-banner default. +DocSprout's own [Architecture](architecture.md#follow-one-build) page includes +a vertical SVG build diagram. Check its label legibility at 320px and 1440px, +in light and dark mode. It must remain within the article width; its nearby +prose must explain the same sequence without relying on the image. + The fixture is also the maintained [custom CSS](custom-css.md) example: its `docs/assets/custom.css` uses the documented `--dk-*` tokens, every built page loads it after DocSprout's styles, and CI builds the fixture on every change, so diff --git a/docs/writing-great-docs.md b/docs/writing-great-docs.md index 8dc0f07..a71ec17 100644 --- a/docs/writing-great-docs.md +++ b/docs/writing-great-docs.md @@ -127,6 +127,42 @@ Define an unavoidable technical term the first time you use it. You can link to the [glossary](glossary.md) instead of interrupting every guide with a long definition. +## Draw one idea at a time + +A useful diagram answers a question: what happens next, where does this data +go, or which part owns this responsibility? Choose one question per diagram. +Three clearly labelled stages usually teach more than a crowded map of every +module. + +- Follow one reading direction, from top to bottom or left to right. +- Name the boxes with the same words used in the surrounding explanation. +- Label an arrow when its meaning would otherwise be ambiguous. +- Use colour as a cue, with labels and shapes that still work in greyscale. +- Keep labels readable at a phone's width. Split a large diagram into smaller + views when shrinking it would make the text tiny. +- Explain the same idea in nearby prose, so the image is never the only way to + learn it. + +DocSprout publishes local SVG and raster images. Export your diagram to SVG +for crisp text, save it under `docs/assets/`, and reference it with ordinary +Markdown. Mermaid and Graphviz fences currently display as code; export the +finished diagram before including it. + +For example, after saving `docs/assets/build-pipeline.svg`: + +```markdown +![Project files are validated and rendered before the completed site is published.](assets/build-pipeline.svg) + +The build reads project files, validates and renders them, then publishes +the completed site. If rendering fails, the previous single-site build stays +available. +``` + +See this pattern in [Architecture](architecture.md#follow-one-build). Its +diagram is a small, editable SVG with a solid reading surface that works in +both light and dark sites. A link to the [full-size diagram](assets/build-pipeline.svg) +lets readers inspect it separately when needed. + ## Review the page as a beginner Before publishing, ask someone—or your future tired self—to follow the page diff --git a/examples/github-pages/.github/workflows/docsprout-pages.yml b/examples/github-pages/.github/workflows/docsprout-pages.yml index 6ff13a2..55946b7 100644 --- a/examples/github-pages/.github/workflows/docsprout-pages.yml +++ b/examples/github-pages/.github/workflows/docsprout-pages.yml @@ -13,7 +13,7 @@ concurrency: jobs: documentation: if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.1 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.2 with: versioned: false release: latest diff --git a/examples/historical/.github/workflows/documentation.yml b/examples/historical/.github/workflows/documentation.yml index 57188fa..5142dd4 100644 --- a/examples/historical/.github/workflows/documentation.yml +++ b/examples/historical/.github/workflows/documentation.yml @@ -12,7 +12,7 @@ permissions: jobs: documentation: - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.1 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.2 permissions: contents: read pages: write diff --git a/examples/single-version/.github/workflows/documentation.yml b/examples/single-version/.github/workflows/documentation.yml index 0f6e8ee..ea33eef 100644 --- a/examples/single-version/.github/workflows/documentation.yml +++ b/examples/single-version/.github/workflows/documentation.yml @@ -12,7 +12,7 @@ permissions: jobs: documentation: - uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.1 + uses: ikelaiah/docsprout/.github/workflows/publish-docs.yml@v1.3.2 with: versioned: false release: latest diff --git a/pyproject.toml b/pyproject.toml index 3aec21f..e001f54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "docsprout" -version = "1.3.1" +version = "1.3.2" description = "Versioned static documentation sites for code projects" keywords = ["documentation", "static-site", "markdown", "github-pages"] readme = "README.md" diff --git a/src/docsprout/__init__.py b/src/docsprout/__init__.py index c0e984e..86f99ba 100644 --- a/src/docsprout/__init__.py +++ b/src/docsprout/__init__.py @@ -1,3 +1,3 @@ """DocSprout: versioned static documentation sites for code projects.""" -__version__ = "1.3.1" +__version__ = "1.3.2" diff --git a/src/docsprout/assets.py b/src/docsprout/assets.py index b86af65..0c00880 100644 --- a/src/docsprout/assets.py +++ b/src/docsprout/assets.py @@ -451,7 +451,15 @@ const tocLinks=[...document.querySelectorAll('.toc a[href^="#"],.inline-toc a[href^="#"]')]; if(tocLinks.length&&'IntersectionObserver'in globalThis){ const headingIds=new Set(tocLinks.map(link=>link.getAttribute('href').slice(1))); - const activate=id=>tocLinks.forEach(link=>{const current=link.getAttribute('href').slice(1)===id;link.classList.toggle('is-active',current);if(current){link.setAttribute('aria-current','location');const inline=link.closest('.inline-toc');if(inline&&!inline.open)inline.open=true}else link.removeAttribute('aria-current')}); + function activate(id){ + // Track location without changing the reader's disclosure choice. + tocLinks.forEach(link=>{ + const current=link.getAttribute('href').slice(1)===id; + link.classList.toggle('is-active',current); + if(current)link.setAttribute('aria-current','location'); + else link.removeAttribute('aria-current'); + }); + } const observer=new IntersectionObserver(entries=>{const visible=entries.find(entry=>entry.isIntersecting);if(visible)activate(visible.target.id)},{rootMargin:'0px 0px -70% 0px'}); headingIds.forEach(id=>{const heading=document.getElementById(id);if(heading)observer.observe(heading)}); } diff --git a/src/docsprout/build.py b/src/docsprout/build.py index d15a57d..49f02df 100644 --- a/src/docsprout/build.py +++ b/src/docsprout/build.py @@ -17,7 +17,7 @@ from .errors import DocSproutError from .markdown import render_markdown from .models import Page -from .safety import prepare_output +from .safety import prepare_output, staged_output DEFAULT_CAPABILITIES = ( @@ -360,6 +360,18 @@ def build_site( require_listed_documents: bool = True, ) -> BuildResult: """Build one release. `root` is never used as an output location.""" + with staged_output(output) as staging: + return _build_site( + root=root, output=staging, release=release, versions=versions, + require_listed_documents=require_listed_documents, + ) + + +def _build_site( + *, root: Path, output: Path, release: str, versions: tuple[tuple[str, str], ...], + require_listed_documents: bool, +) -> BuildResult: + """Render into an unpublished staging directory.""" root, output = root.resolve(), output.resolve() config = load_config(root, require_listed_documents=require_listed_documents) docs = root / "docs" diff --git a/src/docsprout/cli.py b/src/docsprout/cli.py index 341e357..71ac98c 100644 --- a/src/docsprout/cli.py +++ b/src/docsprout/cli.py @@ -243,6 +243,7 @@ def __init__( self.output = output.resolve() self.release = release self._snapshot: tuple[tuple[str, int, int], ...] = () + self._asset_sources: tuple[Path, ...] = () package = Path(__file__).resolve().parent self._renderer_sources = tuple( path.resolve() @@ -261,7 +262,7 @@ def _stamp(path: Path) -> tuple[int, int]: return (info.st_mtime_ns, info.st_size) def _source_snapshot(self) -> tuple[tuple[str, int, int], ...]: - sources: list[Path] = [] + sources = [path for path in self._asset_sources if path.is_file()] readme = self.root / "README.md" if readme.is_file(): sources.append(readme) @@ -270,7 +271,7 @@ def _source_snapshot(self) -> tuple[tuple[str, int, int], ...]: sources.extend(path for path in docs.rglob("*") if path.is_file()) return tuple( (path.relative_to(self.root).as_posix(), *self._stamp(path)) - for path in sorted(sources) + for path in sorted(set(sources)) ) def _renderer_source_snapshot(self) -> tuple[tuple[str, int, int], ...]: @@ -285,6 +286,12 @@ def _reload_renderer() -> None: importlib.reload(importlib.import_module(f"docsprout.{name}")) def _build(self) -> None: + # Resolve configured dependencies only when building, not on each poll. + # Read through the renderer module so live reload uses current code. + config = build_module.load_config(self.root) + self._asset_sources = tuple( + self.root / path for path in (config.banner, config.logo, config.custom_css) if path + ) # Snapshot before building: a source saved while the build runs must # stay dirty so the next poll rebuilds it instead of serving it stale. source_snapshot = self._source_snapshot() diff --git a/src/docsprout/markdown.py b/src/docsprout/markdown.py index 41aa286..2cfb71f 100644 --- a/src/docsprout/markdown.py +++ b/src/docsprout/markdown.py @@ -87,45 +87,69 @@ def _typographic(value: str) -> str: return "".join(parts) +def _emphasis(escaped: str) -> str: + """Format escaped prose only; never pass generated HTML into this function.""" + escaped = STRIKE.sub(r"\1", escaped) + escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped) + return re.sub(r"(?\1", escaped) + + def _inline(value: str, resolve: LinkResolver) -> str: - # Protect inline code and math spans from every ordinary inline transform. - # Code is literal code; math is TeX, not Markdown prose. The token is - # deterministic per render, scoped to this call, and uses NUL sentinels - # that ordinary prose never contains. The base is extended until it - # cannot collide with the current input. + # Keep every generated element outside subsequent Markdown transforms. + # A span has three meanings: HTML in prose, plain text in image alt, and + # exact source (including delimiters) in a URL. Restore the right context + # before escaping attributes; inserting rendered spans there permits XSS. base = "DOCSPROUT" while base in value: base += "X" - contents: list[tuple[str, str]] = [] + token = re.compile(r"\x00" + re.escape(base) + r"\d+\x00") + fragments: dict[str, str] = {} + sources: dict[str, str] = {} + span_text: dict[str, str] = {} + + def protect(rendered: str, source: str) -> str: + key = f"\x00{base}{len(fragments)}\x00" + fragments[key] = rendered + sources[key] = source + return key + + def restore_html(text: str) -> str: + return token.sub(lambda match: fragments[match.group()], text) - def _stash(match: re.Match[str]) -> str: + def restore_source(text: str) -> str: + return token.sub(lambda match: sources[match.group()], html.unescape(text)) + + def protect_span(match: re.Match[str]) -> str: if match.group(1) is not None: - contents.append(("code", match.group(1))) + raw = match.group(1) + rendered = f"{html.escape(raw, quote=False)}" else: - contents.append(("math", match.group(2) or "")) - return f"\x00{base}{len(contents) - 1}\x00" + raw = match.group(2) + rendered = f'' + key = protect(rendered, match.group()) + span_text[key] = raw + return key + + def target_attribute(text: str) -> str: + return html.escape(resolve(restore_source(text)), quote=True) + + def image_element(match: re.Match[str]) -> str: + description = html.unescape(match.group(1)) + description = token.sub(lambda span: span_text[span.group()], description) + return protect( + f'{html.escape(description, quote=True)}', + restore_source(match.group()), + ) - working = PROTECTED_SPAN.sub(_stash, value) + def link_element(match: re.Match[str]) -> str: + label = restore_html(_emphasis(match.group(1))) + return protect(f'{label}', restore_source(match.group())) + + working = PROTECTED_SPAN.sub(protect_span, value) escaped = html.escape(_typographic(working), quote=False) - escaped = IMAGE.sub( - lambda match: f'{html.escape(html.unescape(match.group(1)), quote=True)}', - escaped, - ) - escaped = LINK.sub(lambda match: f'{match.group(1)}', escaped) - escaped = STRIKE.sub(r"\1", escaped) - escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped) - escaped = re.sub(r"(?\1", escaped) - if contents: - token = re.compile(r"\x00" + re.escape(base) + r"(\d+)\x00") - - def _restore(match: re.Match[str]) -> str: - kind, raw = contents[int(match.group(1))] - if kind == "code": - return f"{html.escape(raw, quote=False)}" - return f'' - - escaped = token.sub(_restore, escaped) - return escaped + escaped = IMAGE.sub(image_element, escaped) + escaped = LINK.sub(link_element, escaped) + return restore_html(_emphasis(escaped)) def render_markdown(source: str, resolve_link: LinkResolver) -> RenderedMarkdown: diff --git a/src/docsprout/safety.py b/src/docsprout/safety.py index 5b088a5..d11932a 100644 --- a/src/docsprout/safety.py +++ b/src/docsprout/safety.py @@ -1,7 +1,10 @@ """Safe filesystem primitives for generated documentation output.""" +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path import shutil +import tempfile from .errors import DocSproutError @@ -16,13 +19,7 @@ def _owned_marker(output: Path) -> str | None: return None -def prepare_output(output: Path) -> None: - """Empty a DocSprout-owned output directory and recreate its marker. - - Output generated by the pre-rebrand DocKit-FP name is recognised through - its legacy marker, then replaced with the canonical DocSprout marker. - """ - output = output.resolve() +def _validate_output(output: Path) -> None: if output.exists(): if not output.is_dir(): raise DocSproutError(f"Output path is not a directory: {output}") @@ -31,6 +28,49 @@ def prepare_output(output: Path) -> None: f"Refusing to replace output directory {output}: it is not owned by DocSprout " f"(missing {OWNERSHIP_MARKER})." ) + + +def prepare_output(output: Path) -> None: + """Empty a DocSprout-owned output directory and recreate its marker. + + Output generated by the pre-rebrand DocKit-FP name is recognised through + its legacy marker, then replaced with the canonical DocSprout marker. + """ + output = output.resolve() + _validate_output(output) + if output.exists(): shutil.rmtree(output) output.mkdir(parents=True) (output / OWNERSHIP_MARKER).write_text("DocSprout generated output.\n", encoding="utf-8") + + +@contextmanager +def staged_output(output: Path) -> Iterator[Path]: + """Publish a completed build, restoring the old site if installation fails. + + Stage beside the destination so directory renames stay on one filesystem. + No generated files become visible until the caller finishes successfully. + """ + output = output.resolve() + _validate_output(output) + output.parent.mkdir(parents=True, exist_ok=True) + workspace = Path(tempfile.mkdtemp(prefix=f".{output.name}-", dir=output.parent)) + staged, previous = workspace / "site", workspace / "previous" + published = False + try: + yield staged + _validate_output(output) + if output.exists(): + output.rename(previous) + try: + staged.rename(output) + except OSError: + if previous.exists(): + previous.rename(output) + raise + published = True + finally: + # If restoration itself fails (for example, a Windows file lock), keep + # the previous site for recovery rather than deleting the only copy. + if published or not previous.exists(): + shutil.rmtree(workspace) diff --git a/tasks/quality-review.md b/tasks/quality-review.md new file mode 100644 index 0000000..07d33a6 --- /dev/null +++ b/tasks/quality-review.md @@ -0,0 +1,88 @@ +# Code and output quality review + +## Scope and plan + +Improve safety, reading comfort and contributor understanding while preserving +the dependency-free CLI, configuration schemas, routes and public CSS tokens. +Keep the completed release plans in this directory as historical records. + +1. Fix inline Markdown context handling. Regression cases must prove that code, + math and emphasis cannot inject HTML attributes or alter link destinations; + normal formatting and linked images must continue to work. +2. Preserve a working build when a rebuild fails. Reproduce the broken-link + failure, stage the replacement, and retain owned-output safety checks. +3. Respect the reader's mobile outline choice. Scrolling may update the active + section, but must not reopen a disclosure the reader closed. + Also track configured preview assets outside `docs/`, so saved changes + appear without restarting the preview. +4. Add a readable SVG build diagram and practical diagram-writing guidance; + explain renderer boundaries and the contributor verification workflow. +5. Run focused regression tests, the complete suite, lint, documentation checks, + strict audit, package build and visual inspection where tools permit. + +## Acceptance checklist + +- [x] Inline rendering passes context-boundary regressions and existing tests. +- [x] A failed single-site rebuild leaves the previous output intact. +- [x] The inline outline stays closed until the reader opens it. +- [x] Configured preview assets outside `docs/` trigger rebuilds. +- [x] Diagrams have readable labels, descriptive alternative text and a prose equivalent. +- [x] Full checks pass; remaining findings and verification limits are recorded. + +## Findings and verification + +### Findings addressed + +| Severity | Finding | Change and evidence | +| --- | --- | --- | +| Critical | `_inline` restored generated code/math HTML inside image attributes; code containing a quote could create an `onerror` attribute. Later emphasis passes also rewrote URLs. | Separate HTML, plain description text and original source contexts; protect generated elements from later passes. Five tests in `tests/test_inline_contexts.py` cover injection, literal destinations, linked images, formatted labels and composed targets. | +| Required | `build_site` removed the working site before discovering a broken link. | Stage the complete site beside its destination, check ownership again, and restore the previous directory if installation fails. Six regressions cover failure preservation, cleanup, replacement and ownership. | +| Required | Preview ignored configured logo, banner and CSS files outside `docs/`. | Cache those source paths at build time and include them in snapshots. Three tests cover edits, path changes and deletion/recreation. | +| Required | Section tracking forced the inline outline open again after the reader closed it. | Keep location highlighting independent of disclosure state. Generated-script regression and browser keyboard/scroll checks pass. | +| Improvement | Architecture had no diagram; contributor instructions did not explain inline context boundaries. | Add a narrow SVG with large labels and prose equivalent, a diagram-writing recipe, and focused testing guidance. | + +The baseline had 314 passing tests. The final implementation has 329 passing +tests on Windows / Python 3.13.5. `ruff check .`, `docsprout check`, +`docsprout audit --strict` (41 pages, zero errors or warnings), and the own-site +build pass. A wheel and source distribution were built without adding a runtime +dependency. + +Headless Chrome inspected the built architecture page in Classic, Paper, +E-ink and Glassmorphic, Light and Dark, at 320, 768, 1024 and 1440 pixels: +32 cases passed with no page overflow, missing diagram, console warning or +JavaScript error. Keyboard checks verified disclosure toggling, closed-state +preservation during scrolling, current-section markers, search results, +ArrowDown and Escape. Phone and desktop screenshots were visually inspected. +Local evidence is in the ignored `build/quality-shots/` directory; full test +output is in `build/quality-tests.log`. + +The browser connector was unavailable; an isolated headless Chrome profile +provided runtime checks. This is not screen-reader or cross-browser +qualification, and no release or historical-tag rehearsal was performed. + +### Remaining work + +- **Required: README identity during adoption.** `docsprout init` followed by + `docsprout check` fails for a project containing only `docs/README.md`: + explicit home validation reserves `README.md` for the root source. With + both root and docs READMEs, generated navigation instead fails its duplicate + path check. Address source-aware page identity, home selection and route + compatibility together in `config.py`, `cli.py` and `build.py`; silently + excluding one README would hide documentation. Until then, rename the docs + README to `docs/index.md` and use that path in navigation. +- **Required: historical build preservation.** `versions.py::build_all` + still clears its top-level output before rendering all releases. Extend + staging to the complete multi-release tree and test failure in a later + release. This pass changed single-site publication only; the build guide + explicitly describes the historical limitation. +- **FYI: publication failure boundary.** Directory replacement uses two + renames and is not crash-atomic. If installation and rollback both fail, + the staging workspace retains the previous site for recovery. The staging + approach also requires disk space for both the previous and next sites. + +The renderer, build and preview fixes received independent review for +correctness, security, readability and compatibility. The patch changes the +package version to 1.3.2 and updates current release pins. It introduces no +runtime dependency, configuration schema, generated route or public CSS token +change. The remaining required items above prevent an unqualified +repository-wide approval. diff --git a/tests/qualification_installed.py b/tests/qualification_installed.py index 25fc444..7970411 100644 --- a/tests/qualification_installed.py +++ b/tests/qualification_installed.py @@ -5,7 +5,7 @@ source tree. Every path used here is a fresh temporary directory, so a passing run is evidence that the package does not depend on repository-only files. - python qualification_installed.py --expected-version 1.3.1 + python qualification_installed.py --expected-version 1.3.2 The script is deliberately self-contained (stdlib only) so it can be copied or run from any location without importing anything from the repository. diff --git a/tests/test_build.py b/tests/test_build.py index 2b4c296..07faea9 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -10,6 +10,48 @@ class BuildSiteTests(unittest.TestCase): + def test_failed_rebuild_preserves_the_complete_previous_site(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docs = root / "docs" + docs.mkdir() + (docs / "index.md").write_text("# Original home\n", encoding="utf-8") + output = root / "site" + build_site(root=root, output=output, release="dev") + original = {path.relative_to(output): path.read_bytes() for path in output.rglob("*") if path.is_file()} + (docs / "index.md").write_text("# Broken home\n\n[Missing](missing.md)\n", encoding="utf-8") + + with self.assertRaisesRegex(DocSproutError, "does not exist"): + build_site(root=root, output=output, release="dev") + + remaining = {path.relative_to(output): path.read_bytes() for path in output.rglob("*") if path.is_file()} + self.assertEqual(original, remaining) + self.assertEqual({"docs", "site"}, {path.name for path in root.iterdir()}) + + def test_failed_first_build_leaves_no_partial_output(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docs = root / "docs" + docs.mkdir() + (docs / "index.md").write_text("# Home\n\n[Missing](missing.md)\n", encoding="utf-8") + + with self.assertRaisesRegex(DocSproutError, "does not exist"): + build_site(root=root, output=root / "site", release="dev") + + self.assertEqual({"docs"}, {path.name for path in root.iterdir()}) + + def test_rejects_unowned_output_before_reading_documentation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "site" + output.mkdir() + (output / "important.txt").write_text("keep", encoding="utf-8") + + with self.assertRaisesRegex(DocSproutError, "not owned"): + build_site(root=root, output=output, release="dev") + + self.assertEqual("keep", (output / "important.txt").read_text(encoding="utf-8")) + def test_publishes_local_markdown_images_with_their_page(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/tests/test_cli.py b/tests/test_cli.py index 707d650..b30b328 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -11,6 +11,7 @@ from docsprout import build as build_module from docsprout.cli import _PreviewBuilder, _watch_preview, main +from docsprout.errors import DocSproutError class CliTests(unittest.TestCase): @@ -281,6 +282,79 @@ def test_preview_rebuilds_when_layout_or_docsprout_configuration_changes(self) - self.assertIn("--dk-accent:#7c3aed", built) self.assertFalse(preview.rebuild_if_changed()) + def test_preview_rebuilds_when_configured_repository_assets_change(self) -> None: + for field, value, filename, generated in ( + ("theme", {"custom_css": "custom.css"}, "custom.css", "custom.css"), + ("identity", {"logo": "logo.svg"}, "logo.svg", "logo.svg"), + ("banner", {"path": "banner.svg", "alt": "Banner"}, "banner.svg", "banner.svg"), + ): + with self.subTest(field=field), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + docs = root / "docs" + docs.mkdir() + (docs / "index.md").write_text("# Home", encoding="utf-8") + (docs / "docsprout.json").write_text(json.dumps({ + "schema_version": 1, "project": {"name": "Demo"}, field: value, + }), encoding="utf-8") + (docs / "layout.json").write_text(json.dumps({ + "schema_version": 1, + "navigation": [{"title": "Docs", "pages": [{"title": "Home", "path": "index.md"}]}], + }), encoding="utf-8") + asset = root / filename + asset.write_text("original", encoding="utf-8") + preview = _PreviewBuilder(root=root, output=root / "site", release="preview") + preview.build_initial() + asset.write_text("updated asset", encoding="utf-8") + + self.assertTrue(preview.rebuild_if_changed()) + self.assertEqual("updated asset", (root / "site" / "assets" / generated).read_text(encoding="utf-8")) + self.assertFalse(preview.rebuild_if_changed()) + + def test_preview_tracks_newly_configured_asset_paths(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with redirect_stdout(io.StringIO()): + main(["init", "--root", str(root)]) + (root / "first.css").write_text("first", encoding="utf-8") + (root / "second.css").write_text("second", encoding="utf-8") + primary = root / "docs" / "docsprout.json" + config = json.loads(primary.read_text(encoding="utf-8")) + config["theme"]["custom_css"] = "first.css" + primary.write_text(json.dumps(config), encoding="utf-8") + preview = _PreviewBuilder(root=root, output=root / "site", release="preview") + preview.build_initial() + + config["theme"]["custom_css"] = "second.css" + primary.write_text(json.dumps(config), encoding="utf-8") + self.assertTrue(preview.rebuild_if_changed()) + (root / "second.css").write_text("second updated", encoding="utf-8") + self.assertTrue(preview.rebuild_if_changed()) + self.assertEqual("second updated", (root / "site" / "assets" / "custom.css").read_text(encoding="utf-8")) + (root / "first.css").write_text("no longer used", encoding="utf-8") + self.assertFalse(preview.rebuild_if_changed()) + + def test_preview_retries_a_deleted_configured_asset_when_it_returns(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with redirect_stdout(io.StringIO()): + main(["init", "--root", str(root)]) + asset = root / "custom.css" + asset.write_text("original", encoding="utf-8") + primary = root / "docs" / "docsprout.json" + config = json.loads(primary.read_text(encoding="utf-8")) + config["theme"]["custom_css"] = "custom.css" + primary.write_text(json.dumps(config), encoding="utf-8") + preview = _PreviewBuilder(root=root, output=root / "site", release="preview") + preview.build_initial() + + asset.unlink() + with self.assertRaisesRegex(DocSproutError, "does not exist"): + preview.rebuild_if_changed() + self.assertEqual("original", (root / "site" / "assets" / "custom.css").read_text(encoding="utf-8")) + asset.write_text("restored asset", encoding="utf-8") + self.assertTrue(preview.rebuild_if_changed()) + self.assertEqual("restored asset", (root / "site" / "assets" / "custom.css").read_text(encoding="utf-8")) + def test_the_public_cli_surface_lists_only_operational_commands(self) -> None: commands = ("init", "serve", "check", "audit", "build", "build-all", "check-release", "github-pages", "doctor") with redirect_stdout(io.StringIO()) as help_output: diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 6b7017e..25371a4 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -93,13 +93,13 @@ def test_recommended_layouts_use_the_explicit_modern_contract(self) -> None: def test_release_metadata_and_version_manifest_agree(self) -> None: manifest = json.loads((self.root / "docs" / "versions.json").read_text(encoding="utf-8")) - self.assertEqual("1.3.1", __version__) + self.assertEqual("1.3.2", __version__) self.assertEqual(__version__, manifest["current"]) self.assertEqual(f"v{__version__}", manifest["versions"][0]["source_ref"]) pyproject = (self.root / "pyproject.toml").read_text(encoding="utf-8") self.assertIn('name = "docsprout"', pyproject) - self.assertIn('version = "1.3.1"', pyproject) + self.assertIn('version = "1.3.2"', pyproject) self.assertIn('license = "MIT"', pyproject) self.assertIn('name = "DocSprout contributors"', pyproject) self.assertNotIn('Development Status :: 3 - Alpha', pyproject) @@ -295,7 +295,7 @@ def test_qualification_document_matches_the_ci_contract(self) -> None: self.assertIn("wheel", qualification) self.assertIn("sdist", qualification) self.assertIn("## Manual browser/keyboard matrix", qualification) - self.assertIn("# Qualification evidence for DocSprout v1.3.1", qualification) + self.assertIn("# Qualification evidence for DocSprout v1.3.2", qualification) self.assertIn("Browser automation is not part of CI", qualification) self.assertIn("ruff check", qualification) self.assertIn("ruff check .", ci) @@ -348,7 +348,7 @@ def test_build_guide_documents_options_archives_and_doctor(self) -> None: guide = (self.root / "docs" / "building.md").read_text(encoding="utf-8") readme = (self.root / "README.md").read_text(encoding="utf-8") - self.assertIn("docsprout build --release 1.3.1 --offline-archive", guide) + self.assertIn("docsprout build --release 1.3.2 --offline-archive", guide) self.assertIn("`--root `", guide) self.assertIn("Status: preview-ready", guide) self.assertIn("docs/building.md", readme) diff --git a/tests/test_inline_contexts.py b/tests/test_inline_contexts.py new file mode 100644 index 0000000..2a98902 --- /dev/null +++ b/tests/test_inline_contexts.py @@ -0,0 +1,59 @@ +"""Inline formatting must never cross into generated HTML attributes.""" + +from html.parser import HTMLParser +import unittest + +from docsprout.markdown import render_markdown + + +class Elements(HTMLParser): + def __init__(self, source: str) -> None: + super().__init__() + self.tags: list[tuple[str, dict[str, str | None]]] = [] + self.feed(source) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self.tags.append((tag, dict(attrs))) + + +class InlineContextTests(unittest.TestCase): + def test_code_and_math_in_image_descriptions_cannot_create_attributes(self) -> None: + for description in ('`" onerror="alert`', '$x^2$', '**bold** and ~~old~~'): + with self.subTest(description=description): + rendered = render_markdown(f'![{description}](diagram.svg)', lambda target: target) + tags = Elements(rendered.html).tags + self.assertEqual(['p', 'img'], [tag for tag, _attrs in tags]) + self.assertEqual({'src', 'alt'}, set(tags[1][1])) + self.assertNotIn('<', tags[1][1]['alt']) + + def test_link_targets_reach_the_resolver_and_browser_unchanged(self) -> None: + for target in ('https://example.test/$value$', 'https://example.test/`value`', + 'https://example.test/**value**', 'https://example.test/~~value~~', + 'https://example.test/`&`', 'https://example.test/$"$'): + with self.subTest(target=target): + resolved = [] + + def resolve(value: str) -> str: + resolved.append(value) + return value + + rendered = render_markdown(f'[Example]({target})', resolve) + self.assertEqual([target], resolved) + self.assertEqual([('p', {}), ('a', {'href': target})], Elements(rendered.html).tags) + + def test_unsupported_image_inside_a_target_does_not_crash(self) -> None: + rendered = render_markdown('[Example](https://example.test/![alt](image.svg))', lambda target: target) + self.assertIn(('a', {'href': 'https://example.test/![alt](image.svg)'}), Elements(rendered.html).tags) + + def test_formatted_link_label_does_not_format_its_target(self) -> None: + rendered = render_markdown('[**Read** `--flag` $x$](https://example.test/**path**)', lambda target: target) + self.assertIn('Read --flag', rendered.html) + self.assertIn('data-tex="x"', rendered.html) + self.assertIn(('a', {'href': 'https://example.test/**path**'}), Elements(rendered.html).tags) + + def test_linked_image_keeps_safe_plain_alt_and_exact_destinations(self) -> None: + rendered = render_markdown('[![A `--flag` diagram](diagram.svg)](guide.md)', lambda target: target) + self.assertEqual( + [('p', {}), ('a', {'href': 'guide.md'}), ('img', {'src': 'diagram.svg', 'alt': 'A --flag diagram'})], + Elements(rendered.html).tags, + ) diff --git a/tests/test_reading_layout.py b/tests/test_reading_layout.py index 306b7a1..118c955 100644 --- a/tests/test_reading_layout.py +++ b/tests/test_reading_layout.py @@ -91,6 +91,10 @@ def test_previous_and_next_show_destination_context(self) -> None: one = self.pages["one.html"] self.assertIn('NextReferenceLong guide', one) + def test_scroll_tracking_does_not_open_the_readers_outline(self) -> None: + self.assertNotRegex(self.js, r"\.open\s*=\s*true") + self.assertIn("'aria-current'", self.js) + def test_phone_header_and_progress_follow_the_reading_article(self) -> None: page = self.pages["long.html"] self.assertIn('
None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "site" + prepare_output(output) + (output / "old.html").write_text("old", encoding="utf-8") + + with staged_output(output) as staging: + prepare_output(staging) + (staging / "index.html").write_text("new", encoding="utf-8") + self.assertEqual("old", (output / "old.html").read_text(encoding="utf-8")) + self.assertFalse((output / "index.html").exists()) + + self.assertFalse((output / "old.html").exists()) + self.assertEqual("new", (output / "index.html").read_text(encoding="utf-8")) + self.assertTrue((output / OWNERSHIP_MARKER).is_file()) + self.assertEqual([output], list(root.iterdir())) + + def test_staged_output_restores_the_previous_site_if_installation_fails(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "site" + prepare_output(output) + (output / "index.html").write_text("old", encoding="utf-8") + original_rename = Path.rename + + def failing_install(path: Path, target: Path): + if path == staging: + raise OSError("installation failed") + return original_rename(path, target) + + with self.assertRaisesRegex(OSError, "installation failed"), patch.object(Path, "rename", new=failing_install): + with staged_output(output) as staging: + prepare_output(staging) + (staging / "index.html").write_text("new", encoding="utf-8") + + self.assertEqual("old", (output / "index.html").read_text(encoding="utf-8")) + self.assertEqual([output], list(root.iterdir())) + + def test_staged_output_rechecks_ownership_before_publishing(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = root / "site" + with self.assertRaisesRegex(DocSproutError, "not owned"): + with staged_output(output) as staging: + prepare_output(staging) + output.mkdir() + (output / "important.txt").write_text("keep", encoding="utf-8") + + self.assertEqual("keep", (output / "important.txt").read_text(encoding="utf-8")) + self.assertEqual([output], list(root.iterdir())) + def test_refuses_to_replace_an_unowned_directory(self) -> None: with tempfile.TemporaryDirectory() as temporary: output = Path(temporary) / "site" diff --git a/tests/test_workflows.py b/tests/test_workflows.py index edc5aaa..0276bba 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -20,7 +20,7 @@ def test_reusable_workflow_supports_versioned_and_single_version_sites(self) -> def test_reusable_workflow_installs_the_pinned_docsprout_release(self) -> None: workflow = (self.root / ".github" / "workflows" / "publish-docs.yml").read_text(encoding="utf-8") - self.assertIn("git+https://github.com/ikelaiah/docsprout.git@v1.3.1", workflow) + self.assertIn("git+https://github.com/ikelaiah/docsprout.git@v1.3.2", workflow) self.assertNotIn("python -m pip install .", workflow) def test_workflows_use_checkout_v7_node_24_major(self) -> None: @@ -51,7 +51,7 @@ def test_examples_pin_the_release_and_select_the_intended_mode(self) -> None: single = (self.root / "examples" / "single-version" / ".github" / "workflows" / "documentation.yml").read_text(encoding="utf-8") historical = (self.root / "examples" / "historical" / ".github" / "workflows" / "documentation.yml").read_text(encoding="utf-8") - self.assertIn("publish-docs.yml@v1.3.1", single) + self.assertIn("publish-docs.yml@v1.3.2", single) self.assertIn("versioned: false", single) - self.assertIn("publish-docs.yml@v1.3.1", historical) + self.assertIn("publish-docs.yml@v1.3.2", historical) self.assertNotIn("versioned: false", historical)