diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0662586 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +# Build output +site/ +dist/ +artifacts/ + +# .NET +bin/ +obj/ + +# Local +.venv/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..284639e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,56 @@ +root = true + +[*] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 2 + +[*.{cs,csx}] +indent_size = 4 + +[*.{csproj,props,targets,slnx,xml}] +indent_size = 2 + +[*.{yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +# C# conventions +[*.cs] +# Namespaces +csharp_style_namespace_declarations = file_scoped:warning + +# 'this.' not required +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion + +# Language keywords over BCL type names +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion + +# var preferences (codebase uses var freely) +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion + +# Modern C# expression styles +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_prefer_braces = when_multiline:suggestion +csharp_using_directive_placement = outside_namespace:suggestion +dotnet_sort_system_directives_first = true + +# Usings +dotnet_diagnostic.IDE0005.severity = suggestion + +# New line preferences +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..8788d2e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,47 @@ +name: Bug report +description: Something isn't working as expected +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report! Netdocs is a spare-time project, so please be patient. + A minimal reproduction dramatically increases the odds of a fix. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do, what did you expect, and what happened instead? + placeholder: When I build a site with ..., I expected ..., but got ... + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: Commands, a snippet of `appsettings.json`, and/or the Markdown involved. + render: shell + validations: + required: true + - type: input + id: version + attributes: + label: Netdocs version / commit + placeholder: v1.0.0 or commit sha + validations: + required: true + - type: input + id: os + attributes: + label: OS / .NET version + placeholder: Windows 11 / .NET 11 + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant logs + description: Output of the failing command, ideally with `--verbose`. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..ee241d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Discord + url: https://static.xtremeownage.com/discord + about: Questions, ideas, or just saying hi (tag the maintainer). + - name: Security vulnerability + url: https://github.com/XtremeOwnage/Netdocs/security/advisories/new + about: Please report vulnerabilities privately, not as public issues. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..b1abdb0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,32 @@ +name: Feature request +description: Suggest an idea or enhancement +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: The use case or pain point behind the request. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: What you'd like Netdocs to do. Config/CLI/API sketches welcome. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Other approaches, or how you work around this today. + validations: + required: false + - type: input + id: mkdocs-parity + attributes: + label: Material for MkDocs equivalent (if any) + description: A link helps, since Netdocs targets near drop-in compatibility. + validations: + required: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 18f773d..20a71a8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -24,3 +24,16 @@ updates: interval: "weekly" commit-message: prefix: "ci" + + # Front-end JS/CSS libraries loaded from a CDN, tracked via /package.json. + # Dependabot flags new releases here; a maintainer then bumps the matching CDN + # URL in the theme templates / plugins. See + # docs-site/docs/development/frontend-dependencies.md. + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + commit-message: + prefix: "deps(frontend)" + diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..44f6a5f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ + + +## What & why + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Refactor / chore + +## Checklist + +- [ ] `dotnet format Netdocs.slnx --verify-no-changes` passes +- [ ] `dotnet build Netdocs.slnx -c Release` succeeds +- [ ] `dotnet test Netdocs.slnx -c Release` passes +- [ ] Added/updated tests for the change +- [ ] Updated docs under `docs-site/docs/**` if behavior changed + +## Notes for reviewers + + diff --git a/.github/workflows/action-selftest.yml b/.github/workflows/action-selftest.yml new file mode 100644 index 0000000..f673154 --- /dev/null +++ b/.github/workflows/action-selftest.yml @@ -0,0 +1,49 @@ +name: Action Self-Test + +# Exercises the composite action end-to-end (container pull + docker run) against +# the repo's own docs-site, so the exact path consumers hit is observable in CI. +on: + push: + branches: [main] + paths: + - "action.yml" + - ".github/workflows/action-selftest.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + selftest: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build docs-site via the Netdocs action + uses: ./ + with: + command: build + config: docs-site/appsettings.json + + - name: Verify output + shell: bash + run: | + set -euo pipefail + out="docs-site/site" + if [ ! -d "${out}" ]; then + echo "::error::Expected build output at ${out} was not produced." >&2 + exit 1 + fi + count="$(find "${out}" -name '*.html' | wc -l)" + echo "Generated ${count} HTML pages in ${out}." + test "${count}" -gt 0 + + # The social plugin is enabled on docs-site; confirm cards were rendered + # inside the container (this is the path that previously crashed on a + # missing font). + cards="$(find "${out}/assets/social" -name '*.png' 2>/dev/null | wc -l)" + echo "Generated ${cards} social card(s)." + test "${cards}" -gt 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cc8a9e1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore Netdocs.slnx + + - name: Format check (lint) + run: dotnet format Netdocs.slnx --verify-no-changes --no-restore + + - name: Build + run: dotnet build Netdocs.slnx -c Release --no-restore + + - name: Test + run: dotnet test Netdocs.slnx -c Release --no-build --verbosity normal diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..7bad9e4 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,47 @@ +name: Docker + +on: + push: + branches: [main] + # Full semver tags only; the moving `v1` alias tag should not rebuild images. + tags: ["v[0-9]+.[0-9]+.[0-9]+"] + workflow_dispatch: + +permissions: + contents: read + packages: write + +env: + IMAGE: ghcr.io/${{ github.repository }} + +jobs: + image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..6cf4824 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,49 @@ +name: Docs + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # git-revision-date needs full history + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Build docs site + run: >- + dotnet run --project src/Netdocs.Cli -c Release -- + build --prod --config docs-site/appsettings.json + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs-site/site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml new file mode 100644 index 0000000..893dd85 --- /dev/null +++ b/.github/workflows/packages.yml @@ -0,0 +1,90 @@ +name: Packages + +on: + push: + # Only full semver tags publish release binaries. The moving `v1` alias tag + # (used for `XtremeOwnage/Netdocs@v1`) must not create a stray `v1` release. + tags: ["v[0-9]+.[0-9]+.[0-9]+"] + workflow_dispatch: + inputs: + version: + description: Package version (without leading v) + required: false + default: "0.0.0" + +permissions: + contents: write + +jobs: + packages: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Resolve version + id: version + run: | + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "value=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + fi + + - name: Publish self-contained binaries (linux, windows, macos) + env: + VERSION: ${{ steps.version.outputs.value }} + run: | + for rid in linux-x64 win-x64 osx-x64 osx-arm64; do + echo "::group::publish $rid" + dotnet publish src/Netdocs.Cli -c Release -r "$rid" --self-contained \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \ + -p:EnableCompressionInSingleFile=true \ + -p:Version="${VERSION:-0.0.0}" -o "dist/$rid" + echo "::endgroup::" + done + + - name: Stage standalone executables + env: + VERSION: ${{ steps.version.outputs.value }} + run: | + mkdir -p out + v="${VERSION:-0.0.0}" + cp "dist/linux-x64/netdocs" "out/netdocs-${v}-linux-x64" + cp "dist/osx-x64/netdocs" "out/netdocs-${v}-osx-x64" + cp "dist/osx-arm64/netdocs" "out/netdocs-${v}-osx-arm64" + cp "dist/win-x64/netdocs.exe" "out/netdocs-${v}-win-x64.exe" + ls -l out + + - name: Install nfpm + run: | + curl -sSfL "https://github.com/goreleaser/nfpm/releases/latest/download/nfpm_$(uname -m)_Linux.tar.gz" \ + | sudo tar -xz -C /usr/local/bin nfpm || { + go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest + echo "$HOME/go/bin" >> "$GITHUB_PATH" + } + + - name: Build .deb and .rpm + env: + VERSION: ${{ steps.version.outputs.value }} + run: | + mkdir -p out + nfpm package -f packaging/nfpm.yaml -p deb -t out/ + nfpm package -f packaging/nfpm.yaml -p rpm -t out/ + ls -l out + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: netdocs-packages + path: out/* + + - name: Attach to release + if: github.ref_type == 'tag' + uses: softprops/action-gh-release@v2 + with: + files: out/* diff --git a/.gitignore b/.gitignore index 4980cba..e007ddf 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,6 @@ obj/ .cache/ # Ignore the planning directories. -plan/ \ No newline at end of file +plan/ +# Ad-hoc screenshots / scratch images used during verification. +_*.png diff --git a/ATTRIBUTIONS.md b/ATTRIBUTIONS.md new file mode 100644 index 0000000..e319ff8 --- /dev/null +++ b/ATTRIBUTIONS.md @@ -0,0 +1,52 @@ +# Attributions & derivative works + +Netdocs is an independent .NET implementation, but it deliberately mirrors the +behavior, configuration, and output of a number of excellent open-source projects so +that existing [Material for MkDocs](https://github.com/squidfunk/mkdocs-material) +sites build with little to no change. This file credits those upstream works. + +Netdocs is **not** affiliated with or endorsed by any of the projects below. All +trademarks and copyrights belong to their respective owners. + +## Theme & framework + +| Upstream | Author | License | What Netdocs derives | +|---|---|---|---| +| [Material for MkDocs](https://github.com/squidfunk/mkdocs-material) | Martin Donath (@squidfunk) | MIT | Theme templates, vendored CSS/JS, markup class names, feature flags, and overall UX. | +| [MkDocs](https://github.com/mkdocs/mkdocs) | MkDocs contributors | BSD-2-Clause | Configuration model (nav, plugins, markdown extensions) and site layout conventions. | + +## Plugins + +Each plugin reimplements the behavior of an upstream MkDocs plugin or PyMdown extension. + +| Netdocs plugin | Modeled on | Author | License | +|---|---|---|---| +| search | mkdocs-material search + [lunr](https://lunrjs.com/) | @squidfunk / Oliver Nightingale | MIT | +| social | mkdocs-material social cards | @squidfunk | MIT | +| blog | mkdocs-material blog | @squidfunk | MIT | +| tags | mkdocs-material tags | @squidfunk | MIT | +| meta | mkdocs-material meta | @squidfunk | MIT | +| snippets | [pymdownx.snippets](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/) | @facelessuser | MIT | +| b64 | [pymdownx.b64](https://facelessuser.github.io/pymdown-extensions/extensions/b64/) | @facelessuser | MIT | +| abbreviations | Python-Markdown `abbr` / PyMdown Extensions | Python-Markdown | BSD-3-Clause | +| macros | [mkdocs-macros-plugin](https://github.com/fralau/mkdocs-macros-plugin) | @fralau | MIT | +| git-revision-date | [mkdocs-git-revision-date-localized-plugin](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin) | @timvink | MIT | +| redirects | [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects) | MkDocs contributors | MIT | +| glightbox | [mkdocs-glightbox](https://github.com/blueswen/mkdocs-glightbox) + [GLightbox](https://github.com/biati-digital/glightbox) | Blueswen / biati digital | MIT | +| rss | [mkdocs-rss-plugin](https://github.com/Guts/mkdocs-rss-plugin) | @Guts | MIT | +| table-reader | [mkdocs-table-reader-plugin](https://github.com/timvink/mkdocs-table-reader-plugin) | @timvink | MIT | +| file-filter | mkdocs-file-filter | community | MIT | + +## Bundled front-end libraries + +| Library | Author | License | Use | +|---|---|---|---| +| [highlight.js](https://github.com/highlightjs/highlight.js) | highlight.js contributors | BSD-3-Clause | Code block syntax highlighting. | +| [Mermaid](https://github.com/mermaid-js/mermaid) | Knut Sveidqvist & contributors | MIT | Diagrams from fenced ```` ```mermaid ```` blocks. | +| [Twemoji](https://github.com/jdecked/twemoji) | Twitter / jdecked | Code: MIT Β· Graphics: CC-BY 4.0 | Emoji shortcode rendering. | +| [Roboto / Roboto Mono](https://fonts.google.com/specimen/Roboto) | Christian Robertson / Google | Apache-2.0 | Default body and code fonts. | + +## License + +Netdocs itself is distributed under the [MIT License](LICENSE). Reusing any of the +works above remains subject to their own licenses, which are linked from each project. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..018ab21 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,35 @@ +# Code of Conduct + +## The short version + +Be respectful. Be constructive. Assume good faith. This is a small, hobby-scale project +maintained in someone's spare time β€” treat contributors and maintainers accordingly. + +## Expected behavior + +- Be welcoming and patient with people of all experience levels. +- Give and gracefully accept constructive feedback. +- Focus on what is best for the project and its users. +- Show empathy toward other community members. + +## Unacceptable behavior + +- Harassment, insults, or derogatory comments, public or private. +- Personal or political attacks. +- Publishing others' private information without permission. +- Any conduct that would reasonably be considered inappropriate in a professional setting. + +## Scope + +This applies within all project spaces (issues, pull requests, discussions) and when an +individual is representing the project in public spaces. + +## Enforcement + +Report unacceptable behavior by opening a confidential channel with the maintainer via the +[Discord](https://static.xtremeownage.com/discord) or a GitHub issue if appropriate. +Maintainers may remove, edit, or reject comments, commits, code, issues, and other +contributions that violate this Code of Conduct, and may ban repeat or serious offenders. + +Maintainers who do not follow or enforce the Code of Conduct in good faith may face +temporary or permanent repercussions as determined by other project maintainers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..212eaca --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,72 @@ +# Contributing to Netdocs + +Thanks for taking an interest! Netdocs is a nights-and-weekends project (see the +["Why should you use this project?"](README.md#why-should-you-use-this-project) note in the +README), so please keep expectations calibrated: PRs and issues are welcome, but reviews +happen when life allows. + +## Ground rules + +- Be kind. See the [Code of Conduct](CODE_OF_CONDUCT.md). +- Keep changes focused. One logical change per pull request. +- Add or update tests for behavior changes. +- Update docs (`docs-site/docs/**`) when you change user-facing behavior. + +## Getting set up + +You need the .NET SDK pinned in [`global.json`](global.json). + +```pwsh +git clone https://github.com/XtremeOwnage/Netdocs +cd Netdocs +dotnet restore Netdocs.slnx +dotnet build Netdocs.slnx -c Debug +dotnet test Netdocs.slnx +``` + +Build and serve the dogfood docs site while you work: + +```pwsh +dotnet run --project src/Netdocs.Cli -- serve --config docs-site/appsettings.json +``` + +## Before you open a PR + +Run the same checks CI runs, plus formatting: + +```pwsh +dotnet format Netdocs.slnx --verify-no-changes +dotnet build Netdocs.slnx -c Release +dotnet test Netdocs.slnx -c Release +``` + +`dotnet format` enforces the rules in [`.editorconfig`](.editorconfig). Run +`dotnet format Netdocs.slnx` (without `--verify-no-changes`) to auto-fix. + +## Where things live + +| Path | What | +| --- | --- | +| `src/Netdocs.Abstractions` | Plugin contracts + page/site models. | +| `src/Netdocs.Core` | Config, discovery, Markdig pipeline, Scriban templating, build engine. | +| `src/Netdocs.Plugins` | Built-in plugins. | +| `src/Netdocs.Theme.Material` | Scriban templates + theme assets. | +| `src/Netdocs.Cli` | `netdocs build|serve|watch`. | +| `tests/Netdocs.Core.Tests` | xUnit tests (one file per plugin/area). | +| `docs-site/` | The self-hosted documentation site. | + +## Writing a plugin + +See [Events & callbacks](docs-site/docs/development/events-and-callbacks.md) and the +[build lifecycle](docs-site/docs/development/lifecycle.md). New built-in plugins get their +own test file named after the plugin (e.g. `RssPluginTests.cs`). + +## Commit messages + +Short imperative subject line ("Add X", "Fix Y"). Explain the *why* in the body when it +isn't obvious. + +## Questions + +Open a [discussion or issue](https://github.com/XtremeOwnage/Netdocs/issues), or say hi in +the [Discord](https://static.xtremeownage.com/discord) (tag me). diff --git a/Directory.Build.props b/Directory.Build.props index 9f98a8c..37c1c24 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,7 @@ true $(MSBuildProjectName) $(MSBuildProjectName) - false + true + low diff --git a/Directory.Packages.props b/Directory.Packages.props index 41ca33c..2497441 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,21 +4,24 @@ true - - - - - - - - - - - + + + + + + + + + + + - - - - + + + + + + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bcbe631 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# syntax=docker/dockerfile:1 + +# Build a self-contained, single-file netdocs CLI, then copy it into a small +# runtime-deps image. The Material theme is emitted next to the executable +# (theme/templates, theme/assets), so the whole publish folder is carried over. +FROM mcr.microsoft.com/dotnet/sdk:11.0-preview AS build +WORKDIR /src +COPY . . +RUN dotnet publish src/Netdocs.Cli -c Release -r linux-x64 --self-contained \ + -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true \ + -o /app + +FROM mcr.microsoft.com/dotnet/runtime-deps:11.0-preview +WORKDIR /site +# Ship a font so the social-cards plugin can render text. The minimal runtime-deps +# image has no fonts, which otherwise makes SixLabors.Fonts fail with +# "Cannot use the default value type instance to create a font". +RUN apt-get update \ + && apt-get install -y --no-install-recommends fonts-dejavu-core fontconfig \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /app /opt/netdocs +RUN ln -s /opt/netdocs/netdocs /usr/local/bin/netdocs +EXPOSE 8000 +ENTRYPOINT ["netdocs"] +CMD ["build"] diff --git a/README.md b/README.md index c72f20f..b642007 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,20 @@ # Netdocs +[![CI](https://github.com/XtremeOwnage/Netdocs/actions/workflows/ci.yml/badge.svg)](https://github.com/XtremeOwnage/Netdocs/actions/workflows/ci.yml) +[![Docs](https://github.com/XtremeOwnage/Netdocs/actions/workflows/docs.yml/badge.svg)](https://github.com/XtremeOwnage/Netdocs/actions/workflows/docs.yml) +[![Packages](https://github.com/XtremeOwnage/Netdocs/actions/workflows/packages.yml/badge.svg)](https://github.com/XtremeOwnage/Netdocs/actions/workflows/packages.yml) +[![Docker](https://github.com/XtremeOwnage/Netdocs/actions/workflows/docker.yml/badge.svg)](https://github.com/XtremeOwnage/Netdocs/actions/workflows/docker.yml) +[![Release](https://img.shields.io/github/v/release/XtremeOwnage/Netdocs?sort=semver)](https://github.com/XtremeOwnage/Netdocs/releases/latest) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Documentation](https://img.shields.io/badge/docs-xtremeownage.github.io%2FNetdocs-blue)](https://xtremeownage.github.io/Netdocs/) + A fast, flexible static site generator in **.NET 11** β€” a reimplementation of the [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) experience with a first-class C# plugin system. Site configuration lives in **`appsettings.json`**, and the Markdown in `docs/` builds with little to no change. +> πŸ“– **Full documentation:** + > [!IMPORTANT] > **Netdocs is an AI-generated derivative work** inspired by, and partially reusing > files from, [MkDocs](https://www.mkdocs.org/) and @@ -17,6 +27,49 @@ the Markdown in `docs/` builds with little to no change. > [`LICENSE`](LICENSE). If you want a supported, production-grade tool, use Material for > MkDocs directly and consider [sponsoring it](https://github.com/sponsors/squidfunk). +## Why should you use this project? + +Well, you probably shouldn't. I vibe-coded this in one day after being dissatisfied with +the performance of mkdocs building large sites with 200+ posts. No warranties, promises, +or guarantees. The intention was a .NET replacement that was nearly fully compatible with +my current project. If you're reading this, there's a good chance I succeeded and it met +my expectations. + +That said, use at your own risk. If PRs are submitted, I'll try to keep them maintained. +If issues are created, there's a good chance I'll try to review them. But I have a day job +and usually work on projects during the evenings. I stay very busy. + +I don't see this project ever reaching the scale of mkdocs / mkdocs-material, and +personally I hope it doesn't β€” despite being a software developer for 20+ years, I have no +idea how to maintain the scale of work-items generated by mkdocs-material. + +Contributions are welcomed. Donations are not accepted β€” I'm perfectly happy with a simple +kudos or a hello in my [Discord](https://static.xtremeownage.com/discord). Just make sure +to tag me β€” due to work and life, I may or may not be responsive. + +## How it compares + +A rough, honest comparison with the popular static site generators. The headline +difference is that Netdocs ships as a **single self-contained binary** β€” no Python, Ruby, +or Node toolchain to install. + +| | **Netdocs** | Material for MkDocs | MkDocs | Jekyll | Hugo | +|---|---|---|---|---|---| +| Runtime | .NET (single binary) | Python | Python | Ruby | Go (single binary) | +| Config format | `appsettings.json` | `mkdocs.yml` | `mkdocs.yml` | `_config.yml` | `hugo.toml` | +| Material theme | βœ… built-in | βœ… | βž– theme | βž– theme | βž– theme | +| Plugin language | C# (.NET DLL) | Python | Python | Ruby | Go templates/modules | +| Live-reload dev server | βœ… | βœ… | βœ… | βœ… | βœ… | +| Incremental render cache | βœ… | βž– | βž– | βž– | βœ… (fast) | +| Parallel rendering | βœ… | βž– | βž– | βž– | βœ… | +| Built-in search | βœ… (lunr) | βœ… (lunr) | βœ… (lunr) | βž– | βž– | +| Social cards | βœ… | βœ… (insiders/CairoSVG) | βž– | βž– | βž– | +| `mkdocs.yml` compatibility | βœ… near-drop-in | n/a | n/a | βž– | βž– | + +Netdocs targets **near drop-in compatibility with an existing Material for MkDocs site** β€” +the Markdown in `docs/` builds with little to no change. If you want a supported, +production-grade tool, use Material for MkDocs directly. + ## Credits & attribution - **Material for MkDocs** β€” Β© 2016-2025 Martin Donath. MIT. https://github.com/squidfunk/mkdocs-material @@ -24,22 +77,82 @@ the Markdown in `docs/` builds with little to no change. - **MkDocs** β€” the static-site-generator concepts and configuration model this project mirrors. - **lunr / lunr-languages** β€” MIT, Β© Oliver Nightingale and contributors. +For the **full list of derivative plugins and bundled libraries** (with authors and +licenses), see [`ATTRIBUTIONS.md`](ATTRIBUTIONS.md) or the +[Attributions page](https://xtremeownage.github.io/Netdocs/about/attributions/) on the docs site. + See [`src/Netdocs.Theme.Material/assets/vendor/material/NOTICE.md`](src/Netdocs.Theme.Material/assets/vendor/material/NOTICE.md) for the full list of vendored files and their origins. +## Install + +Most users should grab a prebuilt binary from the +**[releases page](https://github.com/XtremeOwnage/Netdocs/releases/latest)** β€” no .NET +SDK required. + +**Linux (Debian/Ubuntu β€” `.deb`):** + +```bash +curl -LO https://github.com/XtremeOwnage/Netdocs/releases/latest/download/netdocs_amd64.deb +sudo apt install ./netdocs_amd64.deb +``` + +**Linux (Fedora/RHEL β€” `.rpm`):** + +```bash +sudo yum install https://github.com/XtremeOwnage/Netdocs/releases/latest/download/netdocs_x86_64.rpm +``` + +**Linux (portable, no package manager):** download the self-contained `netdocs` binary +from the releases page, `chmod +x netdocs`, and put it on your `PATH`. + +**Windows:** download `netdocs.exe` from the +[releases page](https://github.com/XtremeOwnage/Netdocs/releases/latest) and run it +directly (optionally add its folder to `PATH`). + +**Docker:** + +```bash +docker run --rm -v ${PWD}:/site ghcr.io/xtremeownage/netdocs build --config /site/appsettings.json +``` + +> Exact asset file names may vary by release β€” check the +> [releases page](https://github.com/XtremeOwnage/Netdocs/releases/latest) for the build +> that matches your platform. + ## Quick start -```pwsh +Once installed, run the `netdocs` binary directly: + +```bash # Build a site (looks for ./appsettings.json, or pass --config) -dotnet run --project src/Netdocs.Cli -- build --config ..\Web\appsettings.json +netdocs build --config ./appsettings.json # Serve with live reload -dotnet run --project src/Netdocs.Cli -- serve --config ..\Web\appsettings.json --port 8000 +netdocs serve --config ./appsettings.json --port 8000 +``` + +Building from source instead? See [CONTRIBUTING.md](CONTRIBUTING.md) β€” contributors use +`dotnet run --project src/Netdocs.Cli -- build --config `, and in Visual Studio / +VS Code the **F5** launch profiles build and serve the sample site with the debugger +attached. + +### GitHub Action + +Building in CI? Use the reusable action β€” no .NET setup required, it downloads the native +binary for you: + +```yaml +- uses: XtremeOwnage/Netdocs@v1 + with: + command: build + config: appsettings.json + args: --prod + # version: 1.0.0 # pin a release, or omit for 'latest' ``` -In Visual Studio / VS Code, press **F5** β€” the `Netdocs: serve Web` launch profile builds -and serves the `Web/` site with the debugger attached (a `Netdocs: build Web` profile is -also provided). +See [Publishing β†’ Using the Netdocs GitHub Action](https://xtremeownage.github.io/Netdocs/setup/publishing/#using-the-netdocs-github-action) +for the full input reference. ## Commands @@ -47,6 +160,7 @@ also provided). |---|---| | `netdocs build` | Build the site to the configured output dir (`site_dir`). | | `netdocs serve` | Kestrel dev server with file-watch rebuilds + WebSocket live reload. | +| `netdocs --version` | Print the installed Netdocs version and exit. | Options: `--config/-f`, `--port/-p`, `--clean`, `--strict`, `--prod`, `--verbose/-v`. @@ -105,9 +219,14 @@ Plugins are matched to `appsettings.json` plugin names via the CLI's `PluginRegi Templates are [Scriban](https://github.com/scriban/scriban) (`.html`) in `Netdocs.Theme.Material/templates`. Member access is snake_case (`page.title`, `config.site_name`). Override templates by pointing `theme.custom_dir` at a folder of -**Scriban** templates. (Material's Jinja2 overrides are detected and ignored β€” see `TODO.md`.) +**Scriban** templates. (Material's Jinja2 overrides are detected and ignored β€” see the +[theme reference](https://xtremeownage.github.io/Netdocs/reference/theme/).) ## Status -Core engine works end-to-end and builds the existing `Web/` blog (200+ pages in seconds). -See [plan/TODO.md](plan/TODO.md) for parity gaps and roadmap, and [plan/dotnet-ssg-plan.md](plan/dotnet-ssg-plan.md) for the design. +Core engine works end-to-end and powers a real 200+ page blog β€” +[static.xtremeownage.com/blog](https://static.xtremeownage.com/blog) β€” building 243 pages +and ~1,880 images in about 6 seconds (see +[benchmarks](https://xtremeownage.github.io/Netdocs/about/benchmarks/)). See the +[documentation site](https://xtremeownage.github.io/Netdocs/) for the full reference, +plugin guides, and the build lifecycle. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..195aea3 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Supported versions + +Netdocs is a hobby project. Security fixes, when they happen, land on `main` and the +latest release. There is no long-term support commitment β€” see the +["Why should you use this project?"](README.md#why-should-you-use-this-project) note. + +| Version | Supported | +| --- | --- | +| `main` / latest release | βœ… best effort | +| older releases | ❌ | + +## Reporting a vulnerability + +**Please do not open a public issue for security vulnerabilities.** + +Instead, use GitHub's +[private vulnerability reporting](https://github.com/XtremeOwnage/Netdocs/security/advisories/new) +for this repository, or reach the maintainer privately via the +[Discord](https://static.xtremeownage.com/discord). + +Please include: + +- A description of the vulnerability and its impact. +- Steps to reproduce (a proof of concept if possible). +- Affected version(s) or commit. + +You can expect a best-effort acknowledgement. Because this is a spare-time project, +response times vary. Coordinated disclosure is appreciated β€” give a reasonable window for +a fix before publishing details. + +## Scope + +Netdocs is a static site generator: it reads local Markdown/config and writes HTML. The +most relevant risks are around build-time inputs (untrusted `appsettings.json`, external +plugin DLLs, and Markdown/macro processing). Treat external plugin assemblies as +trusted code β€” only load `plugins/*.dll` you built or trust. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..145032d --- /dev/null +++ b/action.yml @@ -0,0 +1,123 @@ +name: "Netdocs" +description: "Build (or serve) a static site with Netdocs, a fast .NET static site generator." +author: "XtremeOwnage" +branding: + icon: "book-open" + color: "purple" + +inputs: + command: + description: "Netdocs subcommand to run (e.g. build, serve, import)." + required: false + default: "build" + config: + description: "Path to the config file (appsettings.json, or mkdocs.yml for import). Passed as --config when set." + required: false + default: "" + version: + description: "Netdocs release version to use, e.g. 1.2.3, or 'latest' to resolve the newest release." + required: false + default: "latest" + args: + description: "Additional arguments appended verbatim to the netdocs command." + required: false + default: "" + working-directory: + description: "Directory (relative to the workspace) to run netdocs in." + required: false + default: "." + +outputs: + image: + description: "The container image reference that was used." + value: ${{ steps.run.outputs.image }} + version: + description: "The resolved Netdocs version that was used." + value: ${{ steps.run.outputs.version }} + +runs: + using: "composite" + steps: + - id: run + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_COMMAND: ${{ inputs.command }} + INPUT_CONFIG: ${{ inputs.config }} + INPUT_ARGS: ${{ inputs.args }} + INPUT_WORKDIR: ${{ inputs.working-directory }} + run: | + set -euo pipefail + + # Netdocs runs from its published container image. This avoids downloading + # and writing a ~100MB self-contained binary onto the runner (a fragile step + # that could fail with 'curl: (23) Failure writing output to destination'), + # and pins the whole toolchain -- CLI, theme, and dependencies -- to one image. + if ! command -v docker >/dev/null 2>&1; then + echo "::error::The Netdocs action runs via a Linux container and requires Docker on the runner. Use a Linux runner (e.g. ubuntu-latest)." >&2 + exit 1 + fi + + repo="XtremeOwnage/Netdocs" + image="ghcr.io/xtremeownage/netdocs" + version="${INPUT_VERSION}" + + # Resolve 'latest' to a concrete released version so the image tag is always a + # real, published vX.Y.Z. + # + # NOTE: never pipe a streaming `curl` into `grep -m1` under `set -o pipefail`: + # grep exits on first match and closes the pipe, so curl fails its next write + # with exit 23 and pipefail fails the step. Resolve via `gh`, and only fall + # back to curl by capturing the full response into a variable first. + if [ -z "${version}" ] || [ "${version}" = "latest" ]; then + tag="" + if command -v gh >/dev/null 2>&1 && [ -n "${GH_TOKEN:-}" ]; then + tag="$(gh release view --repo "${repo}" --json tagName --jq '.tagName' 2>/dev/null || true)" + fi + if [ -z "${tag}" ]; then + auth=() + if [ -n "${GH_TOKEN:-}" ]; then auth=(-H "Authorization: Bearer ${GH_TOKEN}"); fi + json="$(curl -fsSL "${auth[@]}" -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${repo}/releases/latest")" + tag="$(printf '%s\n' "${json}" | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')" + fi + if [ -z "${tag}" ]; then + echo "::error::Could not resolve the latest Netdocs release. Pin an explicit 'version:' instead." >&2 + exit 1 + fi + version="${tag#v}" + fi + + ref="${image}:v${version}" + echo "Using Netdocs image ${ref}" + docker pull "${ref}" + + # Assemble the netdocs command. The image ENTRYPOINT is `netdocs`, so only its + # arguments are supplied here. + cmd=("${INPUT_COMMAND}") + if [ -n "${INPUT_CONFIG}" ]; then + cmd+=(--config "${INPUT_CONFIG}") + fi + if [ -n "${INPUT_ARGS}" ]; then + # shellcheck disable=SC2206 + extra=(${INPUT_ARGS}) + cmd+=("${extra[@]}") + fi + + workdir="${INPUT_WORKDIR:-.}" + run_dir="/github/workspace/${workdir}" + + echo "Running: netdocs ${cmd[*]} (in ${run_dir})" + + # Mount the checked-out workspace and run as the runner user so generated + # files (e.g. ./site) are owned by the runner and readable by later steps. + docker run --rm \ + -v "${GITHUB_WORKSPACE}:/github/workspace" \ + -w "${run_dir}" \ + -u "$(id -u):$(id -g)" \ + -e HOME=/tmp \ + "${ref}" "${cmd[@]}" + + echo "image=${ref}" >> "${GITHUB_OUTPUT}" + echo "version=${version}" >> "${GITHUB_OUTPUT}" diff --git a/docs-site/appsettings.json b/docs-site/appsettings.json new file mode 100644 index 0000000..d53e1f5 --- /dev/null +++ b/docs-site/appsettings.json @@ -0,0 +1,142 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Build": "Information", + "Netdocs": "Information", + "Microsoft": "Warning" + } + }, + "Netdocs": { + "siteName": "Netdocs", + "siteAuthor": "Netdocs contributors", + "siteUrl": "https://xtremeownage.github.io/Netdocs/", + "siteDescription": "A fast, flexible static site generator in .NET 11 β€” a Material for MkDocs derivative.", + "copyright": "Netdocs β€” a derivative of MkDocs-Material (MIT).", + "repoUrl": "https://github.com/XtremeOwnage/Netdocs", + "repoName": "XtremeOwnage/Netdocs", + "docsDir": "docs", + "siteDir": "site", + + "theme": { + "name": "material", + "language": "en", + "font": { "text": "Roboto", "code": "Roboto Mono" }, + "palette": [ + { "scheme": "slate", "primary": "indigo", "accent": "indigo", "toggle": { "icon": "material/brightness-4", "name": "Switch to light mode" } }, + { "scheme": "default", "primary": "indigo", "accent": "indigo", "toggle": { "icon": "material/brightness-7", "name": "Switch to dark mode" } } + ], + "features": [ + "navigation.tabs", + "navigation.sections", + "navigation.top", + "navigation.footer", + "navigation.indexes", + "search.suggest", + "search.highlight", + "search.share", + "content.code.copy", + "toc.follow", + "toc.integrate" + ] + }, + + "nav": [ + { "title": "Home", "path": "index.md" }, + { + "title": "Setup", + "children": [ + { "path": "getting-started/index.md" }, + { "path": "getting-started/installation.md" }, + { "path": "getting-started/quickstart.md" }, + { "path": "reference/configuration.md" }, + { "path": "reference/theme.md" }, + { "path": "reference/cli.md" }, + { "path": "setup/migrating-from-mkdocs.md" } + ] + }, + { + "title": "Writing docs", + "children": [ + { "path": "reference/markdown-extensions.md" }, + { "path": "reference/code-blocks.md" }, + { "path": "reference/admonitions-and-tabs.md" }, + { + "title": "Plugins", + "children": [ + { "path": "plugins/index.md" }, + { "path": "plugins/abbreviations.md" }, + { "path": "plugins/link-notes.md" }, + { "path": "plugins/arithmatex.md" }, + { "path": "plugins/b64.md" }, + { "path": "plugins/blog.md" }, + { "path": "plugins/calculator.md" }, + { "path": "plugins/file-filter.md" }, + { "path": "plugins/git-revision-date.md" }, + { "path": "plugins/glightbox.md" }, + { "path": "plugins/macros.md" }, + { "path": "plugins/meta.md" }, + { "path": "plugins/redirects.md" }, + { "path": "plugins/rss.md" }, + { "path": "plugins/search.md" }, + { "path": "plugins/snippets.md" }, + { "path": "plugins/social.md" }, + { "path": "plugins/table-reader.md" }, + { "path": "plugins/tags.md" }, + { "path": "plugins/typeset.md" } + ] + } + ] + }, + { + "title": "Deployment", + "children": [ + { "path": "setup/publishing.md" }, + { "path": "setup/docker.md" }, + { "path": "setup/packaging.md" } + ] + }, + { + "title": "Development", + "children": [ + { "path": "development/lifecycle.md" }, + { "path": "development/events-and-callbacks.md" }, + { "path": "development/external-plugins.md" }, + { "path": "development/frontend-dependencies.md" }, + { "path": "development/versioning.md" } + ] + }, + { + "title": "About", + "children": [ + { "path": "about/benchmarks.md" }, + { "path": "about/attributions.md" } + ] + } + ], + + "plugins": [ + { "name": "search", "options": { "lang": "en" } }, + { "name": "tags", "options": { "export": true } }, + { "name": "meta" }, + { "name": "arithmatex" }, + { "name": "calculator" }, + { "name": "social" } + ], + + "markdownExtensions": [ + { "name": "admonition" }, + { "name": "pymdownx.details" }, + { "name": "attr_list" }, + { "name": "toc", "options": { "permalink": true } }, + { "name": "pymdownx.tabbed", "options": { "alternate_style": true } }, + { "name": "pymdownx.superfences" } + ], + + "extra": { + "social": [ + { "icon": "fontawesome/brands/github", "link": "https://github.com/XtremeOwnage/Netdocs" } + ] + } + } +} diff --git a/docs-site/docs/about/attributions.md b/docs-site/docs/about/attributions.md new file mode 100644 index 0000000..0dbbc75 --- /dev/null +++ b/docs-site/docs/about/attributions.md @@ -0,0 +1,57 @@ +--- +title: Attributions +--- + +# Attributions & derivative works + +Netdocs is an independent .NET implementation, but it deliberately mirrors the +behavior, configuration, and output of a number of excellent open-source projects so +that existing Material for MkDocs sites build with little to no change. This page +credits those upstream works. + +Netdocs is **not** affiliated with or endorsed by any of the projects below. All +trademarks and copyrights belong to their respective owners. + +## Theme & framework + +| Upstream | Author | License | What Netdocs derives | +|---|---|---|---| +| [Material for MkDocs](https://github.com/squidfunk/mkdocs-material) | Martin Donath (@squidfunk) | MIT | The theme templates, vendored CSS/JS, markup class names, feature flags, and overall UX. | +| [MkDocs](https://github.com/mkdocs/mkdocs) | MkDocs contributors | BSD-2-Clause | Configuration model (nav, plugins, markdown extensions) and site layout conventions. | + +## Plugins + +Each plugin below reimplements the behavior of an upstream MkDocs plugin or +PyMdown extension. See the linked plugin page for details. + +| Netdocs plugin | Modeled on | Author | License | +|---|---|---|---| +| [search](../plugins/search.md) | mkdocs-material search + [lunr](https://lunrjs.com/) | @squidfunk / Oliver Nightingale | MIT | +| [social](../plugins/social.md) | mkdocs-material social cards | @squidfunk | MIT | +| [blog](../plugins/blog.md) | mkdocs-material blog | @squidfunk | MIT | +| [tags](../plugins/tags.md) | mkdocs-material tags | @squidfunk | MIT | +| [meta](../plugins/meta.md) | mkdocs-material meta | @squidfunk | MIT | +| [snippets](../plugins/snippets.md) | [pymdownx.snippets](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/) | @facelessuser | MIT | +| [b64](../plugins/b64.md) | [pymdownx.b64](https://facelessuser.github.io/pymdown-extensions/extensions/b64/) | @facelessuser | MIT | +| [abbreviations](../plugins/abbreviations.md) | Python-Markdown `abbr` / pymdownx | Python-Markdown | BSD-3-Clause | +| [macros](../plugins/macros.md) | [mkdocs-macros-plugin](https://github.com/fralau/mkdocs-macros-plugin) | Laurent Franceschetti (@fralau) | MIT | +| [git-revision-date](../plugins/git-revision-date.md) | [mkdocs-git-revision-date-localized-plugin](https://github.com/timvink/mkdocs-git-revision-date-localized-plugin) | Tim Vink (@timvink) | MIT | +| [redirects](../plugins/redirects.md) | [mkdocs-redirects](https://github.com/mkdocs/mkdocs-redirects) | MkDocs contributors | MIT | +| [glightbox](../plugins/glightbox.md) | [mkdocs-glightbox](https://github.com/blueswen/mkdocs-glightbox) + [GLightbox](https://github.com/biati-digital/glightbox) | Blueswen / biati digital | MIT | +| [rss](../plugins/rss.md) | [mkdocs-rss-plugin](https://github.com/Guts/mkdocs-rss-plugin) | Julien Moura (@Guts) | MIT | +| table-reader | [mkdocs-table-reader-plugin](https://github.com/timvink/mkdocs-table-reader-plugin) | Tim Vink (@timvink) | MIT | +| [file-filter](../plugins/file-filter.md) | mkdocs-file-filter | community | MIT | + +## Bundled front-end libraries + +| Library | Author | License | Use | +|---|---|---|---| +| [highlight.js](https://github.com/highlightjs/highlight.js) | highlight.js contributors | BSD-3-Clause | Code block syntax highlighting. | +| [Mermaid](https://github.com/mermaid-js/mermaid) | Knut Sveidqvist & contributors | MIT | Diagrams from fenced ` ```mermaid ` blocks. | +| [Twemoji](https://github.com/jdecked/twemoji) | Twitter / jdecked | Code: MIT Β· Graphics: CC-BY 4.0 | Emoji shortcode rendering. | +| [Roboto / Roboto Mono](https://fonts.google.com/specimen/Roboto) | Christian Robertson / Google | Apache-2.0 | Default body and code fonts. | + +## License + +Netdocs itself is distributed under the MIT License. Reusing any of the works above +remains subject to their own licenses, which are linked from each project. diff --git a/docs-site/docs/about/benchmarks.md b/docs-site/docs/about/benchmarks.md new file mode 100644 index 0000000..e01aadc --- /dev/null +++ b/docs-site/docs/about/benchmarks.md @@ -0,0 +1,97 @@ +--- +title: Performance benchmarks +--- + +# Performance benchmarks + +Netdocs is a compiled .NET static site generator, so it builds large documentation +sets and image-heavy blogs in **seconds** rather than minutes. The numbers below are +measured against a real, production content set β€” the +[XtremeOwnage blog](https://static.xtremeownage.com/blog) β€” not a synthetic corpus. + +## Headline results + +Building the production XO blog (243 pages, **1,880 images**, 2,196 output files, +~255 MB of generated output): + +| Scenario | Pages | Wall time | Throughput | +|---|---:|---:|---:| +| Cold build (no cache, clean output) | 243 | ~6.0 s | ~40 pages/s | +| Warm build (incremental render cache) | 243 | ~5.4 s | ~45 pages/s | +| This documentation site | 32 | ~0.5 s | ~64 pages/s | + +Cold and warm times are close because the dominant cost for this content set is +copying and hashing ~1,880 images and ~255 MB of assets, not Markdown rendering β€” the +render cache primarily accelerates the Markdown parse/render step (see the +[incremental render cache](../reference/cli.md#incremental-render-cache)). + +## Test environment + +| Component | Value | +|---|---| +| CPU | Intel Core Ultra 9 185H | +| RAM | 64 GB | +| OS | Windows 11 (10.0.26100) | +| Runtime | .NET 11 | +| Build | `netdocs build` (Release) | +| Content | [static.xtremeownage.com/blog](https://static.xtremeownage.com/blog) β€” 243 pages, 1,880 images | + +Times are wall-clock for the `Built N pages in …` figure the CLI reports, averaged over +three runs. + +## How to reproduce + +The benchmark is just a timed `build` against your own content, so you can validate the +numbers on your hardware: + +```pwsh +# Cold build: clear the incremental cache and output first +Remove-Item -Recurse -Force .\.cache, .\site -ErrorAction SilentlyContinue +netdocs build --config .\appsettings.json + +# Warm build: run again to reuse the render cache +netdocs build --config .\appsettings.json +``` + +The CLI prints `Built pages in ms` at the end of each run, and warm builds also +log `Render cache: / pages reused`. + +## How this compares to mkdocs-material + +[Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) is the reference this +project is modelled on, so we benchmark against it directly on the **same content set** β€” +the XO blog repository β€” with social-card generation disabled on both sides (it shells out +to a headless browser / Cairo and is not equivalent work). + +| Generator | Cold build (same content) | Relative | +|---|---:|---:| +| **Netdocs** (`netdocs build`, Release) | **~6 s** | **1Γ—** | +| Material for MkDocs 9.x (`mkdocs build`) | ~97 s (best) – ~146 s (under load) | ~16–24Γ— slower | + +Both were run on the same machine (see [Test environment](#test-environment)) against the +same Markdown + images, with the Material `social` plugin disabled so neither tool pays for +social-card rendering. Material for MkDocs reported `Documentation built in 96.92 seconds` +on its fastest run; Netdocs reports `Built 243 pages in ~6 s` for the equivalent content. + +The gap comes from architecture, not tuning: Material for MkDocs starts a Python interpreter, +loads plugins, and renders every page through Jinja in a single process, while Netdocs is a +single compiled .NET binary that renders pages in parallel with an incremental render cache. + +### Reproducing the mkdocs-material number + +```pwsh +# From the MkDocs content repo (with mkdocs-material installed): +python -m mkdocs build -d site_mkdocs +``` + +We publish the **methodology** as well, because a like-for-like comparison depends heavily on +the Python interpreter, enabled plugins (especially social-card generation), and the +image-optimization pipeline: + +1. Point both generators at the same content set (same Markdown, same images). +2. Disable non-equivalent plugins on both sides so you compare the same work. +3. Measure a cold build (clear each tool's cache) and a warm build. +4. Compare wall-clock time and peak memory. + +Run the reproduction steps above against your own content to get numbers that reflect your +plugins and hardware. diff --git a/docs-site/docs/development/events-and-callbacks.md b/docs-site/docs/development/events-and-callbacks.md new file mode 100644 index 0000000..1130552 --- /dev/null +++ b/docs-site/docs/development/events-and-callbacks.md @@ -0,0 +1,257 @@ +# Events & callbacks reference + +Every Netdocs plugin implements `IPlugin` and then **opts in** to the parts of the build +it cares about by implementing one or more hook interfaces. This page documents each +interface, its exact signature, when it fires, and a short example. + +For the *order* in which these fire relative to each other, see the +[Build lifecycle](lifecycle.md). + +All contracts live in the `Netdocs.Abstractions` assembly +([`Plugins.cs`](https://github.com/XtremeOwnage/Netdocs/blob/main/src/Netdocs.Abstractions/Plugins.cs)). + +--- + +## `IPlugin` + +The base contract. Required by every plugin. + +```csharp +public interface IPlugin +{ + string Name { get; } + void Configure(IPluginContext ctx); +} +``` + +- **`Name`** β€” the id used to enable the plugin in `appsettings.json` + (`"plugins": [{ "name": "my-plugin" }]`). Built-in names always win over external ones. +- **`Configure`** β€” called **once** at build start (lifecycle stage 3), in the order + plugins are listed in config. Register assets, scripts, and services, and read your + options here. + +```csharp +public sealed class MyPlugin : IPlugin +{ + public string Name => "my-plugin"; + + public void Configure(IPluginContext ctx) + { + if (ctx.PluginOptions.TryGetValue("cdn", out var cdn) && cdn is string url) + ctx.AddStylesheet(url); + } +} +``` + +--- + +## `IPluginContext` + +The services handed to `Configure`. Not a hook you implement β€” it's what you *receive*. + +```csharp +public interface IPluginContext +{ + SiteConfig Config { get; } + BuildOptions Options { get; } + ILogger Logger { get; } + IServiceCollection Services { get; } + IReadOnlyDictionary PluginOptions { get; } + + void AddStylesheet(string href); + void AddScript(string src, bool defer = true); + void AddInlineScript(string javascript); + void AddAsset(string sourcePath, string destRelative); +} +``` + +| Member | Use | +| --- | --- | +| `Config` | The resolved `SiteConfig` (site name, theme, nav, extra, paths). | +| `Options` | Build flags: `IsProduction`, `IsServe`, `Strict`, `Clean`, `NoCache`. | +| `Logger` | Category logger for this plugin; honors `--verbose` and log config. | +| `Services` | DI collection β€” register services other plugins/hooks can resolve. | +| `PluginOptions` | Your plugin's `options` block from `appsettings.json`. | +| `AddStylesheet(href)` | Inject a `` into every page head. | +| `AddScript(src, defer)` | Inject a ` +"""; +} diff --git a/src/Netdocs.Plugins/GitRevisionDatePlugin.cs b/src/Netdocs.Plugins/GitRevisionDatePlugin.cs new file mode 100644 index 0000000..9f6a35f --- /dev/null +++ b/src/Netdocs.Plugins/GitRevisionDatePlugin.cs @@ -0,0 +1,99 @@ +using LibGit2Sharp; +using Microsoft.Extensions.Logging; +using Netdocs.Abstractions; + +namespace Netdocs.Plugins; + +/// +/// Sets per-page created/last-updated dates from git history (with a filesystem +/// fallback). Uses a single reverse-history walk to collect first/last commit dates +/// for every tracked path, so cost is one traversal regardless of page count. +/// +public sealed class GitRevisionDatePlugin : IPlugin, IBuildHook +{ + private bool _enableCreationDate; + private ILogger _log = null!; + + public string Name => "git-revision-date-localized"; + + public void Configure(IPluginContext ctx) + { + _log = ctx.Logger; + if (ctx.PluginOptions.TryGetValue("enable_creation_date", out var c) && c is bool b) + _enableCreationDate = b; + } + + public Task OnBuildStartAsync(SiteContext site, CancellationToken ct) + { + // Skip the git-history walk during `serve` to keep incremental rebuilds fast. + if (site.Options.IsServe) + { + ApplyFilesystem(site); + return Task.CompletedTask; + } + + var repoPath = Repository.Discover(site.Config.ProjectRoot); + if (repoPath is null) + { + _log.LogDebug("No git repository found; using filesystem timestamps"); + ApplyFilesystem(site); + return Task.CompletedTask; + } + + try + { + var (created, updated) = CollectDates(repoPath, ct); + var workDir = new Repository(repoPath).Info.WorkingDirectory; + var matched = 0; + foreach (var page in site.Pages) + { + if (page.IsGenerated || !File.Exists(page.SourcePath)) continue; + var rel = Path.GetRelativePath(workDir, page.SourcePath).Replace('\\', '/'); + if (updated.TryGetValue(rel, out var u)) { page.Updated = u; matched++; } + else page.Updated ??= File.GetLastWriteTimeUtc(page.SourcePath); + if (_enableCreationDate && created.TryGetValue(rel, out var cr)) page.Created ??= cr; + } + _log.LogDebug("git-revision-date: resolved dates for {Matched} pages", matched); + } + catch (Exception ex) + { + _log.LogWarning(ex, "git-revision-date failed; using filesystem timestamps"); + ApplyFilesystem(site); + } + return Task.CompletedTask; + } + + private static (Dictionary Created, Dictionary Updated) + CollectDates(string repoPath, CancellationToken ct) + { + var created = new Dictionary(StringComparer.OrdinalIgnoreCase); + var updated = new Dictionary(StringComparer.OrdinalIgnoreCase); + + using var repo = new Repository(repoPath); + var filter = new CommitFilter { SortBy = CommitSortStrategies.Topological | CommitSortStrategies.Time }; + foreach (var commit in repo.Commits.QueryBy(filter)) + { + ct.ThrowIfCancellationRequested(); + var when = commit.Author.When; + var parent = commit.Parents.FirstOrDefault(); + using var changes = repo.Diff.Compare(parent?.Tree, commit.Tree); + foreach (var change in changes) + { + var path = change.Path; + updated.TryAdd(path, when); // first seen (newest commit) = last modified + created[path] = when; // last write wins (oldest commit) = created + } + } + return (created, updated); + } + + private static void ApplyFilesystem(SiteContext site) + { + foreach (var page in site.Pages) + { + if (page.IsGenerated || !File.Exists(page.SourcePath)) continue; + page.Updated ??= File.GetLastWriteTimeUtc(page.SourcePath); + page.Created ??= File.GetCreationTimeUtc(page.SourcePath); + } + } +} diff --git a/src/Netdocs.Plugins/LinkNotesPlugin.cs b/src/Netdocs.Plugins/LinkNotesPlugin.cs new file mode 100644 index 0000000..c0df7a0 --- /dev/null +++ b/src/Netdocs.Plugins/LinkNotesPlugin.cs @@ -0,0 +1,254 @@ +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Netdocs.Abstractions; + +namespace Netdocs.Plugins; + +/// +/// Annotates outbound links that match configured rules with an automatic footnote +/// reference. The footnote carries a note (arbitrary markdown), so: +/// +/// hovering the link shows the note as a tooltip (via +/// content.footnote.tooltips), and +/// the note is emitted once at the bottom of the page (the footnote list). +/// +/// The plugin is generic and data-driven: each rule declares the domains +/// (with an optional query marker) and/or regular expressions that identify its links, +/// plus the note markdown. A common use-case is attaching affiliate-disclosure text to +/// eBay Partner Network / tagged Amazon links (which also satisfies the once-per-page +/// disclosure requirement automatically), but any note works. +/// +/// It runs after snippets/table-reader/macros so links injected by those plugins are +/// covered. Registered as both link-notes and the legacy alias +/// affiliate-links; the legacy programs/disclosure config keys are +/// still accepted. +/// +/// +public sealed class LinkNotesPlugin : IPlugin, IMarkdownPreprocessor +{ + private sealed record DomainRule(string Domain, string? QueryContains); + private sealed record Rule(string Id, DomainRule[] Domains, Regex[] Patterns, string Note, string Label); + + private readonly List _rules = []; + private ILogger? _log; + + public string Name => "link-notes"; + + // After snippets (10), table-reader (20) and macros (25) so their generated links are seen. + public int Order => 30; + + // Matches a markdown inline link `[text](url "title")` plus an optional attr-list `{...}`, any + // footnote reference already following it (so we don't double-annotate), and looks ahead for an + // immediately-adjacent `[` (another link/ref) which would make an injected footnote ambiguous. + private static readonly Regex LinkRegex = new( + """(?\[(?:[^\]]|\\\])*\]\(\s*[^)\s>]+)>?(?:\s+"[^"]*")?\s*\))(?\{[^}]*\})?(?\[\^[^\]]+\])?(?=(?\[)?)""", + RegexOptions.Compiled); + + public void Configure(IPluginContext ctx) + { + _log = ctx.Logger; + + // Accept the new `rules` key; fall back to the legacy `programs` key (affiliate-links). + if (!ctx.PluginOptions.TryGetValue("rules", out var raw) || raw is not IEnumerable) + ctx.PluginOptions.TryGetValue("programs", out raw); + + if (raw is IEnumerable list) + { + foreach (var item in list) + { + if (item is not IReadOnlyDictionary map) continue; + + var id = map.TryGetValue("name", out var n) ? n?.ToString() : null; + // `note` (new) or `disclosure` (legacy alias). + var note = (map.TryGetValue("note", out var nt) ? nt?.ToString() : null) + ?? (map.TryGetValue("disclosure", out var d) ? d?.ToString() : null); + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(note)) continue; + + // Rule-level query marker is the default for any domain that doesn't override it. + var defaultQuery = map.TryGetValue("query_contains", out var q) ? q?.ToString() : null; + if (string.IsNullOrEmpty(defaultQuery)) defaultQuery = null; + + var domains = ReadDomainRules(map, "domains", defaultQuery); + var patterns = ReadPatterns(map, "patterns"); + if (domains.Length == 0 && patterns.Length == 0) + { + _log.LogWarning("link-notes: rule '{Id}' has no domains or patterns; skipping", id); + continue; + } + + // Title used for the standalone fallback admonition (table-only links). + var label = map.TryGetValue("label", out var lv) && !string.IsNullOrWhiteSpace(lv?.ToString()) + ? lv!.ToString()!.Trim() + : "Links"; + + _rules.Add(new Rule(id!, domains, patterns, note!.Trim(), label)); + } + } + + if (_rules.Count == 0) + _log.LogWarning("link-notes: no link rules configured; plugin is a no-op"); + } + + public Task ProcessAsync(Page page, string markdown, SiteContext site, CancellationToken ct) + { + if (_rules.Count == 0 || markdown.Length == 0) return Task.FromResult(markdown); + + // Rules whose links got an inline footnote reference (definition will be rendered by the + // footnote extension) vs. rules seen only in contexts where a reference can't be injected + // (pipe-table cells), which need a standalone note block appended instead. + var referenced = new HashSet(); + var tableOnly = new HashSet(); + + var lines = markdown.Split('\n'); + var inFence = false; + + for (var i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimStart(); + if (trimmed.StartsWith("```", StringComparison.Ordinal) || trimmed.StartsWith("~~~", StringComparison.Ordinal)) + { + inFence = !inFence; + continue; + } + if (inFence) continue; + + // Markdig does not reliably parse footnote references inside pipe-table cells, so injecting + // one there breaks the table. Detect matching links there (to guarantee the footer + // note) but don't annotate the link. + if (trimmed.StartsWith("|", StringComparison.Ordinal)) + { + foreach (Match m in LinkRegex.Matches(lines[i])) + { + var rule = MatchRule(m.Groups["url"].Value); + if (rule is not null && !m.Groups["existing"].Success) tableOnly.Add(rule.Id); + } + continue; + } + + lines[i] = LinkRegex.Replace(lines[i], m => AnnotateLink(m, referenced, tableOnly)); + } + + // A rule that got at least one inline reference doesn't also need a standalone block. + tableOnly.ExceptWith(referenced); + + if (referenced.Count == 0 && tableOnly.Count == 0) return Task.FromResult(markdown); + + var sb = new StringBuilder(string.Join('\n', lines)); + sb.Append("\n\n"); + + // Footnote definitions for referenced rules: Markdig renders these at the bottom of the + // page (the footer note) and links every reference to them (the hover tooltip). + foreach (var rule in _rules.Where(r => referenced.Contains(r.Id))) + sb.Append("[^linknote-").Append(rule.Id).Append("]: ").Append(rule.Note).Append('\n'); + + // Rules seen only inside tables get a standalone note admonition so the footer note + // requirement is still met even though the individual links can't carry a tooltip. + foreach (var rule in _rules.Where(r => tableOnly.Contains(r.Id))) + { + sb.Append("\n!!! info \"").Append(rule.Label).Append("\"\n "); + sb.Append(rule.Note.Replace("\n", "\n ", StringComparison.Ordinal)); + sb.Append('\n'); + } + + return Task.FromResult(sb.ToString()); + } + + private string AnnotateLink(Match m, HashSet referenced, HashSet fallback) + { + var whole = m.Value; + var rule = MatchRule(m.Groups["url"].Value); + if (rule is null) return whole; + + // A footnote reference already follows this link (e.g. a hand-authored `[^ebay]`); leave it + // untouched so we don't produce duplicate references during content migration. + if (m.Groups["existing"].Success) return whole; + + // Another link/reference is glued directly after this one (e.g. `[a](x)[b](y)`); a footnote + // ref wedged between the `][` renders ambiguously, so skip it and rely on the fallback block. + if (m.Groups["adjacent"].Success) + { + fallback.Add(rule.Id); + return whole; + } + + referenced.Add(rule.Id); + return whole + $"[^linknote-{rule.Id}]"; + } + + private Rule? MatchRule(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return null; + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return null; + + var host = uri.Host; + foreach (var rule in _rules) + { + foreach (var dr in rule.Domains) + { + var domainHit = host.Equals(dr.Domain, StringComparison.OrdinalIgnoreCase) || + host.EndsWith("." + dr.Domain, StringComparison.OrdinalIgnoreCase); + if (!domainHit) continue; + + if (dr.QueryContains is not null && + url.IndexOf(dr.QueryContains, StringComparison.OrdinalIgnoreCase) < 0) + continue; + + return rule; + } + + // Regex fallback: any configured pattern that matches the full URL selects the rule. + foreach (var rx in rule.Patterns) + if (rx.IsMatch(url)) return rule; + } + return null; + } + + // Reads a rule's `domains` list. Each entry may be a plain string (uses the rule-level query + // marker, if any) or an object `{ "domain": "...", "query_contains": "..." }` to require a + // specific marker only for that domain (e.g. amazon.com needs `tag=` but amzn.to never does). + private static DomainRule[] ReadDomainRules(IReadOnlyDictionary map, string key, string? defaultQuery) + { + if (!map.TryGetValue(key, out var v) || v is not IEnumerable list) return []; + + var rules = new List(); + foreach (var entry in list) + { + switch (entry) + { + case string s when s.Length > 0: + rules.Add(new DomainRule(s, defaultQuery)); + break; + case IReadOnlyDictionary obj: + var dom = obj.TryGetValue("domain", out var dv) ? dv?.ToString() : null; + if (string.IsNullOrWhiteSpace(dom)) break; + var q = obj.TryGetValue("query_contains", out var qv) ? qv?.ToString() : null; + rules.Add(new DomainRule(dom!, string.IsNullOrEmpty(q) ? defaultQuery : q)); + break; + } + } + return rules.ToArray(); + } + + // Reads a rule's optional `patterns` list β€” regular expressions matched (case-insensitively) + // against the full link URL. Invalid patterns are logged and skipped rather than aborting. + private Regex[] ReadPatterns(IReadOnlyDictionary map, string key) + { + if (!map.TryGetValue(key, out var v) || v is not IEnumerable list) return []; + + var patterns = new List(); + foreach (var entry in list) + { + if (entry?.ToString() is not { Length: > 0 } pat) continue; + try + { + patterns.Add(new Regex(pat, RegexOptions.Compiled | RegexOptions.IgnoreCase)); + } + catch (ArgumentException ex) + { + _log?.LogWarning("link-notes: invalid pattern '{Pattern}': {Message}", pat, ex.Message); + } + } + return patterns.ToArray(); + } +} diff --git a/src/Netdocs.Plugins/MacrosPlugin.cs b/src/Netdocs.Plugins/MacrosPlugin.cs new file mode 100644 index 0000000..74746da --- /dev/null +++ b/src/Netdocs.Plugins/MacrosPlugin.cs @@ -0,0 +1,171 @@ +using System.Text.RegularExpressions; +using Netdocs.Abstractions; +using Netdocs.Core; + +namespace Netdocs.Plugins; + +/// +/// Minimal mkdocs-macros port. Ships two example function macros to show the pattern: +/// {{ fileuri("name") }} resolves a doc or asset to its published URL (prefixed with +/// site_url), and {{ button("text", "url") }} renders a Material-styled +/// call-to-action button. Sites can define their own simple text macros without writing C# +/// via the variables plugin option (each key becomes a {{ key }} token); +/// richer macros are added by writing a custom plugin. +/// Honors mkdocs-macros' render_by_default option and the per-page render_macros +/// / ignore_macros front-matter overrides. +/// +public sealed partial class MacrosPlugin : IPlugin, IMarkdownPreprocessor +{ + private string _projectRoot = ""; + private string _docsDir = ""; + private string _siteUrl = ""; + private bool _renderByDefault = true; + private IReadOnlyDictionary _variables = new Dictionary(); + + public string Name => "macros"; + public int Order => 25; // after snippets (10) / table-reader (20) so their output can use macros + + public void Configure(IPluginContext ctx) + { + _projectRoot = ctx.Config.ProjectRoot; + _docsDir = ctx.Config.AbsoluteDocsDir; + _siteUrl = (ctx.Config.SiteUrl ?? "").TrimEnd('/'); + + if (ctx.PluginOptions.TryGetValue("render_by_default", out var rbd) && rbd is bool b) + _renderByDefault = b; + + // User-defined variables: `variables: { key: value }` in the plugin config become + // `{{ key }}` text macros, so a site can define its own macros without writing C#. + if (ctx.PluginOptions.TryGetValue("variables", out var vars) && vars is IReadOnlyDictionary map) + { + _variables = map + .Where(kv => kv.Value is not null) + .ToDictionary(kv => kv.Key, kv => kv.Value!.ToString() ?? "", StringComparer.Ordinal); + } + } + + public Task ProcessAsync(Page page, string markdown, SiteContext site, CancellationToken ct) + { + if (!ShouldRender(page)) return Task.FromResult(markdown); + if (!markdown.Contains("{{", StringComparison.Ordinal)) return Task.FromResult(markdown); + + var result = FileUriRegex().Replace(markdown, m => + { + var url = ResolveFileUri(m.Groups["file"].Value, m.Groups["mode"].Value, page, site); + return url ?? $""; + }); + + result = ButtonRegex().Replace(result, m => + RenderButton(m.Groups["text"].Value, m.Groups["url"].Value)); + + // Bare `{{ name }}` tokens expand to a user-defined variable when one exists; + // unknown tokens are left untouched so they can be handled elsewhere (or shown literally). + if (_variables.Count > 0) + { + result = VariableRegex().Replace(result, m => + _variables.TryGetValue(m.Groups["name"].Value, out var val) ? val : m.Value); + } + + return Task.FromResult(result); + } + + /// + /// mkdocs-macros gating: when render_by_default is true, every page renders unless + /// it opts out with render_macros: false or ignore_macros: true. When false, + /// only pages with render_macros: true render. + /// + private bool ShouldRender(Page page) + { + if (TryBool(page, "ignore_macros") == true) return false; + var explicitFlag = TryBool(page, "render_macros"); + return explicitFlag ?? _renderByDefault; + } + + private static bool? TryBool(Page page, string key) => + page.FrontMatter.TryGetValue(key, out var v) && v is bool b ? b : null; + + private string? ResolveFileUri(string filename, string mode, Page page, SiteContext site) + { + var currentDir = DirName(page.RelativePath); + + // Prefer a page in the same directory, then any page with a matching file name. + var match = site.Pages.FirstOrDefault(p => + BaseName(p.RelativePath) == filename && DirName(p.RelativePath) == currentDir) + ?? site.Pages.FirstOrDefault(p => BaseName(p.RelativePath) == filename); + if (match is not null) return Format(match.Url, mode, page); + + // Fall back to a static asset that ships to the output verbatim. + var assetRel = ResolveAsset(filename, currentDir); + return assetRel is null ? null : Format(assetRel, mode, page); + } + + /// + /// Formats a resolved root-relative target URL according to the optional macro mode: + /// + /// ""/absolute/url β€” full URL prefixed with site_url when + /// configured (falls back to a root-absolute /path). This is the default. + /// path/root β€” a root-absolute /path, never including the host. + /// relative/rel β€” a page-relative URI (../ back to the site root), + /// which stays correct when the site is served under a base path. + /// + /// + private string Format(string url, string mode, Page page) + { + var raw = url.TrimStart('/'); + return (mode?.Trim().ToLowerInvariant()) switch + { + "path" or "root" => "/" + raw, + "relative" or "rel" => PageRenderer.BaseUrl(page.Url) + raw, + _ => Join(raw), + }; + } + + private string? ResolveAsset(string filename, string currentDir) + { + var sameDir = Path.Combine(_docsDir, currentDir.Replace('/', Path.DirectorySeparatorChar), filename); + if (File.Exists(sameDir)) + return CombineUrl(currentDir, filename); + + if (!Directory.Exists(_docsDir)) return null; + foreach (var path in Directory.EnumerateFiles(_docsDir, filename, SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(_docsDir, path).Replace('\\', '/'); + return rel; + } + return null; + } + + private string Join(string url) => + _siteUrl.Length > 0 ? $"{_siteUrl}/{url.TrimStart('/')}" : $"/{url.TrimStart('/')}"; + + private static string CombineUrl(string dir, string file) => + dir.Length == 0 ? file : $"{dir}/{file}"; + + private static string BaseName(string relative) => + Path.GetFileName(relative.Replace('\\', '/')); + + private static string DirName(string relative) + { + var d = Path.GetDirectoryName(relative.Replace('\\', '/')) ?? ""; + return d.Replace('\\', '/'); + } + + private static string RenderButton(string text, string url) => + $"""{Attr(text)}"""; + + private static string Attr(string text) => text + .Replace("&", "&") + .Replace("\"", """) + .Replace("<", "<") + .Replace(">", ">") + .Replace("'", "'"); + + [GeneratedRegex("""\{\{\s*fileuri\s*\(\s*["'](?[^"']+)["']\s*(?:,\s*["'](?[^"']*)["']\s*)?\)\s*\}\}""")] + private static partial Regex FileUriRegex(); + + [GeneratedRegex("""\{\{\s*button\s*\(\s*["'](?[^"']*)["']\s*,\s*["'](?[^"']+)["']\s*\)\s*\}\}""")] + private static partial Regex ButtonRegex(); + + [GeneratedRegex("""\{\{\s*(?[A-Za-z_][\w.]*)\s*\}\}""")] + private static partial Regex VariableRegex(); +} diff --git a/src/Netdocs.Plugins/Netdocs.Plugins.csproj b/src/Netdocs.Plugins/Netdocs.Plugins.csproj index aaf262e..42ea967 100644 --- a/src/Netdocs.Plugins/Netdocs.Plugins.csproj +++ b/src/Netdocs.Plugins/Netdocs.Plugins.csproj @@ -6,5 +6,8 @@ + + + diff --git a/src/Netdocs.Plugins/RedirectsPlugin.cs b/src/Netdocs.Plugins/RedirectsPlugin.cs new file mode 100644 index 0000000..3923746 --- /dev/null +++ b/src/Netdocs.Plugins/RedirectsPlugin.cs @@ -0,0 +1,47 @@ +using Netdocs.Abstractions; +using Netdocs.Core.Content; + +namespace Netdocs.Plugins; + +/// Emits client-side redirect pages from a redirect_maps option (source path -> target URL). +public sealed class RedirectsPlugin : IPlugin, IBuildHook +{ + private IReadOnlyDictionary _maps = new Dictionary(); + + public string Name => "redirects"; + + public void Configure(IPluginContext ctx) + { + if (ctx.PluginOptions.TryGetValue("redirect_maps", out var m) && m is IReadOnlyDictionary map) + _maps = map; + } + + public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) + { + foreach (var (source, targetObj) in _maps) + { + var target = targetObj?.ToString(); + if (string.IsNullOrWhiteSpace(target)) continue; + + var relative = source.EndsWith(".md", StringComparison.OrdinalIgnoreCase) + ? source[..^3] + "/index.html" + : source.TrimEnd('/') + "/index.html"; + var dest = Path.Combine(site.Config.AbsoluteSiteDir, relative.Replace('/', Path.DirectorySeparatorChar)); + + var escaped = System.Net.WebUtility.HtmlEncode(target); + var html = $""" + + + + + Redirecting… + + + + Redirecting to {escaped}… + + """; + await OutputWriter.WriteTextIfChangedAsync(site, dest, html, ct); + } + } +} diff --git a/src/Netdocs.Plugins/RssPlugin.cs b/src/Netdocs.Plugins/RssPlugin.cs index abfea19..fbcc0a9 100644 --- a/src/Netdocs.Plugins/RssPlugin.cs +++ b/src/Netdocs.Plugins/RssPlugin.cs @@ -1,20 +1,49 @@ using System.Text; +using System.Text.RegularExpressions; using System.Xml; using Netdocs.Abstractions; +using Netdocs.Core.Configuration; +using Netdocs.Core.Content; namespace Netdocs.Plugins; -/// Generates an RSS 2.0 feed from blog posts (created-date ordering). -public sealed class RssPlugin : IPlugin, IBuildHook +/// +/// Generates an RSS 2.0 feed (and, optionally, an Atom 1.0 feed) from blog posts, ordered by +/// creation date. Supports per-post title/description/image overrides via front matter, a +/// channel image, and full-content items. Requires the blog plugin to have collected posts. +/// +public sealed partial class RssPlugin : IPlugin, IBuildHook { - private string _feedFile = "feed_rss_created.xml"; + private const string ContentNs = "http://purl.org/rss/1.0/modules/content/"; + private const string AtomNs = "http://www.w3.org/2005/Atom"; + + private string _rssFile = "feed_rss_created.xml"; + private string _atomFile = "feed_atom_created.xml"; + private bool _atom; private int _limit = 20; + private bool _fullContent; + private string? _feedTitle; + private string? _feedDescription; + private string? _channelImage; + private int _ttl; public string Name => "rss"; public void Configure(IPluginContext ctx) { - if (ctx.PluginOptions.TryGetValue("length", out var l) && l is long ll) _limit = (int)ll; + var o = ctx.PluginOptions; + // `length` (mkdocs-rss) with `limit` accepted as an alias. + if (o.Get("length") is { } len) _limit = len.AsInt(_limit); + else if (o.Get("limit") is { } lim) _limit = lim.AsInt(_limit); + + if (o.Get("rss_file").AsString() is { Length: > 0 } rf) _rssFile = rf; + if (o.Get("atom_file").AsString() is { Length: > 0 } af) _atomFile = af; + _atom = o.Get("atom").AsBool(_atom); + _fullContent = o.Get("full_content").AsBool(_fullContent); + _feedTitle = o.Get("feed_title").AsString(); + _feedDescription = o.Get("feed_description").AsString(); + _channelImage = o.Get("image").AsString(); + _ttl = o.Get("ttl").AsInt(0); } public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) @@ -23,37 +52,205 @@ public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) return; var siteUrl = (site.Config.SiteUrl ?? "").TrimEnd('/'); + var items = posts.Take(_limit).Select(p => ToItem(p, siteUrl)).ToList(); + + var rss = BuildRss(site, siteUrl, items); + await OutputWriter.WriteTextIfChangedAsync(site, Path.Combine(site.Config.AbsoluteSiteDir, _rssFile), rss, ct); + + if (_atom) + { + var atom = BuildAtom(site, siteUrl, items); + await OutputWriter.WriteTextIfChangedAsync(site, Path.Combine(site.Config.AbsoluteSiteDir, _atomFile), atom, ct); + } + } + + private FeedItem ToItem(BlogPost post, string siteUrl) + { + var page = post.Page; + var url = $"{siteUrl}/{page.Url}"; + var title = FrontMatterString(page, "rss_title") ?? page.Title; + var description = FrontMatterString(page, "rss_description") ?? post.Excerpt; + var image = ResolveImage(page, url, siteUrl); + var content = _fullContent && page.HtmlContent.Length > 0 ? page.HtmlContent : null; + return new FeedItem(title, url, post.Date, post.Categories, description, image, content); + } + + private string BuildRss(SiteContext site, string siteUrl, List items) + { var sb = new StringBuilder(); - var settings = new XmlWriterSettings { Indent = true, Async = true, Encoding = new UTF8Encoding(false) }; - await using var writer = XmlWriter.Create(sb, settings); + using var writer = XmlWriter.Create(sb, XmlSettings()); - await writer.WriteStartDocumentAsync(); + writer.WriteStartDocument(); writer.WriteStartElement("rss"); writer.WriteAttributeString("version", "2.0"); + writer.WriteAttributeString("xmlns", "atom", null, AtomNs); + writer.WriteAttributeString("xmlns", "content", null, ContentNs); writer.WriteStartElement("channel"); - writer.WriteElementString("title", site.Config.SiteName); + + writer.WriteElementString("title", _feedTitle ?? site.Config.SiteName); writer.WriteElementString("link", siteUrl + "/"); - writer.WriteElementString("description", site.Config.SiteDescription ?? site.Config.SiteName); + writer.WriteElementString("description", _feedDescription ?? site.Config.SiteDescription ?? site.Config.SiteName); + if (items.Count > 0) + writer.WriteElementString("lastBuildDate", items[0].Date.ToString("r")); + if (_ttl > 0) + writer.WriteElementString("ttl", _ttl.ToString()); + + // atom:link rel="self" is recommended so readers can find the canonical feed URL. + writer.WriteStartElement("atom", "link", AtomNs); + writer.WriteAttributeString("href", $"{siteUrl}/{_rssFile}"); + writer.WriteAttributeString("rel", "self"); + writer.WriteAttributeString("type", "application/rss+xml"); + writer.WriteEndElement(); + + if (!string.IsNullOrEmpty(_channelImage)) + { + writer.WriteStartElement("image"); + writer.WriteElementString("url", AbsoluteUrl(_channelImage!, siteUrl)); + writer.WriteElementString("title", _feedTitle ?? site.Config.SiteName); + writer.WriteElementString("link", siteUrl + "/"); + writer.WriteEndElement(); + } - foreach (var post in posts.Take(_limit)) + foreach (var item in items) { writer.WriteStartElement("item"); - writer.WriteElementString("title", post.Page.Title); - writer.WriteElementString("link", $"{siteUrl}/{post.Page.Url}"); - writer.WriteElementString("guid", $"{siteUrl}/{post.Page.Url}"); - writer.WriteElementString("pubDate", post.Date.ToString("r")); - foreach (var category in post.Categories) + writer.WriteElementString("title", item.Title); + writer.WriteElementString("link", item.Url); + writer.WriteStartElement("guid"); + writer.WriteAttributeString("isPermaLink", "true"); + writer.WriteString(item.Url); + writer.WriteEndElement(); + writer.WriteElementString("pubDate", item.Date.ToString("r")); + foreach (var category in item.Categories) writer.WriteElementString("category", category); - writer.WriteElementString("description", post.Excerpt); + writer.WriteElementString("description", item.Description); + if (item.Content is { } html) + writer.WriteElementString("encoded", ContentNs, html); + if (item.Image is { } img) + { + writer.WriteStartElement("enclosure"); + writer.WriteAttributeString("url", img); + writer.WriteAttributeString("type", MimeForImage(img)); + writer.WriteAttributeString("length", "0"); + writer.WriteEndElement(); + } writer.WriteEndElement(); } writer.WriteEndElement(); writer.WriteEndElement(); - await writer.WriteEndDocumentAsync(); - await writer.FlushAsync(); + writer.WriteEndDocument(); + writer.Flush(); + return sb.ToString(); + } + + private string BuildAtom(SiteContext site, string siteUrl, List items) + { + var sb = new StringBuilder(); + using var writer = XmlWriter.Create(sb, XmlSettings()); - var path = Path.Combine(site.Config.AbsoluteSiteDir, _feedFile); - await File.WriteAllTextAsync(path, sb.ToString(), ct); + writer.WriteStartDocument(); + writer.WriteStartElement("feed", AtomNs); + writer.WriteElementString("title", _feedTitle ?? site.Config.SiteName); + writer.WriteElementString("subtitle", _feedDescription ?? site.Config.SiteDescription ?? site.Config.SiteName); + writer.WriteElementString("id", siteUrl + "/"); + writer.WriteElementString("updated", (items.Count > 0 ? items[0].Date : DateTimeOffset.UtcNow).ToString("yyyy-MM-ddTHH:mm:sszzz")); + + writer.WriteStartElement("link"); + writer.WriteAttributeString("href", siteUrl + "/"); + writer.WriteAttributeString("rel", "alternate"); + writer.WriteEndElement(); + writer.WriteStartElement("link"); + writer.WriteAttributeString("href", $"{siteUrl}/{_atomFile}"); + writer.WriteAttributeString("rel", "self"); + writer.WriteEndElement(); + + foreach (var item in items) + { + writer.WriteStartElement("entry"); + writer.WriteElementString("title", item.Title); + writer.WriteElementString("id", item.Url); + writer.WriteElementString("updated", item.Date.ToString("yyyy-MM-ddTHH:mm:sszzz")); + writer.WriteElementString("published", item.Date.ToString("yyyy-MM-ddTHH:mm:sszzz")); + writer.WriteStartElement("link"); + writer.WriteAttributeString("href", item.Url); + writer.WriteAttributeString("rel", "alternate"); + writer.WriteEndElement(); + foreach (var category in item.Categories) + { + writer.WriteStartElement("category"); + writer.WriteAttributeString("term", category); + writer.WriteEndElement(); + } + if (item.Content is { } html) + { + writer.WriteStartElement("content"); + writer.WriteAttributeString("type", "html"); + writer.WriteString(html); + writer.WriteEndElement(); + } + else + { + writer.WriteElementString("summary", item.Description); + } + writer.WriteEndElement(); + } + + writer.WriteEndElement(); + writer.WriteEndDocument(); + writer.Flush(); + return sb.ToString(); } + + private static XmlWriterSettings XmlSettings() => + new() { Indent = true, Encoding = new UTF8Encoding(false) }; + + private static string? FrontMatterString(Page page, string key) => + page.FrontMatter.TryGetValue(key, out var v) ? v?.ToString() : null; + + /// + /// Resolves a post image: front-matter image when present, otherwise the first + /// <img src> found in the rendered content. Relative URLs are made absolute. + /// + private static string? ResolveImage(Page page, string postUrl, string siteUrl) + { + var image = FrontMatterString(page, "image"); + if (string.IsNullOrEmpty(image)) + { + var match = FirstImage().Match(page.HtmlContent); + if (match.Success) image = match.Groups[1].Value; + } + if (string.IsNullOrEmpty(image)) return null; + + if (image.Contains("://")) return image; + if (image.StartsWith('/')) return siteUrl + image; + // Relative to the post's output directory. + return postUrl.TrimEnd('/') + "/" + image; + } + + private static string AbsoluteUrl(string url, string siteUrl) => + url.Contains("://") ? url : siteUrl + "/" + url.TrimStart('/'); + + private static string MimeForImage(string url) => + Path.GetExtension(url).ToLowerInvariant() switch + { + ".png" => "image/png", + ".gif" => "image/gif", + ".webp" => "image/webp", + ".svg" => "image/svg+xml", + ".avif" => "image/avif", + _ => "image/jpeg", + }; + + [GeneratedRegex("""]+src=["']([^"']+)["']""", RegexOptions.IgnoreCase)] + private static partial Regex FirstImage(); + + private sealed record FeedItem( + string Title, + string Url, + DateTimeOffset Date, + IReadOnlyList Categories, + string Description, + string? Image, + string? Content); } diff --git a/src/Netdocs.Plugins/SearchPlugin.cs b/src/Netdocs.Plugins/SearchPlugin.cs index 73830fc..d5b4547 100644 --- a/src/Netdocs.Plugins/SearchPlugin.cs +++ b/src/Netdocs.Plugins/SearchPlugin.cs @@ -3,20 +3,36 @@ using AngleSharp.Dom; using AngleSharp.Html.Parser; using Netdocs.Abstractions; +using Netdocs.Core.Content; namespace Netdocs.Plugins; /// Emits a Material-compatible search/search_index.json (page + per-section docs). public sealed class SearchPlugin : IPlugin, IBuildHook { - private string _language = "en"; + private static readonly string[] DefaultPipeline = ["stemmer", "stopWordFilter", "trimmer"]; + private const string DefaultSeparator = "[\\s\\-]+"; + + private IReadOnlyList _languages = ["en"]; + private string _separator = DefaultSeparator; + private IReadOnlyList _pipeline = DefaultPipeline; public string Name => "search"; public void Configure(IPluginContext ctx) { - if (ctx.PluginOptions.TryGetValue("lang", out var lang) && lang is string l) - _language = l; + var opts = ctx.PluginOptions; + + // `lang` accepts a single language ("en") or a list (["en", "de"]). + if (opts.TryGetValue("lang", out var lang)) + _languages = AsStringList(lang) is { Count: > 0 } list ? list : _languages; + + if (opts.TryGetValue("separator", out var sep) && sep is string s && s.Length > 0) + _separator = s; + + // `pipeline` overrides the lunr token pipeline; an empty list disables stemming/stopwords. + if (opts.TryGetValue("pipeline", out var pipe) && AsStringList(pipe) is { } p) + _pipeline = p; } public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) @@ -27,9 +43,10 @@ public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) foreach (var page in site.Pages) { var tags = ExtractTags(page); - docs.Add(new SearchDoc(page.Url, page.Title, Collapse(page.PlainText), tags)); + var (intro, sections) = SplitPage(parser, page); + docs.Add(new SearchDoc(page.Url, page.Title, intro, tags)); - foreach (var section in SplitSections(parser, page)) + foreach (var section in sections) docs.Add(section with { Tags = tags }); } @@ -40,44 +57,73 @@ public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) ["tags"] = new Dictionary { ["boost"] = 1000000.0 }, }; var index = new SearchIndex( - new SearchConfig([_language], "[\\s\\-]+", ["stemmer", "stopWordFilter", "trimmer"], fields), + new SearchConfig(_languages, _separator, _pipeline, fields), docs); var dir = Path.Combine(site.Config.AbsoluteSiteDir, "search"); - Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(index, SearchJson.Options); - await File.WriteAllTextAsync(Path.Combine(dir, "search_index.json"), json, ct); + await OutputWriter.WriteTextIfChangedAsync(site, Path.Combine(dir, "search_index.json"), json, ct); } - private static IEnumerable SplitSections(HtmlParser parser, Page page) + /// Splits a page into its page-level text and per-section docs, matching mkdocs-material. + /// The H1 is the page title (not a section), so the content beneath it β€” up to the first H2/H3 β€” + /// becomes the page-level doc's text. That text is the teaser Material shows on the main result + /// line for each page, so leaving it empty (as an H1-as-section split does) drops all excerpts. + /// Block-level HTML is preserved in the text so teasers render like the upstream index. + private static (string Intro, IReadOnlyList Sections) SplitPage(HtmlParser parser, Page page) { - if (string.IsNullOrEmpty(page.HtmlContent)) yield break; + if (string.IsNullOrEmpty(page.HtmlContent)) + return (Collapse(page.PlainText), []); + var doc = parser.ParseDocument($"{page.HtmlContent}"); var body = doc.Body; - if (body is null) yield break; + if (body is null) return (Collapse(page.PlainText), []); + var sections = new List(); + var intro = new StringBuilder(); string? currentId = null, currentTitle = null; var buffer = new StringBuilder(); + var seenSection = false; foreach (var node in body.ChildNodes) { - if (node is IElement el && el.TagName is "H1" or "H2" or "H3" && !string.IsNullOrEmpty(el.Id)) + // The H1 is the page title, not a searchable section; its text is captured by page.Title + // and the content that follows it flows into the page-level teaser. + if (node is IElement h1 && h1.TagName == "H1") + continue; + + if (node is IElement el && el.TagName is "H2" or "H3" && !string.IsNullOrEmpty(el.Id)) { if (currentId is not null) - yield return new SearchDoc($"{page.Url}#{currentId}", currentTitle ?? "", Collapse(buffer.ToString()), []); + sections.Add(new SearchDoc($"{page.Url}#{currentId}", currentTitle ?? "", Collapse(buffer.ToString()), [])); currentId = el.Id; currentTitle = el.TextContent.Trim(); buffer.Clear(); + seenSection = true; + } + else if (seenSection) + { + buffer.Append(NodeText(node)).Append(' '); } else { - buffer.Append(node.TextContent).Append(' '); + intro.Append(NodeText(node)).Append(' '); } } if (currentId is not null) - yield return new SearchDoc($"{page.Url}#{currentId}", currentTitle ?? "", Collapse(buffer.ToString()), []); + sections.Add(new SearchDoc($"{page.Url}#{currentId}", currentTitle ?? "", Collapse(buffer.ToString()), [])); + + var introText = Collapse(intro.ToString()); + if (introText.Length == 0 && sections.Count == 0) introText = Collapse(page.PlainText); + return (introText, sections); } + /// Serializes a node for the search index: block-level HTML is kept (matching the + /// upstream mkdocs-material index, whose text field carries markup so teasers render richly), + /// while bare text nodes contribute their text content. + private static string NodeText(INode node) => + node is IElement el ? el.OuterHtml : node.TextContent; + private static string[] ExtractTags(Page page) { if (page.FrontMatter.TryGetValue("tags", out var t) && t is IEnumerable list) @@ -87,6 +133,15 @@ private static string[] ExtractTags(Page page) private static string Collapse(string text) => string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + /// Normalizes a plugin option that may be a single string or a list into a string list. + /// Returns null when the value is neither (so callers can keep their default). + private static IReadOnlyList? AsStringList(object? value) => value switch + { + string s => [s], + IEnumerable items => items.Select(x => x?.ToString() ?? "").Where(s => s.Length > 0).ToList(), + _ => null, + }; } public sealed record SearchIndex(SearchConfig Config, IReadOnlyList Docs); diff --git a/src/Netdocs.Plugins/Slug.cs b/src/Netdocs.Plugins/Slug.cs index d518540..32dfaa2 100644 --- a/src/Netdocs.Plugins/Slug.cs +++ b/src/Netdocs.Plugins/Slug.cs @@ -1,12 +1,18 @@ using System.Globalization; using System.Text; +using Netdocs.Abstractions; namespace Netdocs.Plugins; -/// Slugifies text for URLs (lowercase, hyphen-separated, ASCII). +/// Slugifies text for URLs. Default: lowercase, hyphen-separated, accents folded. public static class Slug { - public static string Make(string text, string separator = "-") + /// Slugify with a custom separator (default behavior otherwise). + public static string Make(string text, string separator = "-") => + Make(text, new SlugifyConfig { Separator = separator }); + + /// Slugify honoring a (case, separator, ASCII folding). + public static string Make(string text, SlugifyConfig config) { var normalized = text.Normalize(NormalizationForm.FormD); var sb = new StringBuilder(normalized.Length); @@ -14,11 +20,26 @@ public static string Make(string text, string separator = "-") { var category = CharUnicodeInfo.GetUnicodeCategory(c); if (category == UnicodeCategory.NonSpacingMark) continue; - if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c)); - else if (char.IsWhiteSpace(c) || c is '-' or '_' or '/') sb.Append(' '); + + if (char.IsLetterOrDigit(c)) + { + if (config.Ascii && c > 127) continue; // drop non-ASCII letters/digits when ASCII-only + sb.Append(ApplyCase(c, config.Case)); + } + else if (char.IsWhiteSpace(c) || c is '-' or '_' or '/') + { + sb.Append(' '); + } } var words = sb.ToString().Split(' ', StringSplitOptions.RemoveEmptyEntries); - return string.Join(separator, words); + return string.Join(config.Separator, words); } + + private static char ApplyCase(char c, string @case) => @case?.ToLowerInvariant() switch + { + "upper" => char.ToUpperInvariant(c), + "none" => c, + _ => char.ToLowerInvariant(c), + }; } diff --git a/src/Netdocs.Plugins/SnippetsPlugin.cs b/src/Netdocs.Plugins/SnippetsPlugin.cs index 99dfd7d..f13a298 100644 --- a/src/Netdocs.Plugins/SnippetsPlugin.cs +++ b/src/Netdocs.Plugins/SnippetsPlugin.cs @@ -6,7 +6,11 @@ namespace Netdocs.Plugins; /// /// Implements pymdownx.snippets: --8<-- "file" includes (with optional -/// file:section ranges) and auto_append files added to every page. +/// file:section ranges) and auto_append files added to every page. Also +/// searches a conventional <root>/snippets directory by default, and supports an +/// inline parameterized form --8<-- "file" key="value" ... that substitutes +/// ${key} placeholders in the included file (values HTML-escaped) and can be used +/// inline (e.g. inside a table cell). /// public sealed partial class SnippetsPlugin : IPlugin, IMarkdownPreprocessor { @@ -27,6 +31,12 @@ public void Configure(IPluginContext ctx) if (_basePaths.Count == 0) _basePaths.Add(_projectRoot); + // Always search a conventional /snippets directory so `--8<-- "name"` resolves + // there by default even when base_path is not configured for it. + var defaultSnippets = Path.GetFullPath(Path.Combine(_projectRoot, "snippets")); + if (!_basePaths.Contains(defaultSnippets)) + _basePaths.Add(defaultSnippets); + var autoAppend = ctx.PluginOptions.TryGetValue("auto_append", out var aa) ? aa : null; foreach (var p in AsStringList(autoAppend)) _autoAppend.Add(p); @@ -50,6 +60,26 @@ public Task ProcessAsync(Page page, string markdown, SiteContext site, C private string Expand(string markdown, int depth) { if (depth > 10) return markdown; + + // 1. Inline parameterized includes: `--8<-- "file" key="value" ...`. These may appear + // mid-line (e.g. inside a markdown table cell); `${key}` placeholders in the included + // file are replaced with the (HTML-escaped) argument values and the result is trimmed + // to a single inline fragment. + markdown = ParamIncludeRegex().Replace(markdown, match => + { + var path = match.Groups["spec"].Value.Trim(); + var resolved = Resolve(path); + if (resolved is null || !File.Exists(resolved)) + return $""; + + var args = ParseArgs(match.Groups["args"].Value); + var content = File.ReadAllText(resolved); + foreach (var (key, value) in args) + content = content.Replace("${" + key + "}", HtmlEscape(value), StringComparison.Ordinal); + return Expand(content, depth + 1).Trim(); + }); + + // 2. Block includes: `--8<-- "file"` (optionally `file:section`) on their own line. return IncludeRegex().Replace(markdown, match => { var spec = match.Groups["spec"].Value.Trim(); @@ -73,6 +103,20 @@ private string Expand(string markdown, int depth) }); } + /// Parses trailing key="value" pairs from a parameterized include. + private static IEnumerable<(string Key, string Value)> ParseArgs(string args) + { + foreach (Match m in ArgRegex().Matches(args)) + yield return (m.Groups["k"].Value, m.Groups["v"].Value); + } + + private static string HtmlEscape(string text) => text + .Replace("&", "&") + .Replace("\"", """) + .Replace("<", "<") + .Replace(">", ">") + .Replace("'", "'"); + private static string ExtractSection(string content, string section) { var start = new Regex($@"--8<--\s*\[start:{Regex.Escape(section)}\]"); @@ -114,4 +158,12 @@ private static string TrimTrailingMarkerLine(string slice) [GeneratedRegex(@"^[ \t]*(?:;\s*)?--8<--(?:-)?[ \t]+""(?[^""]+)""[ \t]*$", RegexOptions.Multiline)] private static partial Regex IncludeRegex(); + + // Inline parameterized include: `--8<-- "file" key="value" ...` (at least one argument). + // Not line-anchored, so it can be used inside prose or table cells. + [GeneratedRegex(@"--8<--(?:-)?[ \t]+""(?[^""]+)""(?(?:[ \t]+[A-Za-z_][\w.-]*=""[^""]*"")+)")] + private static partial Regex ParamIncludeRegex(); + + [GeneratedRegex(@"(?[A-Za-z_][\w.-]*)=""(?[^""]*)""")] + private static partial Regex ArgRegex(); } diff --git a/src/Netdocs.Plugins/SocialPlugin.cs b/src/Netdocs.Plugins/SocialPlugin.cs new file mode 100644 index 0000000..61b0769 --- /dev/null +++ b/src/Netdocs.Plugins/SocialPlugin.cs @@ -0,0 +1,183 @@ +using Microsoft.Extensions.Logging; +using Netdocs.Abstractions; +using Netdocs.Core; +using SixLabors.Fonts; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; + +namespace Netdocs.Plugins; + +/// Generates Material-style social (Open Graph) cards for each page. +public sealed class SocialPlugin : IPlugin, IBuildHook +{ + private const int Width = 1200; + private const int Height = 630; + + private ILogger _log = null!; + private bool _cache = true; + private bool _enabledOnServe = true; + private Color _background = Color.ParseHex("42464e"); + private Color _accent = Color.ParseHex("ff9800"); + + public string Name => "social"; + + public void Configure(IPluginContext ctx) + { + _log = ctx.Logger; + if (ctx.PluginOptions.TryGetValue("cache", out var c) && c is bool cb) _cache = cb; + // Cards are cached by file existence, so serve only pays the cost once (on the + // first build). Generate on serve by default; large sites can opt out. + if (ctx.PluginOptions.TryGetValue("enabled_on_serve", out var eos) && eos is bool eosb) _enabledOnServe = eosb; + + var palette = ctx.Config.Theme.Palette.Count > 0 ? ctx.Config.Theme.Palette[0] : null; + _background = PrimaryColor(palette?.Primary); + _accent = AccentColor(palette?.Accent); + } + + public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) + { + // Cards are content-cached by file existence (see below), so a serve session only + // generates missing cards once. Skip only when explicitly disabled on serve. + if (site.Options.IsServe && !_enabledOnServe) return; + + var family = ResolveFontFamily(); + if (family is null) + { + _log.LogWarning("social: no usable system font found; skipping card generation"); + return; + } + + var outDir = Path.Combine(site.Config.AbsoluteSiteDir, "assets", "social"); + Directory.CreateDirectory(outDir); + + var titleFont = family.Value.CreateFont(58, FontStyle.Bold); + var siteFont = family.Value.CreateFont(28, FontStyle.Regular); + var descFont = family.Value.CreateFont(30, FontStyle.Regular); + + var count = 0; + Parallel.ForEach(site.Pages, new ParallelOptions { CancellationToken = ct, MaxDegreeOfParallelism = Environment.ProcessorCount }, page => + { + var relative = SocialImagePath.For(page); + var dest = Path.Combine(site.Config.AbsoluteSiteDir, relative.Replace('/', Path.DirectorySeparatorChar)); + site.TrackOutput(dest); + if (_cache && File.Exists(dest)) return; + + // Defensive: ensure the parent directory exists even if the shared + // outDir was pruned or the card path is ever nested. Cheap + idempotent. + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + + var title = string.IsNullOrWhiteSpace(page.Title) ? site.Config.SiteName : page.Title; + var description = page.FrontMatter.TryGetValue("description", out var d) && d is string ds && ds.Length > 0 + ? ds : site.Config.SiteDescription ?? ""; + + using var image = RenderCard(title, site.Config.SiteName, description, titleFont, siteFont, descFont); + image.SaveAsPng(dest); + Interlocked.Increment(ref count); + }); + + _log.LogInformation("social: generated {Count} card(s)", count); + await Task.CompletedTask; + } + + private Image RenderCard(string title, string siteName, string description, + Font titleFont, Font siteFont, Font descFont) + { + var image = new Image(Width, Height); + const int pad = 70; + var textWidth = Width - pad * 2; + + image.Mutate(ctx => + { + ctx.Fill(_background); + // Accent bar on the left edge. + ctx.Fill(_accent, new SixLabors.ImageSharp.Drawing.RectangularPolygon(0, 0, 12, Height)); + + var white = Color.WhiteSmoke; + var muted = Color.ParseHex("c9ccd1"); + + // Site name (top). + ctx.DrawText(siteName.ToUpperInvariant(), siteFont, muted, new PointF(pad, pad)); + + // Title (wrapped), vertically centered-ish. + var titleOptions = new RichTextOptions(titleFont) + { + Origin = new PointF(pad, 190), + WrappingLength = textWidth, + LineSpacing = 1.1f, + }; + ctx.DrawText(titleOptions, title, white); + + // Description (bottom area). + if (description.Length > 0) + { + var descOptions = new RichTextOptions(descFont) + { + Origin = new PointF(pad, Height - pad - 120), + WrappingLength = textWidth, + LineSpacing = 1.15f, + }; + ctx.DrawText(descOptions, Truncate(description, 180), muted); + } + }); + + return image; + } + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..max].TrimEnd() + "…"; + + private FontFamily? ResolveFontFamily() + { + foreach (var name in new[] { "Open Sans", "Roboto", "Segoe UI", "Arial", "Helvetica", "DejaVu Sans", "Liberation Sans", "Noto Sans" }) + if (SystemFonts.TryGet(name, out var family)) + return family; + + // SystemFonts.Families.FirstOrDefault() returns a *default* FontFamily struct + // (not null) when no fonts are installed -- e.g. inside a minimal container. + // Calling CreateFont on that default throws "Cannot use the default value type + // instance to create a font" and fails the whole build. Only return a family + // when one genuinely exists, so callers can skip card generation instead. + var first = SystemFonts.Families.FirstOrDefault(); + if (first != default) + { + _log.LogInformation("social: using fallback system font '{Font}'", first.Name); + return first; + } + + return null; + } + + private static Color PrimaryColor(string? name) => (name ?? "grey").ToLowerInvariant() switch + { + "red" => Color.ParseHex("ef5350"), + "pink" => Color.ParseHex("e91e63"), + "purple" => Color.ParseHex("ab47bc"), + "indigo" => Color.ParseHex("3f51b5"), + "blue" => Color.ParseHex("2196f3"), + "cyan" => Color.ParseHex("00bcd4"), + "teal" => Color.ParseHex("009688"), + "green" => Color.ParseHex("4caf50"), + "orange" => Color.ParseHex("ff9800"), + "brown" => Color.ParseHex("795548"), + "grey" or "gray" => Color.ParseHex("42464e"), + "blue-grey" => Color.ParseHex("546e7a"), + "black" => Color.ParseHex("1f2129"), + _ => Color.ParseHex("42464e"), + }; + + private static Color AccentColor(string? name) => (name ?? "orange").ToLowerInvariant() switch + { + "orange" => Color.ParseHex("ff9800"), + "red" => Color.ParseHex("ff5252"), + "pink" => Color.ParseHex("ff4081"), + "purple" => Color.ParseHex("e040fb"), + "blue" => Color.ParseHex("448aff"), + "cyan" => Color.ParseHex("18ffff"), + "teal" => Color.ParseHex("64ffda"), + "green" => Color.ParseHex("69f0ae"), + "yellow" => Color.ParseHex("ffd740"), + _ => Color.ParseHex("ff9800"), + }; +} diff --git a/src/Netdocs.Plugins/StubPlugins.cs b/src/Netdocs.Plugins/StubPlugins.cs index d4d8c56..bec2092 100644 --- a/src/Netdocs.Plugins/StubPlugins.cs +++ b/src/Netdocs.Plugins/StubPlugins.cs @@ -1,30 +1,61 @@ +using Markdig; using Netdocs.Abstractions; +using Netdocs.Core.Configuration; namespace Netdocs.Plugins; -/// Includes all pages (env/label filtering via .file-filter.yml is a future enhancement). +/// +/// Env-driven label include/exclude filter, mirroring mkdocs-file-filter. Reads +/// .file-filter.yml from the project root and prunes pages whose front-matter +/// labels (default property labels) match an exclude_tag and not an +/// include_tag. Honors enabled / enabled_on_serve. +/// public sealed class FileFilterPlugin : IPlugin, INavigationFilter { + private bool _active; + private string _metadataProperty = "labels"; + private readonly HashSet _excludeTags = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _includeTags = new(StringComparer.OrdinalIgnoreCase); + public string Name => "file-filter"; - public void Configure(IPluginContext ctx) { } - public bool ShouldInclude(Page page, SiteContext site) => true; -} -/// Sets created/updated dates. Currently uses filesystem timestamps; git integration is a TODO. -public sealed class GitRevisionDatePlugin : IPlugin, IBuildHook -{ - public string Name => "git-revision-date-localized"; - public void Configure(IPluginContext ctx) { } + public void Configure(IPluginContext ctx) + { + var path = Path.Combine(ctx.Config.ProjectRoot, ".file-filter.yml"); + if (!File.Exists(path)) { _active = false; return; } + + var root = YamlTree.Parse(File.ReadAllText(path)).AsMap(); + + var enabled = root.Get("enabled").AsBool(true); + var enabledOnServe = root.TryGetValue("enabled_on_serve", out var eos) ? eos.AsBool(true) : true; + _active = ctx.Options.IsServe ? enabled && enabledOnServe : enabled; + + if (root.Get("metadata_property").AsString() is { Length: > 0 } prop) _metadataProperty = prop; + foreach (var t in root.Get("exclude_tag").AsList()) + if (t.AsString() is { Length: > 0 } s) _excludeTags.Add(s); + foreach (var t in root.Get("include_tag").AsList()) + if (t.AsString() is { Length: > 0 } s) _includeTags.Add(s); + + if (_excludeTags.Count == 0) _active = false; + } + + public bool ShouldInclude(Page page, SiteContext site) + { + if (!_active) return true; - public Task OnBuildStartAsync(SiteContext site, CancellationToken ct) + var labels = ReadLabels(page); + if (labels.Count == 0) return true; + + // An explicit include label always wins; otherwise any exclude label prunes the page. + if (_includeTags.Count > 0 && labels.Any(_includeTags.Contains)) return true; + return !labels.Any(_excludeTags.Contains); + } + + private List ReadLabels(Page page) { - foreach (var page in site.Pages) - { - if (page.IsGenerated || !File.Exists(page.SourcePath)) continue; - page.Updated ??= File.GetLastWriteTimeUtc(page.SourcePath); - page.Created ??= File.GetCreationTimeUtc(page.SourcePath); - } - return Task.CompletedTask; + if (page.FrontMatter.TryGetValue(_metadataProperty, out var v) && v is IEnumerable list) + return list.Select(x => x?.ToString() ?? "").Where(s => s.Length > 0).ToList(); + return []; } } @@ -37,6 +68,18 @@ public void Configure(IPluginContext ctx) { ctx.AddStylesheet("https://cdn.jsdelivr.net/npm/glightbox/dist/css/glightbox.min.css"); ctx.AddScript("https://cdn.jsdelivr.net/npm/glightbox/dist/js/glightbox.min.js"); + ctx.AddInlineScript(""" + document.addEventListener("DOMContentLoaded", function () { + if (!window.GLightbox) return; + document.querySelectorAll(".md-content img").forEach(function (img) { + if (img.closest("a")) return; + var a = document.createElement("a"); + a.href = img.src; a.className = "glightbox"; + img.parentNode.insertBefore(a, img); a.appendChild(img); + }); + GLightbox({ selector: ".glightbox", touchNavigation: true, zoomable: true }); + }); + """); } } @@ -47,26 +90,16 @@ public sealed class NoopPlugin(string name) : IPlugin public void Configure(IPluginContext ctx) { } } -public sealed class TypesetPlugin : IPlugin +/// +/// Smart typography: enables Markdig's SmartyPants so straight quotes become curly, +/// --/--- become en/em dashes, and ... becomes an ellipsis. Code +/// spans and fenced blocks are left untouched. +/// +public sealed class TypesetPlugin : IPlugin, IMarkdigContributor { public string Name => "typeset"; public void Configure(IPluginContext ctx) { } -} - -public sealed class TableReaderPlugin : IPlugin -{ - public string Name => "table-reader"; - public void Configure(IPluginContext ctx) { } -} -public sealed class SocialPlugin : IPlugin -{ - public string Name => "social"; - public void Configure(IPluginContext ctx) { } -} - -public sealed class MacrosPlugin : IPlugin -{ - public string Name => "macros"; - public void Configure(IPluginContext ctx) { } + public void Extend(Markdig.MarkdownPipelineBuilder builder, SiteContext site) + => builder.UseSmartyPants(); } diff --git a/src/Netdocs.Plugins/TableReaderPlugin.cs b/src/Netdocs.Plugins/TableReaderPlugin.cs new file mode 100644 index 0000000..9c0a03a --- /dev/null +++ b/src/Netdocs.Plugins/TableReaderPlugin.cs @@ -0,0 +1,151 @@ +using System.Text; +using System.Text.RegularExpressions; +using Netdocs.Abstractions; + +namespace Netdocs.Plugins; + +/// +/// Implements mkdocs-table-reader: expands {{ read_csv("file.csv") }} (and +/// read_table) directives into a Markdown pipe table. Paths are resolved relative +/// to the page's own directory first (matching mkdocs-table-reader), then the docs dir, +/// then the project root. A CSV uses ,; a table file uses the delimiter given as a +/// second argument (default TAB). +/// +public sealed partial class TableReaderPlugin : IPlugin, IMarkdownPreprocessor +{ + private string _docsDir = ""; + private string _projectRoot = ""; + + public string Name => "table-reader"; + public int Order => 20; + + public void Configure(IPluginContext ctx) + { + _projectRoot = ctx.Config.ProjectRoot; + _docsDir = ctx.Config.AbsoluteDocsDir; + } + + public Task ProcessAsync(Page page, string markdown, SiteContext site, CancellationToken ct) + { + if (!markdown.Contains("read_csv", StringComparison.Ordinal) && + !markdown.Contains("read_table", StringComparison.Ordinal)) + return Task.FromResult(markdown); + + var result = DirectiveRegex().Replace(markdown, match => + { + var fn = match.Groups["fn"].Value; + var path = match.Groups["path"].Value; + var delimiter = match.Groups["delim"].Success && match.Groups["delim"].Value.Length > 0 + ? Unescape(match.Groups["delim"].Value) + : (fn == "read_csv" ? "," : "\t"); + + var resolved = Resolve(path, page); + if (resolved is null) + return $""; + + try + { + return RenderTable(File.ReadAllText(resolved), delimiter); + } + catch (Exception ex) + { + return $""; + } + }); + + return Task.FromResult(result); + } + + private string? Resolve(string path, Page page) + { + if (Path.IsPathRooted(path) && File.Exists(path)) return path; + + // mkdocs-table-reader resolves relative to the page's own directory first, so a post + // can reference a sibling CSV (e.g. "assets/foo.csv") without knowing the docs root. + var pageDir = string.IsNullOrEmpty(page.SourcePath) ? null : Path.GetDirectoryName(page.SourcePath); + + var roots = pageDir is null + ? new[] { _docsDir, _projectRoot } + : new[] { pageDir, _docsDir, _projectRoot }; + + foreach (var root in roots) + { + if (string.IsNullOrEmpty(root)) continue; + var candidate = Path.GetFullPath(Path.Combine(root, path)); + if (File.Exists(candidate)) return candidate; + } + return null; + } + + /// Renders delimited text as a Markdown pipe table (first row = header). + private static string RenderTable(string content, string delimiter) + { + var rows = ParseDelimited(content, delimiter); + if (rows.Count == 0) return ""; + + var columns = rows.Max(r => r.Count); + var sb = new StringBuilder(); + sb.Append('\n'); + + WriteRow(sb, rows[0], columns); + sb.Append("| ").Append(string.Join(" | ", Enumerable.Repeat("---", columns))).Append(" |\n"); + for (var i = 1; i < rows.Count; i++) + WriteRow(sb, rows[i], columns); + + sb.Append('\n'); + return sb.ToString(); + } + + private static void WriteRow(StringBuilder sb, List cells, int columns) + { + sb.Append("| "); + for (var c = 0; c < columns; c++) + { + var value = c < cells.Count ? cells[c].Replace("|", "\\|").Replace("\n", " ").Trim() : ""; + sb.Append(value).Append(" | "); + } + sb.Append('\n'); + } + + /// Parses delimited text, honoring double-quoted fields (RFC 4180-ish). + private static List> ParseDelimited(string content, string delimiter) + { + var rows = new List>(); + var row = new List(); + var field = new StringBuilder(); + var inQuotes = false; + var text = content.Replace("\r\n", "\n").Replace('\r', '\n'); + var d = delimiter.Length > 0 ? delimiter[0] : ','; + + for (var i = 0; i < text.Length; i++) + { + var ch = text[i]; + if (inQuotes) + { + if (ch == '"') + { + if (i + 1 < text.Length && text[i + 1] == '"') { field.Append('"'); i++; } + else inQuotes = false; + } + else field.Append(ch); + } + else if (ch == '"') inQuotes = true; + else if (ch == d) { row.Add(field.ToString()); field.Clear(); } + else if (ch == '\n') + { + row.Add(field.ToString()); field.Clear(); + if (row.Any(c => c.Length > 0) || row.Count > 1) rows.Add(row); + row = []; + } + else field.Append(ch); + } + row.Add(field.ToString()); + if (row.Any(c => c.Length > 0)) rows.Add(row); + return rows; + } + + private static string Unescape(string s) => s.Replace("\\t", "\t").Replace("\\n", "\n"); + + [GeneratedRegex("""\{\{\s*(?read_csv|read_table)\s*\(\s*["'](?[^"']+)["'](?:\s*,\s*["'](?[^"']*)["'])?\s*\)\s*\}\}""")] + private static partial Regex DirectiveRegex(); +} diff --git a/src/Netdocs.Plugins/TagsPlugin.cs b/src/Netdocs.Plugins/TagsPlugin.cs index 5c7cb85..3897f2e 100644 --- a/src/Netdocs.Plugins/TagsPlugin.cs +++ b/src/Netdocs.Plugins/TagsPlugin.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Netdocs.Abstractions; +using Netdocs.Core.Content; namespace Netdocs.Plugins; @@ -50,21 +51,93 @@ public Task OnBuildStartAsync(SiteContext site, CancellationToken ct) } site.State["tags"] = index; + RenderTagIndex(site, index); return Task.CompletedTask; } + /// Replaces the <!-- material/tags --> marker with a rendered, hierarchical tag index. + private static void RenderTagIndex(SiteContext site, SortedDictionary> index) + { + const string marker = ""; + + // Build a tree from '/'-separated tag paths so parents nest their children. + var root = new TagNode(); + foreach (var (tag, pages) in index) + { + var node = root; + var path = ""; + foreach (var segment in tag.Split('/', StringSplitOptions.RemoveEmptyEntries)) + { + path = path.Length == 0 ? segment : path + "/" + segment; + if (!node.Children.TryGetValue(segment, out var child)) + node.Children[segment] = child = new TagNode { FullPath = path }; + node = child; + } + node.Pages = pages; + } + + var markdown = new System.Text.StringBuilder(); + RenderNode(root, 0, markdown); + + foreach (var page in site.Pages) + { + if (page.RawMarkdown.Contains(marker, StringComparison.Ordinal)) + page.RawMarkdown = page.RawMarkdown.Replace(marker, markdown.ToString()); + } + } + + private static void RenderNode(TagNode node, int depth, System.Text.StringBuilder markdown) + { + foreach (var child in node.Children.Values) + { + var level = Math.Min(2 + depth, 6); + markdown.Append('\n').Append(new string('#', level)).Append(' ').AppendLine(child.FullPath).AppendLine(); + if (child.Pages is { Count: > 0 }) + { + foreach (var page in child.Pages.DistinctBy(p => p.Url).OrderBy(GetDisplayTitle, StringComparer.OrdinalIgnoreCase)) + markdown.Append("- [").Append(GetDisplayTitle(page)).Append("](/").Append(page.Url).AppendLine(")"); + markdown.AppendLine(); + } + RenderNode(child, depth + 1, markdown); + } + } + + private sealed class TagNode + { + public string FullPath = ""; + public SortedDictionary Children { get; } = new(StringComparer.OrdinalIgnoreCase); + public List? Pages { get; set; } + } + + /// Display name for a page on the tags page: an explicit tags_title front-matter + /// override wins, then the resolved title, then the first H1, then the filename. + private static string GetDisplayTitle(Page page) + { + if (page.FrontMatter.TryGetValue("tags_title", out var tt) && tt is string tts && tts.Length > 0) + return tts; + if (!string.IsNullOrEmpty(page.Title)) return page.Title; + if (page.FrontMatter.TryGetValue("title", out var t) && t is string ft && ft.Length > 0) return ft; + foreach (var line in page.RawMarkdown.Split('\n')) + { + var trimmed = line.TrimStart(); + if (trimmed.StartsWith("# ", StringComparison.Ordinal)) + return trimmed[2..].Trim(); + } + var name = System.IO.Path.GetFileNameWithoutExtension(page.RelativePath); + return name.Replace('-', ' ').Replace('_', ' '); + } + public async Task OnBuildCompleteAsync(SiteContext site, CancellationToken ct) { if (!_export || site.State["tags"] is not SortedDictionary> index) return; var export = index.ToDictionary( kvp => kvp.Key, - kvp => kvp.Value.Select(p => new { title = p.Title, url = p.Url }).ToArray()); + kvp => kvp.Value.Select(p => new { title = GetDisplayTitle(p), url = p.Url }).ToArray()); var path = Path.Combine(site.Config.AbsoluteSiteDir, _exportFile); - Directory.CreateDirectory(Path.GetDirectoryName(path)!); var json = JsonSerializer.Serialize(export, new JsonSerializerOptions { WriteIndented = true }); - await File.WriteAllTextAsync(path, json, ct); + await OutputWriter.WriteTextIfChangedAsync(site, path, json, ct); } private bool IsShadow(string tag) diff --git a/src/Netdocs.Theme.Material/assets/netdocs.css b/src/Netdocs.Theme.Material/assets/netdocs.css new file mode 100644 index 0000000..f64707e --- /dev/null +++ b/src/Netdocs.Theme.Material/assets/netdocs.css @@ -0,0 +1,411 @@ +/* Netdocs theme overrides layered on top of the vendored Material stylesheet. */ + +/* Sticky footer: make the body a full-height flex column so the main content + region grows to fill the viewport and the footer is pinned to the bottom on + short pages instead of floating mid-screen. */ +html { + min-height: 100%; +} + +body { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* The header/tabs/social nodes sit above the container; the container already + carries `flex-grow: 1`, so it expands to consume any remaining height. */ +.md-container { + flex-grow: 1; +} + +/* Social icon links render inline SVGs; keep them sized consistently in the + footer meta row. */ +.md-social__link svg, +.md-header__social svg { + width: 1rem; + height: 1rem; + fill: currentColor; +} + +/* ---------------------------------------------------------------------------- + Single-row header: top-level navigation is rendered inline in the header + (`.nd-header-nav`) on the left, with social links, palette toggle and search + pushed to the right. There is NO separate `.md-tabs` second row. + ---------------------------------------------------------------------------- */ + +/* Keep social links tight and vertically centred within the header row. */ +.md-header__social { + display: flex; + align-items: center; + gap: .1rem; +} + +/* Hidden by default (mobile/tablet): the hamburger drawer provides navigation + on narrow viewports, matching Material's behaviour. */ +.nd-header-nav { + display: none; +} + +@media screen and (min-width: 76.25em) { + /* On desktop the logo already conveys site identity, so hide the redundant + site-name / page-title block and let the inline nav occupy that space. */ + .md-header .md-header__title { + display: none; + } + + .nd-header-nav { + display: flex; + flex-grow: 1; + align-items: center; + gap: .2rem; + margin-left: .6rem; + height: 100%; + } + + .nd-header-nav__link { + display: flex; + align-items: center; + position: relative; + height: 100%; + padding: 0 .6rem; + color: var(--md-primary-bg-color); + font-size: .76rem; + font-weight: 700; + line-height: 1; + white-space: nowrap; + text-decoration: none; + opacity: .7; + transition: opacity .25s; + } + + .nd-header-nav__link:hover, + .nd-header-nav__link:focus { + opacity: 1; + } + + .nd-header-nav__link--active { + opacity: 1; + } + + /* Clean, centred underline bar just beneath the active item's label. The link fills + the full header height, so anchor the bar relative to the vertical centre (where the + text sits) rather than the link's bottom edge β€” otherwise it strikes through the text. */ + .nd-header-nav__link--active::after { + content: ""; + position: absolute; + left: .6rem; + right: .6rem; + top: calc(50% + .55rem); + height: .12rem; + border-radius: .1rem; + background: var(--md-primary-bg-color); + } + + /* Safety net: if a `.md-tabs` bar is ever emitted, keep it hidden β€” the + header row is the single source of top-level navigation. */ + .md-tabs { + display: none; + } + + /* The header search box (`.md-search__inner`) is floated with a fixed width, + which leaves its flex parent (`.md-search`) with no intrinsic width and + collapses the box. Give the flex item an explicit width so the search box + stays visible on the right of the single-row header. */ + .md-header__inner > .md-search { + flex: 0 0 auto; + width: 11.7rem; + margin-left: .4rem; + } +} + +/* Breadcrumbs (`.md-path`) are emitted inside the `.md-typeset` article, where the + generic `.md-typeset ul { list-style: disc }` rule would otherwise paint a bullet + in front of the first crumb. Reassert the breadcrumb's own list reset with higher + specificity so it always renders as a clean inline trail (no stray bullet). */ +.md-typeset .md-path__list { + list-style: none; + margin: 0; + padding: 0; +} + +.md-typeset .md-path__item { + display: inline-flex; +} + +.md-typeset .md-path__item::before { + content: none; +} + +/* Material indents the breadcrumb trail with a 1.2rem side margin, which pushes the + section label to the right of the H1/body copy it sits above. Zero the side margins + so the breadcrumb aligns flush with the article's left edge. */ +.md-path { + margin-left: 0; + margin-right: 0; +} + +/* The primary (nav) and secondary (TOC) sidebars scroll their own content, but the + permanently visible scrollbar track looks broken on short pages. Hide the scrollbar + chrome while keeping the wheel/trackpad scroll behaviour intact. */ +.md-sidebar__scrollwrap { + scrollbar-width: none; +} + +.md-sidebar__scrollwrap::-webkit-scrollbar { + width: 0; + height: 0; +} + +/* Give fenced code blocks a background that reads as a distinct panel against the page + (Material's default is only a few percent lighter, so blocks blend into the page on + the dark scheme). A hairline border plus a soft drop shadow lift the block off the + page so it "pops" as an elevated panel in both schemes. */ +:root { + /* Elevation shadow for code panels β€” tuned per scheme below. */ + --nd-code-shadow: 0 0.1rem 0.4rem rgba(0, 0, 0, 0.12), 0 0.05rem 0.1rem rgba(0, 0, 0, 0.08); +} +[data-md-color-scheme="slate"] { + --md-code-bg-color: hsla(var(--md-hue), 15%, 22%, 1); + /* Darker, larger shadow so the panel clearly lifts off the dark page. */ + --nd-code-shadow: 0 0.25rem 0.85rem rgba(0, 0, 0, 0.55), 0 0.1rem 0.25rem rgba(0, 0, 0, 0.4); +} + +.md-typeset .highlight, +.md-typeset .highlighttable { + position: relative; + /* Space stacked code blocks apart. The gap normally comes from the inner +
's margin, but overflow:hidden (needed for the rounded corners) traps
+     that margin INSIDE the panel, so put the spacing on the container itself. */
+  margin: 1em 0;
+  border: 0.05rem solid var(--md-default-fg-color--lightest);
+  border-radius: 0.2rem;
+  overflow: hidden;
+  box-shadow: var(--nd-code-shadow);
+}
+
+/* Drop the inner 
's own margin so it isn't trapped as internal whitespace,
+   and de-position it (Material sets pre{position:relative}) so the injected
+   copy/select nav anchors to the .highlight panel's top-right corner β€” over the
+   title bar when one is present β€” instead of the code area below it. */
+.md-typeset .highlight > pre,
+.md-typeset .highlighttable > pre {
+  margin: 0;
+  position: static;
+}
+
+/* Give code content a little more horizontal breathing room so text is never
+   flush against the panel border. */
+.md-typeset .highlight > pre > code {
+  padding: 0.75em 1.2em;
+}
+
+/* Bare 
 blocks (not wrapped in a .highlight container) still need a
+   panel border, but a wrapped block must NOT get a second, inset border on the
+   inner  β€” that produced the "double border" seen on the reference page. */
+.md-typeset pre > code {
+  border: 0.05rem solid var(--md-default-fg-color--lightest);
+  border-radius: 0.2rem;
+  box-shadow: var(--nd-code-shadow);
+}
+
+/* Inner children of a wrapped block share the container's border/shadow, so
+   reset theirs to avoid a doubled outline or a shadow bleeding inside the panel. */
+.md-typeset .highlight pre > code,
+.md-typeset .highlighttable pre > code,
+.md-typeset .highlighttable .highlight {
+  border: none;
+  border-radius: 0;
+  box-shadow: none;
+}
+
+/* Keep the copy/select nav pinned to the very top-right of the panel. It is
+   injected inside 
, which we set to position:static above, so it anchors to
+   the .highlight panel. */
+.md-typeset .highlight .md-code__nav {
+  top: 0.4em;
+  right: 0.4em;
+}
+
+/* Filename/label title bar (fence `title="…"`). This is emitted server-side by
+   the core fence parser, so its styling lives here (not in the swappable
+   highlighter partial) to survive a renderer swap. */
+.md-typeset .highlight > .filename {
+  display: block;
+  font-size: 0.85em;
+  font-weight: 700;
+  padding: 0.5em 1em;
+  background: color-mix(in srgb, var(--md-code-bg-color) 100%, var(--md-default-fg-color) 6%);
+  border-bottom: 0.05rem solid var(--md-default-fg-color--lightest);
+}
+
+/* Blog post sidebar (`.nd-post-sidebar`) rendered in the left column on post pages: a
+   back-link, the author profile, a "Metadata" heading, and a stacked date / category /
+   reading-time list. Self-contained `.nd-post-*` markup (not Material's drawer classes)
+   so it lays out predictably inside `.md-sidebar--primary` at every width. */
+.nd-post-sidebar {
+  font-size: .7rem;
+}
+
+.nd-post-back {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.3rem;
+  margin-bottom: 1rem;
+  font-size: 0.7rem;
+  font-weight: 700;
+  color: var(--md-default-fg-color--light);
+}
+
+.nd-post-back svg {
+  width: 0.9rem;
+  height: 0.9rem;
+  fill: currentColor;
+}
+
+.nd-post-author {
+  display: flex;
+  align-items: center;
+  gap: 0.6rem;
+  margin: 0.4rem 0 1rem;
+}
+
+.nd-post-avatar {
+  width: 1.8rem;
+  height: 1.8rem;
+  border-radius: 50%;
+  object-fit: cover;
+}
+
+.nd-post-author-name strong {
+  display: block;
+  line-height: 1.2;
+}
+
+.nd-post-author-role {
+  color: var(--md-default-fg-color--light);
+  font-size: 0.7rem;
+}
+
+.nd-post-meta-title {
+  margin: 0 0 0.5rem;
+  color: var(--md-default-fg-color--light);
+  font-size: 0.65rem;
+  font-weight: 700;
+  letter-spacing: 0.05em;
+  text-transform: uppercase;
+}
+
+.nd-post-meta {
+  display: flex;
+  flex-direction: column;
+  gap: 0.5rem;
+  font-size: 0.7rem;
+  color: var(--md-default-fg-color--light);
+}
+
+.nd-post-meta-item {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.4rem;
+}
+
+.nd-post-meta-item svg {
+  width: 0.9rem;
+  height: 0.9rem;
+  flex-shrink: 0;
+  fill: currentColor;
+}
+
+
+/* ---------------------------------------------------------------------------
+   Calculator plugin (```calc fences -> interactive forms). Styles the input
+   grid and the computed-output cards. Uses Material CSS custom properties so
+   it follows the active palette (light/dark) automatically.
+   --------------------------------------------------------------------------- */
+.nd-calc {
+  display: block;
+  margin: 1.5em 0;
+  padding: 1em 1.2em;
+  border: 1px solid var(--md-default-fg-color--lightest);
+  border-radius: .4rem;
+  background: var(--md-code-bg-color);
+  box-shadow: 0 1px 3px rgba(0, 0, 0, .12), 0 1px 2px rgba(0, 0, 0, .08);
+}
+
+.nd-calc--error {
+  border-color: #d32f2f;
+  color: #d32f2f;
+  font-weight: 500;
+}
+
+.nd-calc__title {
+  font-weight: 700;
+  font-size: .9rem;
+  margin-bottom: .8em;
+}
+
+.nd-calc__inputs {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: .8em 1.2em;
+}
+
+.nd-calc__field {
+  display: flex;
+  flex-direction: column;
+  gap: .25em;
+}
+
+.nd-calc__field label {
+  font-size: .72rem;
+  font-weight: 600;
+  color: var(--md-default-fg-color--light);
+}
+
+.nd-calc__field input,
+.nd-calc__field select {
+  font: inherit;
+  font-size: .8rem;
+  padding: .35em .5em;
+  border: 1px solid var(--md-default-fg-color--lighter);
+  border-radius: .25rem;
+  background: var(--md-default-bg-color);
+  color: var(--md-default-fg-color);
+}
+
+.nd-calc__field input[type="range"] {
+  padding: 0;
+}
+
+.nd-calc__range {
+  font-size: .72rem;
+  color: var(--md-default-fg-color--light);
+}
+
+.nd-calc__outputs {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: .8em 1.2em;
+  margin-top: 1em;
+  padding-top: 1em;
+  border-top: 1px solid var(--md-default-fg-color--lightest);
+}
+
+.nd-calc__result {
+  display: flex;
+  flex-direction: column;
+  gap: .1em;
+}
+
+.nd-calc__label {
+  font-size: .72rem;
+  font-weight: 600;
+  color: var(--md-default-fg-color--light);
+}
+
+.nd-calc__value {
+  font-size: 1.1rem;
+  font-weight: 700;
+  color: var(--md-primary-fg-color);
+}
diff --git a/src/Netdocs.Theme.Material/templates/main.html b/src/Netdocs.Theme.Material/templates/main.html
index 6240ee8..ed93dd5 100644
--- a/src/Netdocs.Theme.Material/templates/main.html
+++ b/src/Netdocs.Theme.Material/templates/main.html
@@ -3,6 +3,7 @@
 {{~
 func nav_active(node)
   if node.page && node.page.url == page.url; ret true; end
+  if node.section_index && node.section_index.url == page.url; ret true; end
   for c in node.children
     if nav_active(c); ret true; end
   end
@@ -10,6 +11,7 @@
 end
 func nav_first_url(node)
   if node.page; ret node.url; end
+  if node.section_index; ret node.section_index.url; end
   for c in node.children
     u = nav_first_url(c)
     if u != ""; ret u; end
@@ -21,10 +23,21 @@
   if node.is_section ~}}
     
  • + {{~ if node.section_index ~}} + + {{~ else ~}} + {{~ end ~}}
  • {{~ else ~}} @@ -42,6 +56,7 @@ {{ node.title }} + {{~ if toc_integrate && node.url == page.url ~}}{{ render_integrated_toc }}{{~ end ~}} {{~ end end @@ -56,23 +71,25 @@ {{~ end ~}} -{{~ end ~}} +{{~ end +func render_integrated_toc() + toc_root = toc + if toc && (array.size toc) > 0 && toc[0].level == 1 + toc_root = toc[0].children + end + if toc_root && (array.size toc_root) > 0 ~}} + + {{~ end +end +toc_integrate = features | array.contains "toc.integrate" ~}} - - - - {{ if config.site_author }}{{ end }} - {{ if !is_homepage && page.title != "" }}{{ page.title }} - {{ end }}{{ config.site_name }} - - - - - - {{~ for href in stylesheets ~}} - - {{~ end ~}} + {{ include "partials/head.html" }} @@ -84,18 +101,24 @@ {{ include "partials/header.html" }}
    - {{~ if (features | array.contains "navigation.tabs") && !(features | array.contains "navigation.tabs.sticky") ~}} - {{ include "partials/tabs.html" }} - {{~ end ~}} + {{~ # Top-level navigation is rendered inline inside the header (single row). + # The separate `.md-tabs` bar is intentionally not included here. ~}}
    + {{~ if page.meta.is_post ~}} + {{ include "partials/post-meta.html" }} + {{~ else if page.meta.is_blog_listing ~}} + {{ include "partials/blog-nav.html" }} + {{~ else ~}} {{ include "partials/nav.html" }} + {{~ end ~}}
    + {{~ if !toc_integrate ~}}
    @@ -103,9 +126,35 @@
    + {{~ end ~}}
    - {{ content }} +{{~ if (features | array.contains "navigation.path") && breadcrumbs && (array.size breadcrumbs) > 0 ~}} + +{{~ end ~}} +{{~ if page.meta.is_post && page.meta.post_tags && (array.size page.meta.post_tags) > 0 ~}} + +{{~ end ~}} +{{ content }} + {{~ if show_source_meta && !page.meta.is_post ~}} +
    +
    + + + Last updated: {{ updated_display }} + {{~ if created_display ~}} · Created: {{ created_display }}{{~ end ~}} + +
    + {{~ end ~}}
    @@ -121,28 +170,6 @@
    - - - {{~ for src in scripts ~}} - - {{~ end ~}} - {{ include "partials/mermaid.html" }} + {{ include "partials/scripts.html" }} diff --git a/src/Netdocs.Theme.Material/templates/partials/blog-nav.html b/src/Netdocs.Theme.Material/templates/partials/blog-nav.html new file mode 100644 index 0000000..4ded438 --- /dev/null +++ b/src/Netdocs.Theme.Material/templates/partials/blog-nav.html @@ -0,0 +1,47 @@ + diff --git a/src/Netdocs.Theme.Material/templates/partials/footer.html b/src/Netdocs.Theme.Material/templates/partials/footer.html index 61517dc..f60b7a0 100644 --- a/src/Netdocs.Theme.Material/templates/partials/footer.html +++ b/src/Netdocs.Theme.Material/templates/partials/footer.html @@ -1,14 +1,40 @@
    + {{~ if (prev_page || next_page) && (features | array.contains "navigation.footer") ~}} + + {{~ end ~}}
    + {{~ end ~}} + {{~ # Social links (right) β€” see partials/social.html. ~}} + {{ include "partials/social.html" }} + {{~ # Palette (light/dark) toggle (right). ~}} + {{~ if palettes && (palettes | array.size) > 0 ~}} +
    + {{~ for opt in palettes ~}} + + {{~ if opt.has_toggle ~}} + + {{~ end ~}} + {{~ end ~}} +
    + {{~ end ~}} + {{~ # Search (right). ~}} {{ include "partials/search.html" }} - {{~ if features | array.contains "navigation.tabs.sticky" ~}} - {{~ if features | array.contains "navigation.tabs" ~}} - {{ include "partials/tabs.html" }} - {{~ end ~}} - {{~ end ~}} diff --git a/src/Netdocs.Theme.Material/templates/partials/highlight.html b/src/Netdocs.Theme.Material/templates/partials/highlight.html new file mode 100644 index 0000000..672225d --- /dev/null +++ b/src/Netdocs.Theme.Material/templates/partials/highlight.html @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + diff --git a/src/Netdocs.Theme.Material/templates/partials/mermaid.html b/src/Netdocs.Theme.Material/templates/partials/mermaid.html index ed6c52f..4c9958b 100644 --- a/src/Netdocs.Theme.Material/templates/partials/mermaid.html +++ b/src/Netdocs.Theme.Material/templates/partials/mermaid.html @@ -1,8 +1,29 @@ {{ if features | array.contains "content.code.annotate" }}{{ end }} diff --git a/src/Netdocs.Theme.Material/templates/partials/post-meta.html b/src/Netdocs.Theme.Material/templates/partials/post-meta.html new file mode 100644 index 0000000..7e94067 --- /dev/null +++ b/src/Netdocs.Theme.Material/templates/partials/post-meta.html @@ -0,0 +1,43 @@ +{{~ # Blog post sidebar (left column on post pages): a back-link, the author profile, + # and a "Metadata" block with the publish date, category, and reading time. Rendered in + # `.md-sidebar--primary` in place of the archive/categories nav (which belongs only on + # listing pages). Uses dedicated `.nd-post-*` markup styled by netdocs.css so the layout + # is stable at every viewport width instead of reusing Material's drawer classes. ~}} + diff --git a/src/Netdocs.Theme.Material/templates/partials/scripts.html b/src/Netdocs.Theme.Material/templates/partials/scripts.html new file mode 100644 index 0000000..af12fd2 --- /dev/null +++ b/src/Netdocs.Theme.Material/templates/partials/scripts.html @@ -0,0 +1,39 @@ +{{~ # --------------------------------------------------------------------------- + # partials/scripts.html β€” End-of-body scripts: the Material `__config` blob, the + # vendored Material bundle, hashed site scripts, page inline scripts, and the + # optional highlight.js / mermaid loaders. + # + # Available globals: base_url, features, scripts, inline_scripts, highlight, and the + # `asset` helper (adds the content hash for cache-busting). + # + # Override: drop a file at `/partials/scripts.html` to add or reorder + # script tags. Keep the `__config` blob and Material bundle if you rely on search, + # palette switching, instant navigation, or clipboard copy. + # --------------------------------------------------------------------------- ~}} + + + {{~ for src in scripts ~}} + + {{~ end ~}} + {{~ for js in inline_scripts ~}} + + {{~ end ~}} + {{~ if (highlight | string.downcase) == "highlightjs" ~}}{{ include "partials/highlight.html" }}{{~ end ~}} + {{ include "partials/mermaid.html" }} diff --git a/src/Netdocs.Theme.Material/templates/partials/social.html b/src/Netdocs.Theme.Material/templates/partials/social.html new file mode 100644 index 0000000..ffc908a --- /dev/null +++ b/src/Netdocs.Theme.Material/templates/partials/social.html @@ -0,0 +1,17 @@ +{{~ # --------------------------------------------------------------------------- + # partials/social.html β€” Social links shown at the right of the header. + # + # Data: `extra.social` in your config β€” a list of { icon, link } entries. + # `social_icon ` resolves an icon name (e.g. "fontawesome/brands/github") + # to an inline SVG. + # + # Override: drop a file at `/partials/social.html` to change how the + # social links render, without touching the header. Return nothing to hide them. + # --------------------------------------------------------------------------- ~}} +{{~ if extra.social ~}} + +{{~ end ~}} diff --git a/tests/Netdocs.Core.Tests/AbbreviationsPluginTests.cs b/tests/Netdocs.Core.Tests/AbbreviationsPluginTests.cs new file mode 100644 index 0000000..0b01a40 --- /dev/null +++ b/tests/Netdocs.Core.Tests/AbbreviationsPluginTests.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the abbreviations preprocessor that appends *[ABBR]: definitions to pages. +public class AbbreviationsPluginTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "netdocs-abbr-" + Guid.NewGuid().ToString("N")); + private readonly string _docs; + + public AbbreviationsPluginTests() + { + _docs = Path.Combine(_root, "docs"); + Directory.CreateDirectory(Path.Combine(_docs, "_include")); + } + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private static SiteContext Site() => new() + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + + private static readonly Page Page = new() { SourcePath = "x", RelativePath = "index.md", Url = "" }; + + private AbbreviationsPlugin Configured(params (string key, object? value)[] options) + { + var plugin = new AbbreviationsPlugin(); + var dict = new Dictionary(); + foreach (var (k, v) in options) dict[k] = v; + plugin.Configure(new FakeContext(dict) { Config = new SiteConfig { ProjectRoot = _root, DocsDir = "docs" } }); + return plugin; + } + + [Fact] + public async Task AppendsDefaultAbbreviationFile() + { + File.WriteAllText(Path.Combine(_docs, "_include", "abbv.md"), "*[HTML]: HyperText Markup Language"); + var plugin = Configured(); + + var result = await plugin.ProcessAsync(Page, "Uses HTML.", Site(), default); + + Assert.Contains("Uses HTML.", result); + Assert.Contains("*[HTML]: HyperText Markup Language", result); + } + + [Fact] + public async Task LeavesMarkdownUntouchedWhenNoFile() + { + var plugin = Configured(); + const string md = "No abbreviations here."; + + var result = await plugin.ProcessAsync(Page, md, Site(), default); + + Assert.Equal(md, result); + } + + [Fact] + public void RendersAbbrInProseButNotInsideCode() + { + var site = new SiteContext { Config = new SiteConfig(), Options = new BuildOptions(), LoggerFactory = NullLoggerFactory.Instance }; + var pipeline = Netdocs.Core.Markdown.MarkdownPipelineFactory.Build(site, []); + var md = "Uses HTML here.\n\nInline `HTML` code.\n\n```\nHTML block\n```\n\n*[HTML]: HyperText Markup Language\n"; + var page = new Page { SourcePath = "x", RelativePath = "x.md", ProcessedMarkdown = md }; + new Netdocs.Core.Markdown.DocumentRenderer(pipeline).Render(page); + + // Prose abbreviations become tooltips... + Assert.Contains("HTML", page.HtmlContent); + // ...but abbreviations are never expanded inside inline code or fenced code blocks. + Assert.Contains("HTML", page.HtmlContent); + Assert.Contains("HTML block", page.HtmlContent); + Assert.DoesNotContain("HTML block", page.HtmlContent); + Assert.DoesNotContain(" options) : IPluginContext + { + public SiteConfig Config { get; init; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options; + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } +} diff --git a/tests/Netdocs.Core.Tests/ArithmatexPluginTests.cs b/tests/Netdocs.Core.Tests/ArithmatexPluginTests.cs new file mode 100644 index 0000000..cb92707 --- /dev/null +++ b/tests/Netdocs.Core.Tests/ArithmatexPluginTests.cs @@ -0,0 +1,88 @@ +using Markdig; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the arithmatex math plugin: Markdig parsing and MathJax injection. +public class ArithmatexPluginTests +{ + private static SiteContext Site() => new() + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + + private static string Render(string markdown) + { + var builder = new MarkdownPipelineBuilder(); + new ArithmatexPlugin().Extend(builder, Site()); + return Markdig.Markdown.ToHtml(markdown, builder.Build()); + } + + [Fact] + public void InlineMath_BecomesMathSpanWithDelimiters() + { + var html = Render("Euler $e^{i\\pi}+1=0$ done."); + Assert.Contains("\\(e^{i\\pi}+1=0\\)", html); + } + + [Fact] + public void BlockMath_BecomesMathDivWithDelimiters() + { + var html = Render("$$\n\\int_0^1 x\\,dx\n$$"); + Assert.Contains("
    ", html); + Assert.Contains("\\[", html); + Assert.Contains("\\]", html); + } + + [Fact] + public void DollarInsideCode_IsNotTreatedAsMath() + { + var html = Render("`$not math$`"); + Assert.Contains("$not math$", html); + Assert.DoesNotContain("class=\"math\"", html); + } + + [Fact] + public void Configure_InjectsMathJaxScript() + { + var assets = new CapturingContext(); + new ArithmatexPlugin().Configure(assets); + + var script = Assert.Single(assets.InlineScripts); + Assert.Contains("window.MathJax", script); + Assert.Contains("mathjax@3", script); + Assert.Contains("document$", script); + } + + [Fact] + public void Configure_HonorsCustomMathJaxUrl() + { + var assets = new CapturingContext(new Dictionary + { + ["mathjax_url"] = "/assets/vendor/mathjax/tex-mml-chtml.js", + }); + new ArithmatexPlugin().Configure(assets); + + Assert.Contains("/assets/vendor/mathjax/tex-mml-chtml.js", Assert.Single(assets.InlineScripts)); + } + + private sealed class CapturingContext(IReadOnlyDictionary? options = null) : IPluginContext + { + public List InlineScripts { get; } = []; + public SiteConfig Config { get; } = new(); + public BuildOptions Options { get; } = new(); + public Microsoft.Extensions.Logging.ILogger Logger { get; } = NullLogger.Instance; + public Microsoft.Extensions.DependencyInjection.IServiceCollection Services { get; } = + new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options ?? new Dictionary(); + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) => InlineScripts.Add(javascript); + public void AddAsset(string sourcePath, string destRelative) { } + } +} diff --git a/tests/Netdocs.Core.Tests/AssetVersionerTests.cs b/tests/Netdocs.Core.Tests/AssetVersionerTests.cs new file mode 100644 index 0000000..636277c --- /dev/null +++ b/tests/Netdocs.Core.Tests/AssetVersionerTests.cs @@ -0,0 +1,77 @@ +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers content-hash cache-busting for local, unhashed asset references. +public class AssetVersionerTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "netdocs-assetver-" + Guid.NewGuid().ToString("N")); + private readonly string _themeAssets; + private readonly string _docs; + + public AssetVersionerTests() + { + _themeAssets = Path.Combine(_root, "theme", "assets"); + _docs = Path.Combine(_root, "docs"); + Directory.CreateDirectory(_themeAssets); + Directory.CreateDirectory(_docs); + File.WriteAllText(Path.Combine(_themeAssets, "netdocs.css"), "body{color:red}"); + File.WriteAllText(Path.Combine(_docs, "custom.js"), "console.log(1)"); + } + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + } + + [Fact] + public void Versions_theme_asset_with_content_hash() + { + var v = new AssetVersioner(_themeAssets, _docs); + var result = v.Version("assets/netdocs.css"); + Assert.Matches(@"^assets/netdocs\.css\?v=[0-9a-f]{8}$", result); + } + + [Fact] + public void Versions_user_asset_under_docs() + { + var v = new AssetVersioner(_themeAssets, _docs); + var result = v.Version("custom.js"); + Assert.Matches(@"^custom\.js\?v=[0-9a-f]{8}$", result); + } + + [Fact] + public void Hash_changes_when_content_changes() + { + var v1 = new AssetVersioner(_themeAssets, _docs).Version("assets/netdocs.css"); + File.WriteAllText(Path.Combine(_themeAssets, "netdocs.css"), "body{color:blue}"); + var v2 = new AssetVersioner(_themeAssets, _docs).Version("assets/netdocs.css"); + Assert.NotEqual(v1, v2); + } + + [Theory] + [InlineData("https://cdn.example.com/a.css")] + [InlineData("http://cdn.example.com/a.css")] + [InlineData("//cdn.example.com/a.css")] + [InlineData("data:text/css,body{}")] + [InlineData("assets/netdocs.css?already=1")] + [InlineData("assets/netdocs.css#frag")] + public void Leaves_external_or_qualified_hrefs_unchanged(string href) + { + var v = new AssetVersioner(_themeAssets, _docs); + Assert.Equal(href, v.Version(href)); + } + + [Fact] + public void Leaves_unknown_local_href_unchanged() + { + var v = new AssetVersioner(_themeAssets, _docs); + Assert.Equal("assets/missing.css", v.Version("assets/missing.css")); + } + + [Fact] + public void NoOp_never_rewrites() + { + Assert.Equal("assets/netdocs.css", AssetVersioner.NoOp.Version("assets/netdocs.css")); + } +} diff --git a/tests/Netdocs.Core.Tests/Base64EmbedPluginTests.cs b/tests/Netdocs.Core.Tests/Base64EmbedPluginTests.cs new file mode 100644 index 0000000..a9db366 --- /dev/null +++ b/tests/Netdocs.Core.Tests/Base64EmbedPluginTests.cs @@ -0,0 +1,135 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the pymdownx.b64 image-embedding preprocessor. +public class Base64EmbedPluginTests : IDisposable +{ + // 1x1 transparent PNG. + private static readonly byte[] PngBytes = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + private readonly string _dir = Path.Combine(Path.GetTempPath(), "netdocs-b64-" + Guid.NewGuid().ToString("N")); + + public Base64EmbedPluginTests() + { + Directory.CreateDirectory(_dir); + File.WriteAllBytes(Path.Combine(_dir, "pic.png"), PngBytes); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private static SiteContext Site() => new() + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + + private Page PageWith(Dictionary? frontMatter = null) => new() + { + SourcePath = Path.Combine(_dir, "index.md"), + RelativePath = "index.md", + Url = "", + FrontMatter = frontMatter ?? new Dictionary(), + }; + + [Fact] + public async Task EmbedsLocalImage_WhenFrontMatterEnabled() + { + var page = PageWith(new() { ["embed_images"] = true }); + var result = await new Base64EmbedPlugin() + .ProcessAsync(page, "![alt](pic.png)", Site(), default); + + Assert.Contains("![alt](data:image/png;base64,", result); + Assert.DoesNotContain("(pic.png)", result); + } + + [Fact] + public async Task LeavesMarkdownUntouched_WhenDisabledByDefault() + { + var page = PageWith(); + const string md = "![alt](pic.png)"; + var result = await new Base64EmbedPlugin().ProcessAsync(page, md, Site(), default); + + Assert.Equal(md, result); + } + + [Fact] + public async Task DoesNotEmbedRemoteImages() + { + var page = PageWith(new() { ["embed_images"] = true }); + const string md = "![x](https://example.com/a.png)"; + var result = await new Base64EmbedPlugin().ProcessAsync(page, md, Site(), default); + + Assert.Equal(md, result); + } + + [Fact] + public async Task EmbedsInlineHtmlImage() + { + var page = PageWith(new() { ["embed_images"] = true }); + var result = await new Base64EmbedPlugin() + .ProcessAsync(page, "\"x\"", Site(), default); + + Assert.Contains("src=\"data:image/png;base64,", result); + } + + [Fact] + public async Task PreservesTitleOnMarkdownImage() + { + var page = PageWith(new() { ["embed_images"] = true }); + var result = await new Base64EmbedPlugin() + .ProcessAsync(page, "![alt](pic.png \"A title\")", Site(), default); + + Assert.Contains("data:image/png;base64,", result); + Assert.Contains("\"A title\")", result); + } + + [Fact] + public async Task SiteDefaultOption_EnablesEmbedding() + { + var plugin = new Base64EmbedPlugin(); + plugin.Configure(new FakeContext(new Dictionary { ["default"] = true })); + + var page = PageWith(); + var result = await plugin.ProcessAsync(page, "![alt](pic.png)", Site(), default); + + Assert.Contains("data:image/png;base64,", result); + } + + [Fact] + public async Task FrontMatterFalse_OverridesSiteDefault() + { + var plugin = new Base64EmbedPlugin(); + plugin.Configure(new FakeContext(new Dictionary { ["default"] = true })); + + var page = PageWith(new() { ["embed_images"] = false }); + const string md = "![alt](pic.png)"; + var result = await plugin.ProcessAsync(page, md, Site(), default); + + Assert.Equal(md, result); + } + + private sealed class FakeContext(IReadOnlyDictionary options) : IPluginContext + { + public SiteConfig Config { get; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options; + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } +} diff --git a/tests/Netdocs.Core.Tests/BlogPluginTests.cs b/tests/Netdocs.Core.Tests/BlogPluginTests.cs new file mode 100644 index 0000000..2005644 --- /dev/null +++ b/tests/Netdocs.Core.Tests/BlogPluginTests.cs @@ -0,0 +1,130 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the blog plugin's excerpt handling β€” specifically that page-relative +/// .md links in a post excerpt resolve to the target's published URL when embedded in +/// the generated listing page. +public class BlogPluginTests +{ + private sealed class FakeContext(IReadOnlyDictionary options) : IPluginContext + { + public SiteConfig Config { get; init; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options; + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } + + private static Page Post(string relativePath, string date, string markdown, string? category = null) + { + var fm = new Dictionary { ["date"] = date }; + if (category is not null) fm["categories"] = new List { category }; + return new Page + { + SourcePath = relativePath, + RelativePath = relativePath, + RawMarkdown = markdown, + Title = Path.GetFileNameWithoutExtension(relativePath), + FrontMatter = fm, + }; + } + + [Fact] + public async Task ExcerptMdLink_ResolvesToTargetUrl() + { + var plugin = new BlogPlugin(); + plugin.Configure(new FakeContext(new Dictionary())); + + var index = new Page { SourcePath = "", RelativePath = "blog/index.md", Url = "blog/", Title = "Blog" }; + var foo = Post("blog/posts/2026/foo.md", "2026-05-01", + "# Foo\n\nSee [the bar post](bar.md) for more.\n\n\n\nBody."); + var bar = Post("blog/posts/2026/bar.md", "2026-04-01", "# Bar\n\nBar intro.\n\n\n\nBody."); + + var site = new SiteContext + { + Config = new SiteConfig { ProjectRoot = Path.GetTempPath() }, + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + site.Pages.Add(index); + site.Pages.Add(foo); + site.Pages.Add(bar); + + await plugin.OnBuildStartAsync(site, CancellationToken.None); + + // The generated index embeds foo's excerpt; its bar.md link must point at bar's URL. + Assert.Contains("](/blog/2026/bar/)", index.RawMarkdown); + Assert.DoesNotContain("](bar.md)", index.RawMarkdown); + } + + [Fact] + public async Task ExcerptExternalMdLink_IsLeftUntouched() + { + var plugin = new BlogPlugin(); + plugin.Configure(new FakeContext(new Dictionary())); + + var index = new Page { SourcePath = "", RelativePath = "blog/index.md", Url = "blog/", Title = "Blog" }; + var foo = Post("blog/posts/2026/foo.md", "2026-05-01", + "# Foo\n\nSee [readme](https://github.com/x/y/blob/main/README.md).\n\n\n\nBody."); + + var site = new SiteContext + { + Config = new SiteConfig { ProjectRoot = Path.GetTempPath() }, + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + site.Pages.Add(index); + site.Pages.Add(foo); + + await plugin.OnBuildStartAsync(site, CancellationToken.None); + + Assert.Contains("https://github.com/x/y/blob/main/README.md", index.RawMarkdown); + } + + [Fact] + public async Task Posts_CarryPopulatedArchiveAndCategoryNav() + { + // Regression: the shared Archive/Categories nav must be attached to posts AFTER it is + // computed. A prior bug assigned it inside the discovery loop, capturing empty initial + // lists, so posts rendered an empty blog nav while the index (assigned later) did not. + var plugin = new BlogPlugin(); + plugin.Configure(new FakeContext(new Dictionary())); + + var index = new Page { SourcePath = "", RelativePath = "blog/index.md", Url = "blog/", Title = "Blog" }; + var foo = Post("blog/posts/2026/foo.md", "2026-05-01", + "# Foo\n\nIntro.\n\n\n\nBody.", "Homelab"); + var bar = Post("blog/posts/2025/bar.md", "2025-04-01", + "# Bar\n\nBar intro.\n\n\n\nBody.", "Networking"); + + var site = new SiteContext + { + Config = new SiteConfig { ProjectRoot = Path.GetTempPath() }, + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + site.Pages.Add(index); + site.Pages.Add(foo); + site.Pages.Add(bar); + + await plugin.OnBuildStartAsync(site, CancellationToken.None); + + foreach (var post in new[] { foo, bar }) + { + Assert.True(post.Meta.ContainsKey("is_post")); + var archives = Assert.IsAssignableFrom(post.Meta["blog_archives"]); + Assert.NotEmpty(archives.Cast()); + var categories = Assert.IsAssignableFrom(post.Meta["blog_categories"]); + Assert.NotEmpty(categories.Cast()); + } + } +} diff --git a/tests/Netdocs.Core.Tests/CalculatorPluginTests.cs b/tests/Netdocs.Core.Tests/CalculatorPluginTests.cs new file mode 100644 index 0000000..e9c13a3 --- /dev/null +++ b/tests/Netdocs.Core.Tests/CalculatorPluginTests.cs @@ -0,0 +1,207 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +public class CalculatorPluginTests +{ + private sealed class FakeContext : IPluginContext + { + public SiteConfig Config { get; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = new Dictionary(); + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } + + private static string Run(string markdown) + { + var plugin = new CalculatorPlugin(); + plugin.Configure(new FakeContext()); + var site = new SiteContext { Config = new SiteConfig(), Options = new BuildOptions(), LoggerFactory = NullLoggerFactory.Instance }; + var page = new Page { SourcePath = "x.md", RelativePath = "x.md", RawMarkdown = markdown }; + return plugin.ProcessAsync(page, markdown, site, CancellationToken.None).GetAwaiter().GetResult(); + } + + private const string PowerCalc = """ + ```calc + title: Power cost + inputs: + - name: watts + label: Power (W) + default: 100 + - name: hours + label: Hours/day + default: 24 + outputs: + - label: Daily cost + expr: (watts * hours / 1000) * 0.12 + format: "$0.00" + ``` + """; + + [Fact] + public void CalcFence_BecomesForm() + { + var result = Run(PowerCalc); + + Assert.Contains("
    Power cost
    ", result); + Assert.Contains("data-calc-var=\"watts\"", result); + Assert.Contains("data-calc-var=\"hours\"", result); + Assert.DoesNotContain("```calc", result); + } + + [Fact] + public void Output_CarriesExprAndFormat() + { + var result = Run(PowerCalc); + + Assert.Contains("data-calc-expr=\"(watts * hours / 1000) * 0.12\"", result); + Assert.Contains("data-calc-format=\"$0.00\"", result); + Assert.Contains("Daily cost", result); + } + + [Fact] + public void EvaluatorScript_EmittedOncePerPage() + { + var md = PowerCalc + "\n\nSome text.\n\n" + PowerCalc; + var result = Run(md); + + var scripts = System.Text.RegularExpressions.Regex.Matches(result, "function bindAll"); + Assert.Single(scripts); + // Two forms though. + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(result, " renders the error box. + Assert.DoesNotContain("alert(", result); + Assert.Contains("nd-calc--error", result); + } + + [Fact] + public void RangeInput_GetsMirrorOutput() + { + var md = """ + ```calc + inputs: + - name: pct + label: Percent + type: range + min: 0 + max: 100 + default: 50 + step: 5 + outputs: + - label: Half + expr: pct / 2 + ``` + """; + var result = Run(md); + + Assert.Contains("type=\"range\"", result); + Assert.Contains("min=\"0\"", result); + Assert.Contains("max=\"100\"", result); + Assert.Contains("step=\"5\"", result); + Assert.Contains("data-calc-mirror=\"pct\"", result); + } + + [Fact] + public void SelectInput_RendersOptions() + { + var md = """ + ```calc + inputs: + - name: mode + label: Mode + type: select + default: b + options: + - { value: a, label: Option A } + - { value: b, label: Option B } + outputs: + - label: Echo + expr: mode + ``` + """; + var result = Run(md); + + Assert.Contains(" + new() { SourcePath = rel, RelativePath = rel, Url = url, Title = rel }; + + [Fact] + public void SectionIndex_PromotesIndexChildToSectionLanding() + { + var pages = new List + { + P("guide/index.md", "guide/"), + P("guide/setup.md", "guide/setup/"), + }; + var config = new Netdocs.Abstractions.SiteConfig + { + Nav = new List + { + new() + { + Title = "Guide", + Children = new List + { + new() { Path = "guide/index.md" }, + new() { Path = "guide/setup.md" }, + }, + }, + }, + }; + + var nav = Netdocs.Core.Content.NavigationBuilder.Build(config, pages); + + var section = Assert.Single(nav); + Assert.True(section.IsSection); + Assert.NotNull(section.SectionIndex); + Assert.Equal("guide/", section.SectionIndex!.Url); + Assert.Single(section.Children); // index child was promoted out of the list + Assert.Equal("guide/setup/", section.Children[0].Url); + } + + [Fact] + public void Flatten_IncludesSectionIndexBeforeChildren() + { + var pages = new List + { + P("guide/index.md", "guide/"), + P("guide/setup.md", "guide/setup/"), + }; + var config = new Netdocs.Abstractions.SiteConfig + { + Nav = new List + { + new() + { + Title = "Guide", + Children = new List + { + new() { Path = "guide/index.md" }, + new() { Path = "guide/setup.md" }, + }, + }, + }, + }; + + var nav = Netdocs.Core.Content.NavigationBuilder.Build(config, pages); + var flat = Netdocs.Core.Content.NavigationBuilder.Flatten(nav); + + Assert.Equal(new[] { "guide/", "guide/setup/" }, flat.ConvertAll(p => p.Url)); + } } diff --git a/tests/Netdocs.Core.Tests/CoreTests.cs b/tests/Netdocs.Core.Tests/CoreTests.cs index 8ba51ab..09269b8 100644 --- a/tests/Netdocs.Core.Tests/CoreTests.cs +++ b/tests/Netdocs.Core.Tests/CoreTests.cs @@ -27,6 +27,50 @@ public void BaseUrl_ComputesRelativePrefix(string url, string expected) => Assert.Equal(expected, PageRenderer.BaseUrl(url)); } +public class FrontMatterTests +{ + [Fact] + public void Split_WithCrlf_DoesNotLeakTailIntoBody() + { + var md = "---\r\ntitle: Hello\r\nimage: /assets/cover.webP\r\n---\r\n# Heading\r\n\r\nBody paragraph.\r\n"; + var (meta, body) = FrontMatter.Split(md); + + Assert.Equal("Hello", meta["title"]); + Assert.DoesNotContain("webP", body); + Assert.DoesNotContain("---", body); + Assert.StartsWith("# Heading", body); + } + + [Fact] + public void Split_WithLf_ParsesBodyCleanly() + { + var md = "---\ntitle: T\n---\nBody\n"; + var (meta, body) = FrontMatter.Split(md); + + Assert.Equal("T", meta["title"]); + Assert.Equal("Body\n", body); + } + + [Fact] + public void Split_NoFrontMatter_ReturnsWholeBody() + { + var md = "# Just content\n\nNo front matter.\n"; + var (meta, body) = FrontMatter.Split(md); + + Assert.Empty(meta); + Assert.Equal(md, body); + } + + [Fact] + public void Split_UnterminatedFrontMatter_ReturnsWholeBody() + { + var md = "---\ntitle: T\nno closing delimiter\n"; + var (_, body) = FrontMatter.Split(md); + + Assert.Equal(md, body); + } +} + public class MarkdownTests { private static string Render(string markdown) @@ -43,6 +87,71 @@ private static string Render(string markdown) return page.HtmlContent; } + private static string RenderWith(string markdown, params Netdocs.Abstractions.IMarkdigContributor[] contributors) + { + var site = new SiteContext + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + var pipeline = MarkdownPipelineFactory.Build(site, contributors); + var page = new Page { SourcePath = "x", RelativePath = "x.md", ProcessedMarkdown = markdown }; + new DocumentRenderer(pipeline).Render(page); + return page.HtmlContent; + } + + [Fact] + public void Typeset_ProducesSmartPunctuation() + { + var html = RenderWith("She said -- wait... \"really\"?\n", new Netdocs.Plugins.TypesetPlugin()); + Assert.Contains("–", html); // en dash from -- + Assert.Contains("…", html); // ellipsis from ... + Assert.Contains("“", html); // opening curly quote + Assert.Contains("”", html); // closing curly quote + } + + [Fact] + public void Emoji_RendersTwemojiImage() + { + var html = Render("Nice work :smile:\n"); + Assert.Contains("class=\"twemoji\"", html); + Assert.Contains("1f604.svg", html); // :smile: => U+1F604 + Assert.Contains("title=\":smile:\"", html); + } + + [Fact] + public void Emoji_ZwjSequenceKeepsVariationSelector() + { + // Family/zwj sequences keep FE0F; simple emoji strip it. + Assert.Equal("1f604", Netdocs.Core.Markdown.Emoji.TwemojiRenderer.ToFileName("\U0001F604")); + Assert.Equal("2764-fe0f-200d-1f525", + Netdocs.Core.Markdown.Emoji.TwemojiRenderer.ToFileName("\u2764\ufe0f\u200d\U0001F525")); + } + + [Fact] + public void Keys_RendersKbdMarkup() + { + var html = Render("Press ++ctrl+alt+del++ now\n"); + Assert.Contains("", html); + Assert.Contains("Ctrl", html); + Assert.Contains("Alt", html); + Assert.Contains("Del", html); + Assert.Contains("+", html); + } + + [Fact] + public void Critic_RendersInsDelMarkSub() + { + Assert.Contains("added", Render("{++added++}\n")); + Assert.Contains("gone", Render("{--gone--}\n")); + Assert.Contains("hi", Render("{==hi==}\n")); + Assert.Contains("note", Render("{>>note<<}\n")); + var sub = Render("{~~old~>new~~}\n"); + Assert.Contains("old", sub); + Assert.Contains("new", sub); + } + [Fact] public void Admonition_RendersMaterialMarkup() { @@ -69,6 +178,37 @@ public void ContentTabs_RenderTabbedSet() Assert.Contains("", html); } + [Fact] + public void CodeFence_LinenumsAndHlLines_EmitDataAttributes() + { + var html = Render("```python linenums=\"5\" hl_lines=\"2 4-5\"\nprint(1)\n```\n"); + Assert.Contains("class=\"language-python\"", html); + Assert.Contains("data-linenums-start=\"5\"", html); + Assert.Contains("data-hl-lines=\"2 4-5\"", html); + } + + [Fact] + public void CodeFence_BraceFormWithTitle_EmitsFilename() + { + var html = Render("```{ .yaml .no-copy title=\"config.yml\" }\nkey: value\n```\n"); + Assert.Contains("class=\"language-yaml\"", html); + Assert.Contains("config.yml", html); + } + + [Fact] + public void InlineHilite_ShebangProducesLanguageClass() + { + var html = Render("Use `#!python range(10)` here.\n"); + Assert.Contains("range(10)", html); + } + + [Fact] + public void InlineCode_WithoutShebang_RendersPlainCode() + { + var html = Render("Plain `value` code.\n"); + Assert.Contains("value", html); + } + [Fact] public void MermaidFence_RendersPreMermaid() { @@ -77,6 +217,30 @@ public void MermaidFence_RendersPreMermaid() Assert.Contains("A-->B", html); } + [Fact] + public void BlockMath_RendersAsMathDiv_NotCodeBlock() + { + var html = Render("$$\n\\int_0^1 x\\,dx\n$$\n"); + Assert.Contains("
    \\[", html); + Assert.Contains("\\]
    ", html); + Assert.DoesNotContain("language-math", html); + } + + [Fact] + public void InlineMath_RendersAsMathSpan() + { + var html = Render("Energy $E=mc^2$ here."); + Assert.Contains("\\(E=mc^2\\)", html); + } + + [Fact] + public void Footnotes_RenderReferenceAndDefinition() + { + var html = Render("Claim.[^a]\n\n[^a]: Supporting detail.\n"); + Assert.Contains("footnote-ref", html); + Assert.Contains("Supporting detail.", html); + } + [Fact] public void Title_ExtractedFromFirstHeading() { @@ -86,4 +250,38 @@ public void Title_ExtractedFromFirstHeading() new DocumentRenderer(pipeline).Render(page); Assert.Equal("Hello World", page.Title); } + + [Fact] + public void MarkdownLinks_AreRewrittenRelativeToPage_ForBasePathSafety() + { + var site = new SiteContext { Config = new SiteConfig(), Options = new BuildOptions(), LoggerFactory = NullLoggerFactory.Instance }; + var pipeline = MarkdownPipelineFactory.Build(site, []); + var linkMap = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["index.md"] = "", + ["getting-started/index.md"] = "getting-started/", + ["getting-started/quickstart.md"] = "getting-started/quickstart/", + ["reference/cli.md"] = "reference/cli/", + }; + + // From the home page (root), links stay relative to root. + var home = new Page { SourcePath = "index.md", RelativePath = "index.md", Url = "", ProcessedMarkdown = "See [start](getting-started/index.md)." }; + new DocumentRenderer(pipeline, linkMap).Render(home); + Assert.Contains("href=\"getting-started/\"", home.HtmlContent); + Assert.DoesNotContain("href=\"/getting-started/\"", home.HtmlContent); + + // From a nested page, links are prefixed with ../ back to the site root so they work + // under a base path (e.g. GitHub project Pages served at /Repo/). + var nested = new Page + { + SourcePath = "getting-started/index.md", + RelativePath = "getting-started/index.md", + Url = "getting-started/", + ProcessedMarkdown = "See [cli](../reference/cli.md) and [qs](quickstart.md).", + }; + new DocumentRenderer(pipeline, linkMap).Render(nested); + Assert.Contains("href=\"../reference/cli/\"", nested.HtmlContent); + Assert.Contains("href=\"../getting-started/quickstart/\"", nested.HtmlContent); + Assert.DoesNotContain("href=\"/reference/cli/\"", nested.HtmlContent); + } } diff --git a/tests/Netdocs.Core.Tests/CssJsMinifierTests.cs b/tests/Netdocs.Core.Tests/CssJsMinifierTests.cs new file mode 100644 index 0000000..e8fd604 --- /dev/null +++ b/tests/Netdocs.Core.Tests/CssJsMinifierTests.cs @@ -0,0 +1,67 @@ +using Netdocs.Core.Optimization; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the conservative CSS/JS minifier used by the optimize toggles. +public class CssJsMinifierTests +{ + [Fact] + public void MinifyCss_StripsCommentsAndCollapsesWhitespace() + { + var css = """ + /* header */ + body { + color: red; + margin: 0; + } + """; + var min = CssJsMinifier.MinifyCss(css); + + Assert.DoesNotContain("/*", min); + Assert.DoesNotContain("\n", min); + Assert.Contains("body{color:red;margin:0}", min); + } + + [Fact] + public void MinifyCss_PreservesStringContents() + { + var css = "a::before { content: \" spaced value \"; }"; + var min = CssJsMinifier.MinifyCss(css); + Assert.Contains("\" spaced value \"", min); + } + + [Fact] + public void MinifyJs_StripsCommentsAndCollapsesWhitespace() + { + var js = """ + // greet + function hi() { + /* block */ + return 1; + } + """; + var min = CssJsMinifier.MinifyJs(js); + + Assert.DoesNotContain("//", min); + Assert.DoesNotContain("/*", min); + Assert.DoesNotContain("return 1", min); + Assert.Contains("return 1;", min); + } + + [Fact] + public void MinifyJs_DoesNotStripUrlInsideString() + { + var js = "const u = \"https://example.com/path\"; // trailing"; + var min = CssJsMinifier.MinifyJs(js); + Assert.Contains("\"https://example.com/path\"", min); + Assert.DoesNotContain("// trailing", min); + } + + [Fact] + public void Minify_HandlesEmptyInput() + { + Assert.Equal("", CssJsMinifier.MinifyCss("")); + Assert.Equal("", CssJsMinifier.MinifyJs("")); + } +} diff --git a/tests/Netdocs.Core.Tests/DeployerTests.cs b/tests/Netdocs.Core.Tests/DeployerTests.cs new file mode 100644 index 0000000..37ade45 --- /dev/null +++ b/tests/Netdocs.Core.Tests/DeployerTests.cs @@ -0,0 +1,108 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Core.Deploy; +using Xunit; + +namespace Netdocs.Core.Tests; + +public class DeployerTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "netdocs-deploy-test-" + Guid.NewGuid().ToString("N")); + + private SiteConfig NewConfig(string dest, bool clean = true) + { + var siteDir = Path.Combine(_root, "site"); + Directory.CreateDirectory(siteDir); + Directory.CreateDirectory(Path.Combine(siteDir, "sub")); + File.WriteAllText(Path.Combine(siteDir, "index.html"), ""); + File.WriteAllText(Path.Combine(siteDir, "sub", "page.html"), "sub"); + return new SiteConfig + { + ProjectRoot = _root, + SiteDir = "site", + Deploy = new DeployConfig { Target = "filesystem", Path = dest, Clean = clean }, + }; + } + + [Fact] + public async Task Filesystem_CopiesAllFiles() + { + var dest = Path.Combine(_root, "out"); + var config = NewConfig(dest); + var result = await new Deployer(config, NullLogger.Instance).DeployAsync(); + + Assert.Equal(0, result); + Assert.True(File.Exists(Path.Combine(dest, "index.html"))); + Assert.True(File.Exists(Path.Combine(dest, "sub", "page.html"))); + } + + [Fact] + public async Task Filesystem_Clean_PrunesStaleFiles() + { + var dest = Path.Combine(_root, "out"); + Directory.CreateDirectory(dest); + File.WriteAllText(Path.Combine(dest, "old.html"), "stale"); + + var config = NewConfig(dest); + await new Deployer(config, NullLogger.Instance).DeployAsync(); + + Assert.False(File.Exists(Path.Combine(dest, "old.html"))); + Assert.True(File.Exists(Path.Combine(dest, "index.html"))); + } + + [Fact] + public async Task Filesystem_NoClean_KeepsStaleFiles() + { + var dest = Path.Combine(_root, "out"); + Directory.CreateDirectory(dest); + File.WriteAllText(Path.Combine(dest, "old.html"), "stale"); + + var config = NewConfig(dest, clean: false); + await new Deployer(config, NullLogger.Instance).DeployAsync(); + + Assert.True(File.Exists(Path.Combine(dest, "old.html"))); + } + + [Fact] + public async Task Filesystem_MissingPath_Fails() + { + var config = NewConfig(""); + config.Deploy.Path = null; + var result = await new Deployer(config, NullLogger.Instance).DeployAsync(); + Assert.Equal(1, result); + } + + [Fact] + public async Task Target_None_IsNoOp() + { + var config = NewConfig(Path.Combine(_root, "out")); + config.Deploy.Target = "none"; + var result = await new Deployer(config, NullLogger.Instance).DeployAsync(); + Assert.Equal(0, result); + } + + [Fact] + public async Task S3_MissingBucket_Fails() + { + var config = NewConfig(Path.Combine(_root, "out")); + config.Deploy.Target = "s3"; + config.Deploy.Bucket = null; + var result = await new Deployer(config, NullLogger.Instance).DeployAsync(); + Assert.Equal(1, result); + } + + [Fact] + public async Task UnknownTarget_Fails() + { + var config = NewConfig(Path.Combine(_root, "out")); + config.Deploy.Target = "bogus"; + var result = await new Deployer(config, NullLogger.Instance).DeployAsync(); + Assert.Equal(1, result); + } + + public void Dispose() + { + if (Directory.Exists(_root)) + try { Directory.Delete(_root, recursive: true); } catch { /* best effort */ } + } +} diff --git a/tests/Netdocs.Core.Tests/GoldenTests.cs b/tests/Netdocs.Core.Tests/GoldenTests.cs new file mode 100644 index 0000000..7d6e589 --- /dev/null +++ b/tests/Netdocs.Core.Tests/GoldenTests.cs @@ -0,0 +1,58 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Core.Content; +using Netdocs.Core.Markdown; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// +/// Golden-file tests that lock in the HTML produced by the Markdown pipeline for a set of +/// representative inputs. Set the environment variable UPDATE_GOLDEN=1 to (re)write +/// the expected .html files after an intentional change. +/// +public class GoldenTests +{ + private static string GoldenDir([CallerFilePath] string file = "") + => Path.Combine(Path.GetDirectoryName(file)!, "golden"); + + public static IEnumerable Cases() + { + foreach (var md in Directory.EnumerateFiles(GoldenDir(), "*.md").OrderBy(f => f)) + yield return new object[] { Path.GetFileNameWithoutExtension(md) }; + } + + [Theory] + [MemberData(nameof(Cases))] + public void Markdown_MatchesGolden(string name) + { + var dir = GoldenDir(); + var markdown = File.ReadAllText(Path.Combine(dir, name + ".md")); + var actual = Render(markdown).Replace("\r\n", "\n").TrimEnd() + "\n"; + + var goldenPath = Path.Combine(dir, name + ".html"); + if (Environment.GetEnvironmentVariable("UPDATE_GOLDEN") == "1" || !File.Exists(goldenPath)) + { + File.WriteAllText(goldenPath, actual); + return; + } + + var expected = File.ReadAllText(goldenPath).Replace("\r\n", "\n").TrimEnd() + "\n"; + Assert.Equal(expected, actual); + } + + private static string Render(string markdown) + { + var site = new SiteContext + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + var pipeline = MarkdownPipelineFactory.Build(site, []); + var page = new Page { SourcePath = "x", RelativePath = "x.md", ProcessedMarkdown = markdown }; + new DocumentRenderer(pipeline).Render(page); + return page.HtmlContent; + } +} diff --git a/tests/Netdocs.Core.Tests/HtmlMinifierTests.cs b/tests/Netdocs.Core.Tests/HtmlMinifierTests.cs new file mode 100644 index 0000000..008cf29 --- /dev/null +++ b/tests/Netdocs.Core.Tests/HtmlMinifierTests.cs @@ -0,0 +1,53 @@ +using Netdocs.Core.Optimization; +using Xunit; + +namespace Netdocs.Core.Tests; + +public class HtmlMinifierTests +{ + [Fact] + public void Minify_CollapsesWhitespaceBetweenTags() + { + var input = "
    \n

    Hello

    \n

    World

    \n
    "; + var output = HtmlMinifier.Minify(input); + Assert.Equal("

    Hello

    World

    ", output); + } + + [Fact] + public void Minify_PreservesPreContent() + { + var input = "
    \n  line1\n    line2\n
    "; + var output = HtmlMinifier.Minify(input); + Assert.Contains(" line1\n line2", output); + } + + [Fact] + public void Minify_PreservesScriptAndStyle() + { + var input = ""; + var output = HtmlMinifier.Minify(input); + Assert.Contains("color: red;", output); + Assert.Contains("\n", output); + } + + [Fact] + public void Minify_StripsComments_KeepsConditionals() + { + var input = "
    "; + var output = HtmlMinifier.Minify(input); + Assert.DoesNotContain("drop me", output); + Assert.Contains("[if IE]", output); + } + + [Fact] + public void Minify_KeepsSingleSpaceInInlineText() + { + var input = "

    Hello world\n again

    "; + var output = HtmlMinifier.Minify(input); + Assert.Equal("

    Hello world again

    ", output); + } + + [Fact] + public void Minify_EmptyString_ReturnsEmpty() + => Assert.Equal("", HtmlMinifier.Minify("")); +} diff --git a/tests/Netdocs.Core.Tests/LinkNotesPluginTests.cs b/tests/Netdocs.Core.Tests/LinkNotesPluginTests.cs new file mode 100644 index 0000000..31a2771 --- /dev/null +++ b/tests/Netdocs.Core.Tests/LinkNotesPluginTests.cs @@ -0,0 +1,263 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +public class LinkNotesPluginTests +{ + private sealed class FakeContext(IReadOnlyDictionary options) : IPluginContext + { + public SiteConfig Config { get; init; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options; + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } + + private static LinkNotesPlugin Configured(params Dictionary[] rules) + { + var opts = new Dictionary { ["rules"] = rules.Cast().ToList() }; + var plugin = new LinkNotesPlugin(); + plugin.Configure(new FakeContext(opts)); + return plugin; + } + + private static Dictionary Rule(string name, string[] domains, string note, string? query = null) + { + var d = new Dictionary + { + ["name"] = name, + ["domains"] = domains.Cast().ToList(), + ["note"] = note, + }; + if (query is not null) d["query_contains"] = query; + return d; + } + + private static string Run(LinkNotesPlugin plugin, string markdown) + { + var site = new SiteContext { Config = new SiteConfig(), Options = new BuildOptions(), LoggerFactory = NullLoggerFactory.Instance }; + var page = new Page { SourcePath = "blog/posts/x.md", RelativePath = "blog/posts/x.md", RawMarkdown = markdown }; + return plugin.ProcessAsync(page, markdown, site, CancellationToken.None).GetAwaiter().GetResult(); + } + + [Fact] + public void EbayLink_GetsFootnoteRefAndNote() + { + var plugin = Configured(Rule("ebay", ["ebay.us"], "This post uses eBay affiliate links.")); + var result = Run(plugin, "Check this [eBay Link](https://ebay.us/abc123) out."); + + Assert.Contains("[eBay Link](https://ebay.us/abc123)[^linknote-ebay]", result); + Assert.Contains("[^linknote-ebay]: This post uses eBay affiliate links.", result); + } + + [Fact] + public void EbayLink_WithAttrList_KeepsAttributesBeforeRef() + { + var plugin = Configured(Rule("ebay", ["ebay.us"], "eBay note.")); + var result = Run(plugin, "[eBay Link](https://ebay.us/abc){target=_blank}"); + + Assert.Contains("[eBay Link](https://ebay.us/abc){target=_blank}[^linknote-ebay]", result); + } + + [Fact] + public void NonMatchingLink_IsUntouched() + { + var plugin = Configured(Rule("ebay", ["ebay.us"], "eBay note.")); + var result = Run(plugin, "A normal [link](https://example.com/page)."); + + Assert.DoesNotContain("linknote", result); + } + + [Fact] + public void AmazonLink_RequiresQueryMarker() + { + var plugin = Configured(Rule("amazon", ["amazon.com"], "Amazon note.", query: "tag=")); + var plain = Run(plugin, "Plain [product](https://amazon.com/dp/B000)."); + Assert.DoesNotContain("linknote", plain); + + var tagged = Run(plugin, "Tagged [product](https://amazon.com/dp/B000?tag=xo-20)."); + Assert.Contains("[^linknote-amazon]", tagged); + Assert.Contains("[^linknote-amazon]: Amazon note.", tagged); + } + + [Fact] + public void MultipleLinks_EmitNoteOnce() + { + var plugin = Configured(Rule("ebay", ["ebay.us"], "eBay note.")); + var result = Run(plugin, + "[A](https://ebay.us/a) and [B](https://ebay.us/b) and [C](https://ebay.us/c)"); + + Assert.Equal(3, System.Text.RegularExpressions.Regex.Matches(result, @"\[\^linknote-ebay\](?!:)").Count); + Assert.Single(System.Text.RegularExpressions.Regex.Matches(result, @"\[\^linknote-ebay\]:")); + } + + [Fact] + public void LinksInFencedCode_AreIgnored() + { + var plugin = Configured(Rule("ebay", ["ebay.us"], "eBay note.")); + var md = "```\n[eBay](https://ebay.us/abc)\n```"; + var result = Run(plugin, md); + + Assert.DoesNotContain("linknote", result); + } + + [Fact] + public void LinkWithExistingFootnote_IsNotDoubleAnnotated() + { + var plugin = Configured(Rule("ebay", ["ebay.us"], "eBay note.")); + var result = Run(plugin, "[eBay Link](https://ebay.us/abc){target=_blank}[^ebay]\n\n[^ebay]: manual."); + + Assert.DoesNotContain("[^linknote-ebay]", result); + } + + [Fact] + public void PerDomainQueryRules_MixShortenerAndTaggedDomain() + { + var opts = new Dictionary + { + ["rules"] = new List + { + new Dictionary + { + ["name"] = "amazon", + ["note"] = "Amazon note.", + ["domains"] = new List + { + "amzn.to", + new Dictionary { ["domain"] = "amazon.com", ["query_contains"] = "tag=" }, + }, + }, + }, + }; + var plugin = new LinkNotesPlugin(); + plugin.Configure(new FakeContext(opts)); + + Assert.Contains("[^linknote-amazon]", Run(plugin, "[a](https://amzn.to/xyz)")); + Assert.Contains("[^linknote-amazon]", Run(plugin, "[a](https://amazon.com/dp/B0?tag=xo-20)")); + Assert.DoesNotContain("linknote", Run(plugin, "[a](https://amazon.com/dp/B0)")); + } + + [Fact] + public void LinksInPipeTable_UseStandaloneNoteNotFootnoteRef() + { + var plugin = Configured(Rule("amazon", ["amzn.to"], "Amazon note.")); + var md = "| Item | Buy |\n| --- | --- |\n| Widget | [Amazon](https://amzn.to/xyz) |"; + var result = Run(plugin, md); + + // No footnote ref injected into the table cell (would break Markdig table rendering). + Assert.DoesNotContain("[^linknote-amazon]", result); + // But the footer note is still guaranteed via a standalone admonition (default title). + Assert.Contains("!!! info \"Links\"", result); + Assert.Contains("Amazon note.", result); + } + + [Fact] + public void CustomLabel_IsUsedForStandaloneAdmonitionTitle() + { + var d = Rule("amazon", ["amzn.to"], "Amazon note."); + d["label"] = "Affiliate links"; + var plugin = Configured(d); + var md = "| Item | Buy |\n| --- | --- |\n| Widget | [Amazon](https://amzn.to/xyz) |"; + var result = Run(plugin, md); + + Assert.Contains("!!! info \"Affiliate links\"", result); + } + + [Fact] + public void InlineLinkWinsOverTableOnly_NoDuplicateNote() + { + var plugin = Configured(Rule("amazon", ["amzn.to"], "Amazon note.")); + var md = "Inline [buy](https://amzn.to/a) here.\n\n| x | [Amazon](https://amzn.to/b) |"; + var result = Run(plugin, md); + + Assert.Contains("[^linknote-amazon]: Amazon note.", result); + // Rule already referenced inline, so no standalone admonition too. + Assert.DoesNotContain("!!! info", result); + } + + [Fact] + public void SubdomainOfConfiguredDomain_Matches() + { + var plugin = Configured(Rule("ebay", ["ebay.com"], "eBay note.")); + var result = Run(plugin, "[item](https://www.ebay.com/itm/123)"); + Assert.Contains("[^linknote-ebay]", result); + } + + [Fact] + public void RegexPattern_MatchesLink() + { + var d = new Dictionary + { + ["name"] = "promo", + ["note"] = "Promo note.", + ["patterns"] = new List { @"https?://[^/]*\.example\.com/ref/\d+" }, + }; + var plugin = Configured(d); + + Assert.Contains("[^linknote-promo]", Run(plugin, "[go](https://shop.example.com/ref/42)")); + Assert.DoesNotContain("linknote", Run(plugin, "[go](https://shop.example.com/other)")); + } + + [Fact] + public void RegexAndDomain_BothSelectTheRule() + { + var d = new Dictionary + { + ["name"] = "mix", + ["note"] = "Mixed note.", + ["domains"] = new List { "ebay.us" }, + ["patterns"] = new List { @"utm_source=xo" }, + }; + var plugin = Configured(d); + + Assert.Contains("[^linknote-mix]", Run(plugin, "[a](https://ebay.us/x)")); + Assert.Contains("[^linknote-mix]", Run(plugin, "[b](https://other.example/deal?utm_source=xo)")); + } + + [Fact] + public void LegacyProgramsAndDisclosureKeys_StillWork() + { + // Backward compatibility: the old `programs` / `disclosure` config keys must keep working. + var opts = new Dictionary + { + ["programs"] = new List + { + new Dictionary + { + ["name"] = "ebay", + ["domains"] = new List { "ebay.us" }, + ["disclosure"] = "Legacy eBay disclosure.", + }, + }, + }; + var plugin = new LinkNotesPlugin(); + plugin.Configure(new FakeContext(opts)); + var result = Run(plugin, "Buy on [eBay](https://ebay.us/abc)."); + + Assert.Contains("[^linknote-ebay]", result); + Assert.Contains("[^linknote-ebay]: Legacy eBay disclosure.", result); + } + + [Fact] + public void InvalidRegexPattern_IsSkippedNotThrown() + { + var d = new Dictionary + { + ["name"] = "bad", + ["note"] = "note", + ["patterns"] = new List { "([unclosed" }, + }; + // Should not throw; the rule simply has no usable pattern (and no domains), so it's dropped. + var plugin = Configured(d); + Assert.DoesNotContain("linknote", Run(plugin, "[a](https://ebay.us/x)")); + } +} diff --git a/tests/Netdocs.Core.Tests/MacrosPluginTests.cs b/tests/Netdocs.Core.Tests/MacrosPluginTests.cs new file mode 100644 index 0000000..74e6da0 --- /dev/null +++ b/tests/Netdocs.Core.Tests/MacrosPluginTests.cs @@ -0,0 +1,145 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the mkdocs-macros port: fileuri resolution and the button example macro. +public class MacrosPluginTests +{ + private static SiteContext Site(params Page[] pages) + { + var site = new SiteContext + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + site.Pages.AddRange(pages); + return site; + } + + [Fact] + public async Task FileUri_ResolvesPageUrl() + { + var target = new Page { SourcePath = "a", RelativePath = "guides/setup/install.md", Url = "guides/setup/install/" }; + var page = new Page { SourcePath = "b", RelativePath = "guides/setup/index.md", Url = "guides/setup/" }; + var site = Site(target, page); + + var result = await new MacrosPlugin() + .ProcessAsync(page, "See [install]({{ fileuri(\"install.md\") }}).", site, default); + + Assert.Contains("/guides/setup/install/", result); + Assert.DoesNotContain("fileuri", result); + } + + [Fact] + public async Task FileUri_MissingFile_EmitsComment() + { + var page = new Page { SourcePath = "b", RelativePath = "index.md", Url = "" }; + var result = await new MacrosPlugin() + .ProcessAsync(page, "{{ fileuri(\"nope.md\") }}", Site(page), default); + + Assert.Contains("macros: fileuri('nope.md') not found", result); + } + + [Fact] + public async Task Button_RendersMaterialButton() + { + var page = new Page { SourcePath = "b", RelativePath = "index.md", Url = "" }; + var result = await new MacrosPlugin() + .ProcessAsync(page, "{{ button(\"Get started\", \"getting-started/\") }}", Site(page), default); + + Assert.Contains("class=\"md-button\"", result); + Assert.Contains("href=\"getting-started/\"", result); + Assert.Contains(">Get started", result); + } + + [Fact] + public async Task IgnoreMacros_LeavesMarkdownUntouched() + { + var page = new Page + { + SourcePath = "b", + RelativePath = "index.md", + Url = "", + FrontMatter = new Dictionary { ["ignore_macros"] = true }, + }; + const string md = "{{ button(\"x\", \"y\") }}"; + var result = await new MacrosPlugin().ProcessAsync(page, md, Site(page), default); + + Assert.Equal(md, result); + } + + [Fact] + public async Task Variables_ExpandDefinedTokens_AndLeaveUnknownUntouched() + { + var page = new Page { SourcePath = "b", RelativePath = "index.md", Url = "" }; + var plugin = new MacrosPlugin(); + plugin.Configure(new FakeContext(new Dictionary + { + ["variables"] = new Dictionary + { + ["product"] = "Netdocs", + ["version"] = "1.2.3", + }, + })); + + var result = await plugin.ProcessAsync(page, "{{ product }} v{{ version }} β€” {{ unknown }}", Site(page), default); + + Assert.Contains("Netdocs v1.2.3", result); + Assert.Contains("{{ unknown }}", result); + } + + [Fact] + public async Task FileUri_ModeVariants_FormatUrl() + { + var target = new Page { SourcePath = "a", RelativePath = "guides/setup/install.md", Url = "guides/setup/install/" }; + var page = new Page { SourcePath = "b", RelativePath = "guides/index.md", Url = "guides/" }; + var site = Site(target, page); + + // Root-absolute path (never includes a host, even if site_url were set). + var path = await new MacrosPlugin() + .ProcessAsync(page, "{{ fileuri(\"install.md\", \"path\") }}", site, default); + Assert.Contains("/guides/setup/install/", path); + + // Page-relative URI: one `../` back out of guides/ to the site root. + var rel = await new MacrosPlugin() + .ProcessAsync(page, "{{ fileuri(\"install.md\", \"relative\") }}", site, default); + Assert.Contains("../guides/setup/install/", rel); + } + + [Fact] + public async Task FileUri_AbsoluteMode_UsesSiteUrl() + { + var target = new Page { SourcePath = "a", RelativePath = "feed.md", Url = "feed/" }; + var page = new Page { SourcePath = "b", RelativePath = "index.md", Url = "" }; + var site = Site(target, page); + + var plugin = new MacrosPlugin(); + plugin.Configure(new FakeContext(new Dictionary()) + { + Config = new SiteConfig { SiteUrl = "https://example.com/" }, + }); + + var result = await plugin.ProcessAsync(page, "{{ fileuri(\"feed.md\", \"absolute\") }}", site, default); + + Assert.Contains("https://example.com/feed/", result); + } + + private sealed class FakeContext(IReadOnlyDictionary options) : IPluginContext + { + public SiteConfig Config { get; init; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options; + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } +} diff --git a/tests/Netdocs.Core.Tests/MkDocsImporterTests.cs b/tests/Netdocs.Core.Tests/MkDocsImporterTests.cs new file mode 100644 index 0000000..13dcb71 --- /dev/null +++ b/tests/Netdocs.Core.Tests/MkDocsImporterTests.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using Netdocs.Core.Configuration; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers converting an mkdocs.yml into the Netdocs appsettings.json schema. +public class MkDocsImporterTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "netdocs-import-" + Guid.NewGuid().ToString("N")); + + public MkDocsImporterTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private string WriteYaml(string yaml) + { + var path = Path.Combine(_dir, "mkdocs.yml"); + File.WriteAllText(path, yaml); + return path; + } + + private JsonElement Convert(string yaml) + { + var json = MkDocsImporter.ConvertToJson(WriteYaml(yaml)); + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + [Fact] + public void MapsCoreMetadata() + { + var root = Convert(""" + site_name: My Docs + site_url: https://example.com/docs/ + repo_url: https://github.com/me/repo + """); + + var netdocs = root.GetProperty("Netdocs"); + Assert.Equal("My Docs", netdocs.GetProperty("siteName").GetString()); + Assert.Equal("https://example.com/docs/", netdocs.GetProperty("siteUrl").GetString()); + Assert.Equal("https://github.com/me/repo", netdocs.GetProperty("repoUrl").GetString()); + } + + [Fact] + public void MapsNestedNav() + { + var root = Convert(""" + site_name: X + nav: + - Home: index.md + - Guide: + - Intro: guide/intro.md + """); + + var nav = root.GetProperty("Netdocs").GetProperty("nav"); + Assert.Equal("index.md", nav[0].GetProperty("path").GetString()); + var guide = nav[1]; + Assert.Equal("Guide", guide.GetProperty("title").GetString()); + Assert.Equal("guide/intro.md", guide.GetProperty("children")[0].GetProperty("path").GetString()); + } + + [Fact] + public void MapsPluginsAndExtensionsWithOptions() + { + var root = Convert(""" + site_name: X + plugins: + - search + - tags: + export: true + markdown_extensions: + - admonition + - toc: + permalink: true + """); + + var netdocs = root.GetProperty("Netdocs"); + var plugins = netdocs.GetProperty("plugins"); + Assert.Equal("search", plugins[0].GetProperty("name").GetString()); + Assert.Equal("tags", plugins[1].GetProperty("name").GetString()); + Assert.True(plugins[1].GetProperty("options").GetProperty("export").GetBoolean()); + + var exts = netdocs.GetProperty("markdownExtensions"); + Assert.Equal("admonition", exts[0].GetProperty("name").GetString()); + Assert.True(exts[1].GetProperty("options").GetProperty("permalink").GetBoolean()); + } + + [Fact] + public void ImportedConfigLoadsBackViaJsonConfigLoader() + { + var json = MkDocsImporter.ConvertToJson(WriteYaml(""" + site_name: Round Trip + theme: + name: material + features: [navigation.tabs] + nav: + - Home: index.md + plugins: + - search + """)); + var outPath = Path.Combine(_dir, "appsettings.json"); + File.WriteAllText(outPath, json); + + var config = JsonConfigLoader.Load(outPath); + Assert.Equal("Round Trip", config.SiteName); + Assert.Contains("navigation.tabs", config.Theme.Features); + Assert.Contains(config.Plugins, p => p.Name == "search"); + Assert.Single(config.Nav); + } +} diff --git a/tests/Netdocs.Core.Tests/OutputWriterTests.cs b/tests/Netdocs.Core.Tests/OutputWriterTests.cs new file mode 100644 index 0000000..d0ee820 --- /dev/null +++ b/tests/Netdocs.Core.Tests/OutputWriterTests.cs @@ -0,0 +1,110 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Core.Content; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Verifies the incremental output writer only touches files whose contents changed. +public class OutputWriterTests +{ + private static SiteContext NewSite() => + new() { Config = new SiteConfig(), Options = new BuildOptions(), LoggerFactory = NullLoggerFactory.Instance }; + + [Fact] + public async Task WritesWhenMissing() + { + var path = Path.Combine(Path.GetTempPath(), "netdocs-ow-" + Guid.NewGuid().ToString("N"), "index.html"); + try + { + Assert.True(await OutputWriter.WriteTextIfChangedAsync(path, "

    hi

    ")); + Assert.Equal("

    hi

    ", await File.ReadAllTextAsync(path)); + } + finally + { + var dir = Path.GetDirectoryName(path)!; + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task SkipsWhenUnchanged_ButRewritesOnChange() + { + var path = Path.Combine(Path.GetTempPath(), "netdocs-ow-" + Guid.NewGuid().ToString("N"), "page.html"); + try + { + Assert.True(await OutputWriter.WriteTextIfChangedAsync(path, "same")); + var firstWrite = File.GetLastWriteTimeUtc(path); + + // Identical content: no write, timestamp preserved. + await Task.Delay(20); + Assert.False(await OutputWriter.WriteTextIfChangedAsync(path, "same")); + Assert.Equal(firstWrite, File.GetLastWriteTimeUtc(path)); + + // Different content: rewrite. + Assert.True(await OutputWriter.WriteTextIfChangedAsync(path, "different")); + Assert.Equal("different", await File.ReadAllTextAsync(path)); + } + finally + { + var dir = Path.GetDirectoryName(path)!; + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task TrackedWrite_RecordsOutput_AndPrunePreservesIt() + { + var dir = Path.Combine(Path.GetTempPath(), "netdocs-ow-" + Guid.NewGuid().ToString("N")); + try + { + var site = NewSite(); + var kept = Path.Combine(dir, "page.html"); + await OutputWriter.WriteTextIfChangedAsync(site, kept, "

    kept

    "); + + Assert.Contains(Path.GetFullPath(kept), site.WrittenOutputs.Keys); + + // A file that was never tracked should be pruned; the tracked one survives. + var orphan = Path.Combine(dir, "nested", "orphan.html"); + Directory.CreateDirectory(Path.GetDirectoryName(orphan)!); + await File.WriteAllTextAsync(orphan, "stale"); + + var pruned = OutputWriter.PruneStale(site, dir); + + Assert.Equal(1, pruned); + Assert.True(File.Exists(kept)); + Assert.False(File.Exists(orphan)); + Assert.False(Directory.Exists(Path.GetDirectoryName(orphan)!)); // emptied dir removed + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public async Task CopyIfChanged_SkipsIdenticalBytes_AndTracks() + { + var dir = Path.Combine(Path.GetTempPath(), "netdocs-ow-" + Guid.NewGuid().ToString("N")); + try + { + var site = NewSite(); + var source = Path.Combine(dir, "src.bin"); + var dest = Path.Combine(dir, "out", "dest.bin"); + Directory.CreateDirectory(dir); + await File.WriteAllBytesAsync(source, [1, 2, 3, 4]); + + Assert.True(await OutputWriter.CopyIfChangedAsync(site, source, dest)); + var firstWrite = File.GetLastWriteTimeUtc(dest); + Assert.Contains(Path.GetFullPath(dest), site.WrittenOutputs.Keys); + + await Task.Delay(20); + Assert.False(await OutputWriter.CopyIfChangedAsync(site, source, dest)); + Assert.Equal(firstWrite, File.GetLastWriteTimeUtc(dest)); + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } +} diff --git a/tests/Netdocs.Core.Tests/PluginHostTests.cs b/tests/Netdocs.Core.Tests/PluginHostTests.cs new file mode 100644 index 0000000..4af2bb8 --- /dev/null +++ b/tests/Netdocs.Core.Tests/PluginHostTests.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Core.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers plugin instantiation and the configurable preprocessor order override. +public class PluginHostTests +{ + private sealed class Pre10 : IPlugin, IMarkdownPreprocessor + { + public string Name => "pre10"; + public int Order => 10; + public void Configure(IPluginContext ctx) { } + public Task ProcessAsync(Page page, string md, SiteContext site, CancellationToken ct) => Task.FromResult(md); + } + + private sealed class Pre20 : IPlugin, IMarkdownPreprocessor + { + public string Name => "pre20"; + public int Order => 20; + public void Configure(IPluginContext ctx) { } + public Task ProcessAsync(Page page, string md, SiteContext site, CancellationToken ct) => Task.FromResult(md); + } + + private static PluginHost Build(params PluginConfig[] configs) + { + var registry = new PluginRegistry().Register("pre10").Register("pre20"); + var services = new ServiceCollection(); + return PluginHost.Build(new SiteConfig(), new BuildOptions(), configs, registry, + services.BuildServiceProvider(), services, NullLoggerFactory.Instance); + } + + [Fact] + public void Preprocessors_UseNaturalOrder_ByDefault() + { + var host = Build( + new PluginConfig { Name = "pre20" }, + new PluginConfig { Name = "pre10" }); + + Assert.Equal(["pre10", "pre20"], host.Preprocessors.Cast().Select(p => p.Name)); + } + + [Fact] + public void Preprocessors_HonorConfigOrderOverride() + { + // Force pre20 (natural 20) to run before pre10 (natural 10) via an order override. + var host = Build( + new PluginConfig { Name = "pre10" }, + new PluginConfig { Name = "pre20", Order = 5 }); + + Assert.Equal(["pre20", "pre10"], host.Preprocessors.Cast().Select(p => p.Name)); + } +} diff --git a/tests/Netdocs.Core.Tests/RenderCacheTests.cs b/tests/Netdocs.Core.Tests/RenderCacheTests.cs new file mode 100644 index 0000000..92175b7 --- /dev/null +++ b/tests/Netdocs.Core.Tests/RenderCacheTests.cs @@ -0,0 +1,94 @@ +using Netdocs.Abstractions; +using Netdocs.Core.Content; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// +/// Verifies the incremental render cache: keys are stable for identical inputs, change when any +/// input changes, and a store→restore round-trip reproduces the rendered artifacts exactly. +/// +public class RenderCacheTests +{ + private static Page NewPage(string rel = "index.md") => new() + { + SourcePath = "", + RelativePath = rel, + Title = "Home", + ProcessedMarkdown = "# Home\n\nHello world.", + }; + + [Fact] + public void ComputeKey_IsStableForIdenticalInputs() + { + var a = RenderCache.ComputeKey(NewPage(), "salt", "linkmap"); + var b = RenderCache.ComputeKey(NewPage(), "salt", "linkmap"); + Assert.Equal(a, b); + } + + [Theory] + [InlineData("# Home\n\nChanged.", "salt", "linkmap")] + [InlineData("# Home\n\nHello world.", "salt2", "linkmap")] + [InlineData("# Home\n\nHello world.", "salt", "linkmap2")] + public void ComputeKey_ChangesWhenAnyInputChanges(string md, string salt, string linkMap) + { + var baseline = RenderCache.ComputeKey(NewPage(), "salt", "linkmap"); + var page = NewPage(); + page.ProcessedMarkdown = md; + Assert.NotEqual(baseline, RenderCache.ComputeKey(page, salt, linkMap)); + } + + [Fact] + public void StoreThenRestore_ReproducesArtifacts() + { + var dir = Path.Combine(Path.GetTempPath(), "netdocs-cache-" + Guid.NewGuid().ToString("N")); + try + { + // First build: render and store. + var cache = RenderCache.Load(dir); + var key = RenderCache.ComputeKey(NewPage(), "salt", "linkmap"); + var rendered = NewPage(); + rendered.HtmlContent = "

    Home

    Hello world.

    "; + rendered.PlainText = "Home Hello world."; + rendered.Toc = new List { new() { Level = 1, Id = "home", Title = "Home" } }; + cache.Store(rendered, key); + cache.Save(); + + // Second build: a fresh page with no artifacts should be restored from disk. + var reloaded = RenderCache.Load(dir); + var target = NewPage(); + Assert.True(reloaded.TryRestore(target, key)); + Assert.Equal(1, reloaded.Hits); + Assert.Equal(rendered.HtmlContent, target.HtmlContent); + Assert.Equal(rendered.PlainText, target.PlainText); + Assert.Single(target.Toc); + Assert.Equal("home", target.Toc[0].Id); + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void TryRestore_MissesWhenKeyChanges() + { + var dir = Path.Combine(Path.GetTempPath(), "netdocs-cache-" + Guid.NewGuid().ToString("N")); + try + { + var cache = RenderCache.Load(dir); + cache.Store(NewPage(), RenderCache.ComputeKey(NewPage(), "salt", "linkmap")); + cache.Save(); + + var reloaded = RenderCache.Load(dir); + var target = NewPage(); + var staleKey = RenderCache.ComputeKey(NewPage(), "different-salt", "linkmap"); + Assert.False(reloaded.TryRestore(target, staleKey)); + Assert.Equal(0, reloaded.Hits); + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } +} diff --git a/tests/Netdocs.Core.Tests/RssPluginTests.cs b/tests/Netdocs.Core.Tests/RssPluginTests.cs new file mode 100644 index 0000000..e1401c7 --- /dev/null +++ b/tests/Netdocs.Core.Tests/RssPluginTests.cs @@ -0,0 +1,163 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers RSS/Atom feed generation, per-post overrides, and image support. +public class RssPluginTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "netdocs-rss-" + Guid.NewGuid().ToString("N")); + + public RssPluginTests() => Directory.CreateDirectory(_root); + + public void Dispose() + { + try { Directory.Delete(_root, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private SiteContext Site(params BlogPost[] posts) + { + var site = new SiteContext + { + Config = new SiteConfig + { + ProjectRoot = _root, + SiteDir = "", + SiteName = "Test Site", + SiteDescription = "A test", + SiteUrl = "https://example.com/", + }, + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + site.State["blog_posts"] = posts.ToList(); + return site; + } + + private static BlogPost Post(string title, string url, DateTimeOffset date, + string excerpt = "Excerpt", string html = "

    Body

    ", + Dictionary? frontMatter = null, params string[] categories) => + new(new Page + { + SourcePath = "x", + RelativePath = url + ".md", + Url = url + "/", + Title = title, + HtmlContent = html, + FrontMatter = frontMatter ?? new(), + }, date, categories, excerpt); + + private async Task Run(IReadOnlyDictionary options, SiteContext site) + { + var plugin = new RssPlugin(); + plugin.Configure(new FakeContext(options)); + await plugin.OnBuildCompleteAsync(site, default); + return plugin; + } + + [Fact] + public async Task WritesRssFeedWithItemMetadata() + { + var site = Site(Post("Hello", "blog/hello", new DateTimeOffset(2024, 1, 2, 0, 0, 0, TimeSpan.Zero), + categories: new[] { "News" })); + await Run(new Dictionary(), site); + + var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml")); + Assert.Contains("Hello", xml); + Assert.Contains("https://example.com/blog/hello/", xml); + Assert.Contains("News", xml); + Assert.Contains("rel=\"self\"", xml); + } + + [Fact] + public async Task PerPostTitleOverride_WinsOverPageTitle() + { + var fm = new Dictionary { ["rss_title"] = "Feed Title" }; + var site = Site(Post("Page Title", "blog/p", DateTimeOffset.UtcNow, frontMatter: fm)); + await Run(new Dictionary(), site); + + var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml")); + Assert.Contains("Feed Title", xml); + } + + [Fact] + public async Task FrontMatterImage_EmittedAsEnclosure() + { + var fm = new Dictionary { ["image"] = "/img/hero.png" }; + var site = Site(Post("P", "blog/p", DateTimeOffset.UtcNow, frontMatter: fm)); + await Run(new Dictionary(), site); + + var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml")); + Assert.Contains("Hi

    ")); + await Run(new Dictionary(), site); + + var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml")); + Assert.Contains("https://example.com/blog/p/pic.jpg", xml); + } + + [Fact] + public async Task FullContent_EmitsContentEncoded() + { + var site = Site(Post("P", "blog/p", DateTimeOffset.UtcNow, html: "

    Full body

    ")); + await Run(new Dictionary { ["full_content"] = true }, site); + + var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml")); + Assert.Contains("encoded", xml); + Assert.Contains("Full body", xml); + } + + [Fact] + public async Task Atom_GeneratedWhenEnabled() + { + var site = Site(Post("Hello", "blog/hello", new DateTimeOffset(2024, 1, 2, 0, 0, 0, TimeSpan.Zero))); + await Run(new Dictionary { ["atom"] = true }, site); + + var path = Path.Combine(_root, "feed_atom_created.xml"); + Assert.True(File.Exists(path)); + var xml = File.ReadAllText(path); + Assert.Contains("http://www.w3.org/2005/Atom", xml); + Assert.Contains("", xml); + Assert.Contains("Hello", xml); + } + + [Fact] + public async Task Length_LimitsItems() + { + var now = DateTimeOffset.UtcNow; + var site = Site( + Post("A", "blog/a", now), + Post("B", "blog/b", now), + Post("C", "blog/c", now)); + await Run(new Dictionary { ["length"] = 2 }, site); + + var xml = File.ReadAllText(Path.Combine(_root, "feed_rss_created.xml")); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(xml, "").Count); + } + + private sealed class FakeContext(IReadOnlyDictionary options) : IPluginContext + { + public SiteConfig Config { get; } = new(); + public BuildOptions Options { get; } = new(); + public Microsoft.Extensions.Logging.ILogger Logger { get; } = NullLogger.Instance; + public Microsoft.Extensions.DependencyInjection.IServiceCollection Services { get; } + = new Microsoft.Extensions.DependencyInjection.ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options; + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } +} diff --git a/tests/Netdocs.Core.Tests/SearchPluginTests.cs b/tests/Netdocs.Core.Tests/SearchPluginTests.cs new file mode 100644 index 0000000..560be77 --- /dev/null +++ b/tests/Netdocs.Core.Tests/SearchPluginTests.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// +/// Verifies the search index emitter honors its (previously hard-coded) lunr config knobs: +/// lang (single or list), separator, and pipeline. +/// +public class SearchPluginTests +{ + private sealed class FakeContext(IReadOnlyDictionary options) : IPluginContext + { + public SiteConfig Config { get; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options; + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } + + private static JsonElement EmitConfig(IReadOnlyDictionary options) + { + var dir = Path.Combine(Path.GetTempPath(), "netdocs-search-" + Guid.NewGuid().ToString("N")); + try + { + var plugin = new SearchPlugin(); + plugin.Configure(new FakeContext(options)); + + var config = new SiteConfig { ProjectRoot = dir }; + var site = new SiteContext + { + Config = config, + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + site.Pages.Add(new Page + { + SourcePath = "", + RelativePath = "index.md", + Url = "", + Title = "Home", + HtmlContent = "

    Hello world.

    ", + PlainText = "Hello world.", + }); + + plugin.OnBuildCompleteAsync(site, CancellationToken.None).GetAwaiter().GetResult(); + var json = File.ReadAllText(Path.Combine(config.AbsoluteSiteDir, "search", "search_index.json")); + return JsonDocument.Parse(json).RootElement.GetProperty("config").Clone(); + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + + private static JsonElement EmitIndex(Page page) + { + var dir = Path.Combine(Path.GetTempPath(), "netdocs-search-" + Guid.NewGuid().ToString("N")); + try + { + var plugin = new SearchPlugin(); + plugin.Configure(new FakeContext(new Dictionary())); + + var config = new SiteConfig { ProjectRoot = dir }; + var site = new SiteContext + { + Config = config, + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + site.Pages.Add(page); + + plugin.OnBuildCompleteAsync(site, CancellationToken.None).GetAwaiter().GetResult(); + var json = File.ReadAllText(Path.Combine(config.AbsoluteSiteDir, "search", "search_index.json")); + return JsonDocument.Parse(json).RootElement.Clone(); + } + finally + { + if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true); + } + } + + [Fact] + public void PageLevelDoc_CarriesTextAfterH1_ForTeaser() + { + // Material shows the page-level doc's text as the teaser on each result's main line. + // The H1 is the page title, so the paragraph beneath it must land in the page-level doc, + // not be swallowed by an H1 "section" (which leaves the teaser empty). + var page = new Page + { + SourcePath = "", + RelativePath = "guide.md", + Url = "guide/", + Title = "Guide", + HtmlContent = "

    Guide

    Intro paragraph here.

    Details

    Section body.

    ", + PlainText = "Guide Intro paragraph here. Details Section body.", + }; + + var index = EmitIndex(page); + var docs = index.GetProperty("docs").EnumerateArray().ToList(); + + var pageDoc = docs.First(d => d.GetProperty("location").GetString() == "guide/"); + Assert.Contains("Intro paragraph here.", pageDoc.GetProperty("text").GetString()); + + // The H1 must not become its own anchored section. + Assert.DoesNotContain(docs, d => d.GetProperty("location").GetString() == "guide/#guide"); + // The H2 still produces a section doc. + Assert.Contains(docs, d => d.GetProperty("location").GetString() == "guide/#details"); + } + + [Fact] + public void SearchText_PreservesBlockHtml() + { + var page = new Page + { + SourcePath = "", + RelativePath = "index.md", + Url = "", + Title = "Home", + HtmlContent = "

    Home

    Hello world.

    ", + PlainText = "Home Hello world.", + }; + + var index = EmitIndex(page); + var pageDoc = index.GetProperty("docs").EnumerateArray() + .First(d => d.GetProperty("location").GetString() == ""); + + Assert.Contains("

    Hello world.

    ", pageDoc.GetProperty("text").GetString()); + } + + [Fact] + public void Defaults_MatchMaterialLunrConfig() + { + var cfg = EmitConfig(new Dictionary()); + Assert.Equal("en", cfg.GetProperty("lang")[0].GetString()); + Assert.Equal("[\\s\\-]+", cfg.GetProperty("separator").GetString()); + Assert.Equal( + new[] { "stemmer", "stopWordFilter", "trimmer" }, + cfg.GetProperty("pipeline").EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + [Fact] + public void Lang_AcceptsAList() + { + var cfg = EmitConfig(new Dictionary + { + ["lang"] = new List { "en", "de" }, + }); + Assert.Equal(new[] { "en", "de" }, cfg.GetProperty("lang").EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + [Fact] + public void SeparatorAndPipeline_AreConfigurable() + { + var cfg = EmitConfig(new Dictionary + { + ["separator"] = "[\\s]+", + ["pipeline"] = new List { "trimmer" }, + }); + Assert.Equal("[\\s]+", cfg.GetProperty("separator").GetString()); + Assert.Equal(new[] { "trimmer" }, cfg.GetProperty("pipeline").EnumerateArray().Select(e => e.GetString()).ToArray()); + } +} diff --git a/tests/Netdocs.Core.Tests/SlugTests.cs b/tests/Netdocs.Core.Tests/SlugTests.cs new file mode 100644 index 0000000..b999d5a --- /dev/null +++ b/tests/Netdocs.Core.Tests/SlugTests.cs @@ -0,0 +1,35 @@ +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +public class SlugTests +{ + [Theory] + [InlineData("Hello World", "hello-world")] + [InlineData("Multiple spaces", "multiple-spaces")] + [InlineData("under_score/slash", "under-score-slash")] + public void Make_Default_LowercasesAndHyphenates(string input, string expected) + => Assert.Equal(expected, Slug.Make(input)); + + [Fact] + public void Make_CustomSeparator_IsUsed() + => Assert.Equal("hello_world", Slug.Make("Hello World", "_")); + + [Fact] + public void Make_UpperCase_Config() + => Assert.Equal("HELLO-WORLD", Slug.Make("Hello World", new SlugifyConfig { Case = "upper" })); + + [Fact] + public void Make_PreserveCase_Config() + => Assert.Equal("Hello-World", Slug.Make("Hello World", new SlugifyConfig { Case = "none" })); + + [Fact] + public void Make_CustomSeparator_Config() + => Assert.Equal("hello.world", Slug.Make("Hello World", new SlugifyConfig { Separator = "." })); + + [Fact] + public void Make_AsciiOnly_DropsNonAscii() + => Assert.Equal("nihao", Slug.Make("niδ½ ε₯½hao", new SlugifyConfig { Ascii = true })); +} diff --git a/tests/Netdocs.Core.Tests/SnippetsPluginTests.cs b/tests/Netdocs.Core.Tests/SnippetsPluginTests.cs new file mode 100644 index 0000000..92299de --- /dev/null +++ b/tests/Netdocs.Core.Tests/SnippetsPluginTests.cs @@ -0,0 +1,146 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the pymdownx.snippets preprocessor: includes, named sections, auto_append. +public class SnippetsPluginTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "netdocs-snip-" + Guid.NewGuid().ToString("N")); + + public SnippetsPluginTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private static SiteContext Site() => new() + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + + private static readonly Page Page = new() { SourcePath = "x", RelativePath = "index.md", Url = "" }; + + private SnippetsPlugin PluginWith(params (string key, object? value)[] options) + { + var plugin = new SnippetsPlugin(); + var dict = new Dictionary(); + foreach (var (k, v) in options) dict[k] = v; + plugin.Configure(new FakeContext(dict) { Config = new SiteConfig { ProjectRoot = _dir } }); + return plugin; + } + + private void Write(string name, string content) => + File.WriteAllText(Path.Combine(_dir, name), content.Replace("\r\n", "\n")); + + [Fact] + public async Task IncludesWholeFile() + { + Write("notice.md", "Shared notice text."); + var plugin = PluginWith(("base_path", new object?[] { _dir })); + + var result = await plugin.ProcessAsync(Page, "--8<-- \"notice.md\"", Site(), default); + + Assert.Contains("Shared notice text.", result); + } + + [Fact] + public async Task ExtractsNamedSectionOnly() + { + Write("sample.py", "before\n# --8<-- [start:setup]\nimport os\n# --8<-- [end:setup]\nafter\n"); + var plugin = PluginWith(("base_path", new object?[] { _dir })); + + var result = await plugin.ProcessAsync(Page, "--8<-- \"sample.py:setup\"", Site(), default); + + Assert.Contains("import os", result); + Assert.DoesNotContain("before", result); + Assert.DoesNotContain("after", result); + Assert.DoesNotContain("--8<--", result); + } + + [Fact] + public async Task MissingSnippetEmitsComment() + { + var plugin = PluginWith(("base_path", new object?[] { _dir })); + + var result = await plugin.ProcessAsync(Page, "--8<-- \"nope.md\"", Site(), default); + + Assert.Contains("snippet not found", result); + } + + [Fact] + public async Task AutoAppendAddsFileToEveryPage() + { + Write("abbr.md", "*[HTML]: HyperText Markup Language"); + var plugin = PluginWith( + ("base_path", new object?[] { _dir }), + ("auto_append", new object?[] { "abbr.md" })); + + var result = await plugin.ProcessAsync(Page, "Body content.", Site(), default); + + Assert.Contains("Body content.", result); + Assert.Contains("HyperText Markup Language", result); + } + + [Fact] + public async Task InlineParameterizedIncludeSubstitutesPlaceholders() + { + Write("ebay.html", "${text}"); + var plugin = PluginWith(("base_path", new object?[] { _dir })); + + var input = "| --8<-- \"ebay.html\" text=\"Cool Part\" url=\"https://ebay.us/x\" | 1 | $5 |"; + var result = await plugin.ProcessAsync(Page, input, Site(), default); + + Assert.Contains("Cool Part", result); + // Stays inline within the table row (no injected newlines). + Assert.DoesNotContain("--8<--", result); + Assert.Contains("| 1 | $5 |", result); + } + + [Fact] + public async Task InlineParameterizedIncludeHtmlEscapesValues() + { + Write("box.html", "x"); + var plugin = PluginWith(("base_path", new object?[] { _dir })); + + var result = await plugin.ProcessAsync(Page, "--8<-- \"box.html\" label=\"A & B \"", Site(), default); + + Assert.Contains("A & B <c>", result); + Assert.DoesNotContain("A & B ", result); + } + + [Fact] + public async Task ResolvesFromDefaultRootSnippetsDirectory() + { + var snippetsDir = Path.Combine(_dir, "snippets"); + Directory.CreateDirectory(snippetsDir); + File.WriteAllText(Path.Combine(snippetsDir, "note.md"), "Default snippets dir works."); + // No base_path configured at all β€” should still find /snippets/note.md. + var plugin = PluginWith(); + + var result = await plugin.ProcessAsync(Page, "--8<-- \"note.md\"", Site(), default); + + Assert.Contains("Default snippets dir works.", result); + } + + private sealed class FakeContext(IReadOnlyDictionary options) : IPluginContext + { + public SiteConfig Config { get; init; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = options; + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } +} diff --git a/tests/Netdocs.Core.Tests/SocialIconTests.cs b/tests/Netdocs.Core.Tests/SocialIconTests.cs new file mode 100644 index 0000000..4149f1f --- /dev/null +++ b/tests/Netdocs.Core.Tests/SocialIconTests.cs @@ -0,0 +1,62 @@ +using Netdocs.Core.Templating; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the social_icon template helper: built-in brands and config-driven overrides. +public class SocialIconTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "netdocs-icon-" + Guid.NewGuid().ToString("N")); + + public SocialIconTests() + { + Directory.CreateDirectory(_dir); + File.WriteAllText(Path.Combine(_dir, "t.html"), "{{ social_icon icon }}"); + } + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private string Render(string icon, IDictionary? extra = null) + { + var engine = new TemplateEngine([_dir]); + var model = new Dictionary { ["icon"] = icon }; + if (extra is not null) model["extra"] = extra; + return engine.Render("t.html", model); + } + + [Theory] + [InlineData("fontawesome/brands/github")] + [InlineData("fontawesome/brands/x-twitter")] + [InlineData("fontawesome/brands/mastodon")] + [InlineData("fontawesome/brands/linkedin")] + [InlineData("fontawesome/brands/youtube")] + [InlineData("material/email")] + public void RendersKnownBrandsAsSvg(string icon) + { + var html = Render(icon); + Assert.Contains(" + { + ["social_icons"] = new Dictionary { ["bluesky"] = "M1 2 3 4" }, + }; + var html = Render("fontawesome/brands/bluesky", extra); + Assert.Contains("d=\"M1 2 3 4\"", html); + } +} diff --git a/tests/Netdocs.Core.Tests/TableReaderPluginTests.cs b/tests/Netdocs.Core.Tests/TableReaderPluginTests.cs new file mode 100644 index 0000000..e3e6cbf --- /dev/null +++ b/tests/Netdocs.Core.Tests/TableReaderPluginTests.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Netdocs.Plugins; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers mkdocs-table-reader: read_csv expansion and page-relative path resolution. +public class TableReaderPluginTests : IDisposable +{ + private readonly string _dir = Path.Combine(Path.GetTempPath(), "netdocs-tbl-" + Guid.NewGuid().ToString("N")); + + public TableReaderPluginTests() => Directory.CreateDirectory(_dir); + + public void Dispose() + { + try { Directory.Delete(_dir, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + private static SiteContext Site() => new() + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + + private static TableReaderPlugin Configured(string projectRoot) + { + var plugin = new TableReaderPlugin(); + plugin.Configure(new FakeContext { Config = new SiteConfig { ProjectRoot = projectRoot } }); + return plugin; + } + + [Fact] + public async Task ReadCsv_ResolvesRelativeToPageDirectory() + { + // CSV sits next to the post, referenced with a page-relative path (mkdocs-table-reader behavior). + var assetsDir = Path.Combine(_dir, "blog", "posts", "assets"); + Directory.CreateDirectory(assetsDir); + File.WriteAllText(Path.Combine(assetsDir, "data.csv"), "Make,Model\nChelsio,T520-CR\n"); + + var page = new Page + { + SourcePath = Path.Combine(_dir, "blog", "posts", "post.md"), + RelativePath = "blog/posts/post.md", + }; + + var result = await Configured(_dir) + .ProcessAsync(page, "{{ read_csv(\"assets/data.csv\") }}", Site(), default); + + Assert.Contains("| Make | Model |", result); + Assert.Contains("| Chelsio | T520-CR |", result); + Assert.DoesNotContain("file not found", result); + } + + [Fact] + public async Task ReadCsv_MissingFile_EmitsComment() + { + var page = new Page { SourcePath = Path.Combine(_dir, "post.md"), RelativePath = "post.md" }; + + var result = await Configured(_dir) + .ProcessAsync(page, "{{ read_csv(\"nope.csv\") }}", Site(), default); + + Assert.Contains("table-reader: file not found: nope.csv", result); + } + + private sealed class FakeContext : IPluginContext + { + public SiteConfig Config { get; init; } = new(); + public BuildOptions Options { get; } = new(); + public ILogger Logger { get; } = NullLogger.Instance; + public IServiceCollection Services { get; } = new ServiceCollection(); + public IReadOnlyDictionary PluginOptions { get; } = new Dictionary(); + public void AddStylesheet(string href) { } + public void AddScript(string src, bool defer = true) { } + public void AddInlineScript(string javascript) { } + public void AddAsset(string sourcePath, string destRelative) { } + } +} diff --git a/tests/Netdocs.Core.Tests/ThemeBootstrapperTests.cs b/tests/Netdocs.Core.Tests/ThemeBootstrapperTests.cs new file mode 100644 index 0000000..9c7d63d --- /dev/null +++ b/tests/Netdocs.Core.Tests/ThemeBootstrapperTests.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using Netdocs.Core; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// +/// Guards that the Material theme is embedded in Netdocs.Core so a single-file +/// (PublishSingleFile) executable stays self-contained. Regression test for the +/// release binary failing with "Template 'main.html' not found" because the loose +/// theme folder is not shipped alongside the standalone executable. +/// +public class ThemeBootstrapperTests +{ + private static string[] ThemeResources() => typeof(BuildEngine).Assembly + .GetManifestResourceNames() + .Select(n => n.Replace('\\', '/')) + .Where(n => n.StartsWith("theme/", StringComparison.Ordinal)) + .ToArray(); + + [Fact] + public void CoreAssemblyEmbedsThemeTemplates() + { + var resources = ThemeResources(); + + Assert.Contains("theme/templates/main.html", resources); + } + + [Fact] + public void CoreAssemblyEmbedsThemeAssets() + { + var resources = ThemeResources(); + + Assert.Contains(resources, r => r.StartsWith("theme/assets/", StringComparison.Ordinal)); + } + + [Fact] + public void ExtractsEmbeddedThemeWhenLooseCopyMissing() + { + // Simulate a single-file publish: point ThemePaths at a directory with no theme, + // then confirm the bootstrapper materializes the embedded copy on disk. + var probe = Path.Combine(Path.GetTempPath(), "netdocs-theme-test-" + Guid.NewGuid().ToString("N")); + var resources = ThemeResources(); + Assert.NotEmpty(resources); + + // Extract manually mirroring ThemeBootstrapper so the assertion does not depend on + // mutating process-global ThemePaths state shared with other tests. + var assembly = typeof(BuildEngine).Assembly; + foreach (var name in assembly.GetManifestResourceNames()) + { + var norm = name.Replace('\\', '/'); + if (!norm.StartsWith("theme/", StringComparison.Ordinal)) continue; + var dest = Path.Combine(probe, norm.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + using var stream = assembly.GetManifestResourceStream(name)!; + using var file = File.Create(dest); + stream.CopyTo(file); + } + + try + { + Assert.True(File.Exists(Path.Combine(probe, "theme", "templates", "main.html"))); + } + finally + { + try { Directory.Delete(probe, recursive: true); } catch (IOException) { } + } + } +} diff --git a/tests/Netdocs.Core.Tests/UserDefinedMacroTests.cs b/tests/Netdocs.Core.Tests/UserDefinedMacroTests.cs new file mode 100644 index 0000000..cec7ff2 --- /dev/null +++ b/tests/Netdocs.Core.Tests/UserDefinedMacroTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Netdocs.Abstractions; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the UserDefinedMacro base class that lets authors register macros without regex. +public class UserDefinedMacroTests +{ + private static SiteContext Site() => new() + { + Config = new SiteConfig(), + Options = new BuildOptions(), + LoggerFactory = NullLoggerFactory.Instance, + }; + + private static Page Page(Dictionary? frontMatter = null) => + new() { SourcePath = "x", RelativePath = "index.md", Url = "", FrontMatter = frontMatter ?? new() }; + + private sealed class SampleMacros : UserDefinedMacro + { + public override string Name => "sample"; + protected override void DefineMacros(IMacroBuilder macros) => macros + .Add("year", () => "2024") + .Add("greet", args => $"Hello {(args.Count > 0 ? args[0] : "")}") + .Add("page", inv => inv.Page.RelativePath) + .Variable("product", "Netdocs"); + } + + [Fact] + public async Task ExpandsFunctionAndVariableMacros() + { + var result = await new SampleMacros().ProcessAsync( + Page(), "Β© {{ year() }} {{ product }} β€” {{ greet(\"World\") }} on {{ page() }}", Site(), default); + + Assert.Contains("Β© 2024 Netdocs", result); + Assert.Contains("Hello World", result); + Assert.Contains("on index.md", result); + } + + [Fact] + public async Task LeavesUnknownTokensUntouched() + { + const string md = "{{ unknown() }} and {{ mystery }}"; + var result = await new SampleMacros().ProcessAsync(Page(), md, Site(), default); + Assert.Equal(md, result); + } + + [Fact] + public async Task RespectsIgnoreMacrosFrontMatter() + { + const string md = "{{ year() }}"; + var page = Page(new Dictionary { ["ignore_macros"] = true }); + var result = await new SampleMacros().ProcessAsync(page, md, Site(), default); + Assert.Equal(md, result); + } + + [Fact] + public async Task RespectsRenderMacrosFalse() + { + const string md = "{{ year() }}"; + var page = Page(new Dictionary { ["render_macros"] = false }); + var result = await new SampleMacros().ProcessAsync(page, md, Site(), default); + Assert.Equal(md, result); + } +} diff --git a/tests/Netdocs.Core.Tests/WebpConverterTests.cs b/tests/Netdocs.Core.Tests/WebpConverterTests.cs new file mode 100644 index 0000000..4b5919b --- /dev/null +++ b/tests/Netdocs.Core.Tests/WebpConverterTests.cs @@ -0,0 +1,59 @@ +using Netdocs.Core.Optimization; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers webp encoding and the on-disk conversion cache. +public class WebpConverterTests : IDisposable +{ + // 2x2 solid PNG. + private const string TinyPngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAAE0lEQVR4nGPhEpH7zwAELAxQAAASHAFEWc1phgAAAABJRU5ErkJggg=="; + + private readonly string _cache = Path.Combine(Path.GetTempPath(), "netdocs-webp-" + Guid.NewGuid().ToString("N")); + + public void Dispose() + { + try { Directory.Delete(_cache, recursive: true); } catch (IOException) { } + GC.SuppressFinalize(this); + } + + [Fact] + public async Task ConvertsPngToWebpAndCaches() + { + var png = Convert.FromBase64String(TinyPngBase64); + var converter = new WebpConverter(_cache, 80); + + var webp = await converter.ConvertAsync(png, CancellationToken.None); + + Assert.NotNull(webp); + // RIFF....WEBP magic bytes. + Assert.Equal((byte)'R', webp![0]); + Assert.Equal((byte)'I', webp[1]); + Assert.Equal((byte)'F', webp[2]); + Assert.Equal((byte)'F', webp[3]); + Assert.True(Directory.Exists(_cache)); + Assert.NotEmpty(Directory.GetFiles(_cache, "*.webp")); + } + + [Fact] + public async Task SecondCallUsesCache() + { + var png = Convert.FromBase64String(TinyPngBase64); + var converter = new WebpConverter(_cache, 80); + + var first = await converter.ConvertAsync(png, CancellationToken.None); + var second = await converter.ConvertAsync(png, CancellationToken.None); + + Assert.NotNull(first); + Assert.Equal(first, second); + } + + [Fact] + public async Task InvalidImageReturnsNull() + { + var converter = new WebpConverter(_cache, 80); + var result = await converter.ConvertAsync("not an image"u8.ToArray(), CancellationToken.None); + Assert.Null(result); + } +} diff --git a/tests/Netdocs.Core.Tests/WebpHtmlRewriterTests.cs b/tests/Netdocs.Core.Tests/WebpHtmlRewriterTests.cs new file mode 100644 index 0000000..7aa8798 --- /dev/null +++ b/tests/Netdocs.Core.Tests/WebpHtmlRewriterTests.cs @@ -0,0 +1,59 @@ +using Netdocs.Core.Optimization; +using Xunit; + +namespace Netdocs.Core.Tests; + +/// Covers the non-destructive img -> picture webp rewrite. +public class WebpHtmlRewriterTests +{ + [Fact] + public void WrapsLocalPngInPicture() + { + var html = """

    a

    """; + var result = WebpHtmlRewriter.Rewrite(html); + + Assert.Contains("", result); + Assert.Contains("""""", result); + Assert.Contains("""a""", result); + Assert.Contains("", result); + } + + [Theory] + [InlineData("photo.jpg", "photo.webp")] + [InlineData("a/b/c.jpeg", "a/b/c.webp")] + public void RewritesJpgAndJpeg(string src, string expectedWebp) + { + var result = WebpHtmlRewriter.Rewrite($""); + Assert.Contains($"srcset=\"{expectedWebp}\"", result); + } + + [Theory] + [InlineData("https://cdn.example.com/x.png")] + [InlineData("//cdn.example.com/x.png")] + [InlineData("data:image/png;base64,AAAA")] + [InlineData("diagram.svg")] + [InlineData("already.webp")] + public void LeavesNonLocalOrNonRasterUntouched(string src) + { + var html = $""; + var result = WebpHtmlRewriter.Rewrite(html); + Assert.DoesNotContain("", result); + Assert.Equal(html, result); + } + + [Fact] + public void NoImgTag_ReturnsInputUnchanged() + { + var html = "

    no images here

    "; + Assert.Equal(html, WebpHtmlRewriter.Rewrite(html)); + } + + [Fact] + public void ConvertibleDetection() + { + Assert.True(WebpConverter.IsConvertible("a.png")); + Assert.True(WebpConverter.IsConvertible("a.JPG")); + Assert.False(WebpConverter.IsConvertible("a.svg")); + Assert.False(WebpConverter.IsConvertible("a.webp")); + } +} diff --git a/tests/Netdocs.Core.Tests/golden/admonition.html b/tests/Netdocs.Core.Tests/golden/admonition.html new file mode 100644 index 0000000..7089fe8 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/admonition.html @@ -0,0 +1,8 @@ +
    +

    A note

    +

    This is the body of the admonition.

    +
    +
    +Collapsible +

    Hidden until expanded.

    +
    diff --git a/tests/Netdocs.Core.Tests/golden/admonition.md b/tests/Netdocs.Core.Tests/golden/admonition.md new file mode 100644 index 0000000..c2fc138 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/admonition.md @@ -0,0 +1,5 @@ +!!! note "A note" + This is the body of the admonition. + +??? warning "Collapsible" + Hidden until expanded. diff --git a/tests/Netdocs.Core.Tests/golden/code.html b/tests/Netdocs.Core.Tests/golden/code.html new file mode 100644 index 0000000..ac92567 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/code.html @@ -0,0 +1,4 @@ +
    demo.py
    print("hello")
    +print("world")
    +
    +

    Inline highlight: range(10) and plain code.

    diff --git a/tests/Netdocs.Core.Tests/golden/code.md b/tests/Netdocs.Core.Tests/golden/code.md new file mode 100644 index 0000000..b07f3ba --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/code.md @@ -0,0 +1,6 @@ +```python linenums="3" hl_lines="1" title="demo.py" +print("hello") +print("world") +``` + +Inline highlight: `#!python range(10)` and plain `code`. diff --git a/tests/Netdocs.Core.Tests/golden/critic.html b/tests/Netdocs.Core.Tests/golden/critic.html new file mode 100644 index 0000000..8d86023 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/critic.html @@ -0,0 +1,2 @@ +

    Text with an insertion, a deletion, a highlight, +a comment, and a typofix.

    diff --git a/tests/Netdocs.Core.Tests/golden/critic.md b/tests/Netdocs.Core.Tests/golden/critic.md new file mode 100644 index 0000000..9af2465 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/critic.md @@ -0,0 +1,2 @@ +Text with {++an insertion++}, {--a deletion--}, {==a highlight==}, +{>>a comment<<}, and a {~~typo~>fix~~}. diff --git a/tests/Netdocs.Core.Tests/golden/emoji.html b/tests/Netdocs.Core.Tests/golden/emoji.html new file mode 100644 index 0000000..ed47950 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/emoji.html @@ -0,0 +1 @@ +

    I ❀️ Netdocs πŸš€.

    diff --git a/tests/Netdocs.Core.Tests/golden/emoji.md b/tests/Netdocs.Core.Tests/golden/emoji.md new file mode 100644 index 0000000..83f4807 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/emoji.md @@ -0,0 +1 @@ +I :heart: Netdocs :rocket:. diff --git a/tests/Netdocs.Core.Tests/golden/keys.html b/tests/Netdocs.Core.Tests/golden/keys.html new file mode 100644 index 0000000..1d1ac2d --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/keys.html @@ -0,0 +1 @@ +

    Press Ctrl+Alt+Del to reboot. Save with Cmd+S.

    diff --git a/tests/Netdocs.Core.Tests/golden/keys.md b/tests/Netdocs.Core.Tests/golden/keys.md new file mode 100644 index 0000000..7b658e9 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/keys.md @@ -0,0 +1 @@ +Press ++ctrl+alt+del++ to reboot. Save with ++cmd+s++. diff --git a/tests/Netdocs.Core.Tests/golden/tables-footnotes.html b/tests/Netdocs.Core.Tests/golden/tables-footnotes.html new file mode 100644 index 0000000..41db966 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/tables-footnotes.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + +
    FeatureStatus
    TablesYes
    Footnotes1Yes
    +
    +
    +
      +
    1. +

      A footnote definition.

      +
    2. +
    +
    diff --git a/tests/Netdocs.Core.Tests/golden/tables-footnotes.md b/tests/Netdocs.Core.Tests/golden/tables-footnotes.md new file mode 100644 index 0000000..76ea5b9 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/tables-footnotes.md @@ -0,0 +1,6 @@ +| Feature | Status | +| --- | --- | +| Tables | Yes | +| Footnotes[^1] | Yes | + +[^1]: A footnote definition. diff --git a/tests/Netdocs.Core.Tests/golden/tabs.html b/tests/Netdocs.Core.Tests/golden/tabs.html new file mode 100644 index 0000000..d388107 --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/tabs.html @@ -0,0 +1,16 @@ +
    + + +
    + + +
    +
    +
    +

    Use apt.

    +
    +
    +

    Use winget.

    +
    +
    +
    diff --git a/tests/Netdocs.Core.Tests/golden/tabs.md b/tests/Netdocs.Core.Tests/golden/tabs.md new file mode 100644 index 0000000..6a39ccf --- /dev/null +++ b/tests/Netdocs.Core.Tests/golden/tabs.md @@ -0,0 +1,5 @@ +=== "Linux" + Use `apt`. + +=== "Windows" + Use `winget`.