From a09c1439a815fe717080f6dd4a79449f64ecb620 Mon Sep 17 00:00:00 2001 From: squidfunk Date: Sat, 1 Nov 2025 13:46:51 +0100 Subject: [PATCH] workspace:chore - initial commit --- .editorconfig | 42 + .gitattributes | 25 + .gitcliff.toml | 68 + .githooks/commit-msg | 26 + .gitignore | 51 + .ruff.toml | 25 + .rustfmt.toml | 26 + .vscode/settings.json | 11 + Cargo.lock | 1123 +++++++++++++++++ Cargo.toml | 75 ++ LICENSE.md | 22 + README.md | 25 + crates/zensical-serve/Cargo.toml | 51 + crates/zensical-serve/src/handler.rs | 189 +++ crates/zensical-serve/src/handler/convert.rs | 62 + crates/zensical-serve/src/handler/error.rs | 50 + crates/zensical-serve/src/handler/matcher.rs | 173 +++ .../src/handler/matcher/error.rs | 54 + .../src/handler/matcher/params.rs | 191 +++ .../src/handler/matcher/route.rs | 182 +++ .../src/handler/matcher/route/error.rs | 56 + crates/zensical-serve/src/handler/scope.rs | 108 ++ crates/zensical-serve/src/handler/stack.rs | 296 +++++ .../src/handler/stack/builder.rs | 240 ++++ .../src/handler/stack/factory.rs | 71 ++ crates/zensical-serve/src/http.rs | 34 + crates/zensical-serve/src/http/component.rs | 36 + .../src/http/component/error.rs | 52 + .../src/http/component/header.rs | 390 ++++++ .../src/http/component/method.rs | 165 +++ .../src/http/component/status.rs | 263 ++++ crates/zensical-serve/src/http/request.rs | 329 +++++ .../zensical-serve/src/http/request/error.rs | 81 ++ .../src/http/request/headers.rs | 236 ++++ crates/zensical-serve/src/http/request/uri.rs | 176 +++ .../src/http/request/uri/encoding.rs | 60 + .../src/http/request/uri/query.rs | 355 ++++++ .../src/http/request/uri/query/encoding.rs | 64 + crates/zensical-serve/src/http/response.rs | 251 ++++ .../src/http/response/convert.rs | 65 + .../zensical-serve/src/http/response/error.rs | 48 + .../zensical-serve/src/http/response/ext.rs | 138 ++ .../src/http/response/headers.rs | 258 ++++ crates/zensical-serve/src/lib.rs | 40 + crates/zensical-serve/src/middleware.rs | 136 ++ .../zensical-serve/src/middleware/convert.rs | 70 + crates/zensical-serve/src/middleware/files.rs | 142 +++ crates/zensical-serve/src/middleware/path.rs | 32 + .../src/middleware/path/base.rs | 91 ++ .../src/middleware/path/normalize.rs | 209 +++ .../src/middleware/websocket.rs | 210 +++ crates/zensical-serve/src/router.rs | 489 +++++++ crates/zensical-serve/src/router/action.rs | 115 ++ crates/zensical-serve/src/router/routes.rs | 106 ++ .../src/router/routes/builder.rs | 117 ++ crates/zensical-serve/src/server.rs | 285 +++++ crates/zensical-serve/src/server/builder.rs | 159 +++ .../zensical-serve/src/server/connection.rs | 285 +++++ crates/zensical-serve/src/server/error.rs | 75 ++ crates/zensical-serve/src/server/poller.rs | 168 +++ crates/zensical-watch/Cargo.toml | 53 + crates/zensical-watch/src/agent.rs | 213 ++++ crates/zensical-watch/src/agent/error.rs | 118 ++ crates/zensical-watch/src/agent/event.rs | 132 ++ crates/zensical-watch/src/agent/handler.rs | 166 +++ .../src/agent/handler/builder.rs | 118 ++ .../zensical-watch/src/agent/handler/error.rs | 81 ++ crates/zensical-watch/src/agent/manager.rs | 808 ++++++++++++ crates/zensical-watch/src/agent/monitor.rs | 582 +++++++++ crates/zensical-watch/src/lib.rs | 35 + crates/zensical/Cargo.toml | 76 ++ crates/zensical/src/config.rs | 226 ++++ crates/zensical/src/config/error.rs | 69 + crates/zensical/src/config/extra.rs | 47 + crates/zensical/src/config/mdx.rs | 55 + crates/zensical/src/config/plugins.rs | 92 ++ crates/zensical/src/config/project.rs | 89 ++ crates/zensical/src/config/theme.rs | 182 +++ crates/zensical/src/lib.rs | 230 ++++ crates/zensical/src/server.rs | 99 ++ crates/zensical/src/server/client.rs | 127 ++ crates/zensical/src/structure.rs | 34 + crates/zensical/src/structure/dynamic.rs | 96 ++ .../zensical/src/structure/dynamic/float.rs | 71 ++ crates/zensical/src/structure/markdown.rs | 139 ++ crates/zensical/src/structure/nav.rs | 416 ++++++ crates/zensical/src/structure/nav/item.rs | 55 + crates/zensical/src/structure/nav/iter.rs | 86 ++ crates/zensical/src/structure/nav/meta.rs | 61 + crates/zensical/src/structure/page.rs | 247 ++++ crates/zensical/src/structure/search.rs | 140 ++ crates/zensical/src/structure/search/item.rs | 51 + crates/zensical/src/structure/tag.rs | 40 + crates/zensical/src/structure/toc.rs | 49 + crates/zensical/src/template.rs | 123 ++ crates/zensical/src/template/filter.rs | 116 ++ crates/zensical/src/template/loader.rs | 81 ++ crates/zensical/src/watcher.rs | 218 ++++ crates/zensical/src/workflow.rs | 299 +++++ crates/zensical/src/workflow/cached.rs | 103 ++ pyproject.toml | 85 ++ python/zensical/.gitignore | 1 + python/zensical/__init__.py | 28 + .../bootstrap/.github/workflows/docs.yml | 22 + python/zensical/bootstrap/docs/index.md | 172 +++ python/zensical/bootstrap/docs/markdown.md | 49 + python/zensical/bootstrap/zensical.toml | 312 +++++ python/zensical/config.py | 770 +++++++++++ python/zensical/extensions/__init__.py | 22 + python/zensical/extensions/emoji.py | 119 ++ python/zensical/extensions/links.py | 126 ++ python/zensical/extensions/preview.py | 201 +++ python/zensical/extensions/search.py | 373 ++++++ .../zensical/extensions/utilities/__init__.py | 22 + .../zensical/extensions/utilities/filter.py | 87 ++ python/zensical/main.py | 172 +++ python/zensical/markdown.py | 139 ++ python/zensical/zensical.pyi | 40 + scripts/commit.py | 243 ++++ scripts/dev.py | 78 ++ scripts/prepare.py | 71 ++ uv.lock | 261 ++++ 122 files changed, 18388 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitcliff.toml create mode 100755 .githooks/commit-msg create mode 100644 .gitignore create mode 100644 .ruff.toml create mode 100644 .rustfmt.toml create mode 100644 .vscode/settings.json create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 crates/zensical-serve/Cargo.toml create mode 100644 crates/zensical-serve/src/handler.rs create mode 100644 crates/zensical-serve/src/handler/convert.rs create mode 100644 crates/zensical-serve/src/handler/error.rs create mode 100644 crates/zensical-serve/src/handler/matcher.rs create mode 100644 crates/zensical-serve/src/handler/matcher/error.rs create mode 100644 crates/zensical-serve/src/handler/matcher/params.rs create mode 100644 crates/zensical-serve/src/handler/matcher/route.rs create mode 100644 crates/zensical-serve/src/handler/matcher/route/error.rs create mode 100644 crates/zensical-serve/src/handler/scope.rs create mode 100644 crates/zensical-serve/src/handler/stack.rs create mode 100644 crates/zensical-serve/src/handler/stack/builder.rs create mode 100644 crates/zensical-serve/src/handler/stack/factory.rs create mode 100644 crates/zensical-serve/src/http.rs create mode 100644 crates/zensical-serve/src/http/component.rs create mode 100644 crates/zensical-serve/src/http/component/error.rs create mode 100644 crates/zensical-serve/src/http/component/header.rs create mode 100644 crates/zensical-serve/src/http/component/method.rs create mode 100644 crates/zensical-serve/src/http/component/status.rs create mode 100644 crates/zensical-serve/src/http/request.rs create mode 100644 crates/zensical-serve/src/http/request/error.rs create mode 100644 crates/zensical-serve/src/http/request/headers.rs create mode 100644 crates/zensical-serve/src/http/request/uri.rs create mode 100644 crates/zensical-serve/src/http/request/uri/encoding.rs create mode 100644 crates/zensical-serve/src/http/request/uri/query.rs create mode 100644 crates/zensical-serve/src/http/request/uri/query/encoding.rs create mode 100644 crates/zensical-serve/src/http/response.rs create mode 100644 crates/zensical-serve/src/http/response/convert.rs create mode 100644 crates/zensical-serve/src/http/response/error.rs create mode 100644 crates/zensical-serve/src/http/response/ext.rs create mode 100644 crates/zensical-serve/src/http/response/headers.rs create mode 100644 crates/zensical-serve/src/lib.rs create mode 100644 crates/zensical-serve/src/middleware.rs create mode 100644 crates/zensical-serve/src/middleware/convert.rs create mode 100644 crates/zensical-serve/src/middleware/files.rs create mode 100644 crates/zensical-serve/src/middleware/path.rs create mode 100644 crates/zensical-serve/src/middleware/path/base.rs create mode 100644 crates/zensical-serve/src/middleware/path/normalize.rs create mode 100644 crates/zensical-serve/src/middleware/websocket.rs create mode 100644 crates/zensical-serve/src/router.rs create mode 100644 crates/zensical-serve/src/router/action.rs create mode 100644 crates/zensical-serve/src/router/routes.rs create mode 100644 crates/zensical-serve/src/router/routes/builder.rs create mode 100644 crates/zensical-serve/src/server.rs create mode 100644 crates/zensical-serve/src/server/builder.rs create mode 100644 crates/zensical-serve/src/server/connection.rs create mode 100644 crates/zensical-serve/src/server/error.rs create mode 100644 crates/zensical-serve/src/server/poller.rs create mode 100644 crates/zensical-watch/Cargo.toml create mode 100644 crates/zensical-watch/src/agent.rs create mode 100644 crates/zensical-watch/src/agent/error.rs create mode 100644 crates/zensical-watch/src/agent/event.rs create mode 100644 crates/zensical-watch/src/agent/handler.rs create mode 100644 crates/zensical-watch/src/agent/handler/builder.rs create mode 100644 crates/zensical-watch/src/agent/handler/error.rs create mode 100644 crates/zensical-watch/src/agent/manager.rs create mode 100644 crates/zensical-watch/src/agent/monitor.rs create mode 100644 crates/zensical-watch/src/lib.rs create mode 100644 crates/zensical/Cargo.toml create mode 100644 crates/zensical/src/config.rs create mode 100644 crates/zensical/src/config/error.rs create mode 100644 crates/zensical/src/config/extra.rs create mode 100644 crates/zensical/src/config/mdx.rs create mode 100644 crates/zensical/src/config/plugins.rs create mode 100644 crates/zensical/src/config/project.rs create mode 100644 crates/zensical/src/config/theme.rs create mode 100644 crates/zensical/src/lib.rs create mode 100644 crates/zensical/src/server.rs create mode 100644 crates/zensical/src/server/client.rs create mode 100644 crates/zensical/src/structure.rs create mode 100644 crates/zensical/src/structure/dynamic.rs create mode 100644 crates/zensical/src/structure/dynamic/float.rs create mode 100644 crates/zensical/src/structure/markdown.rs create mode 100644 crates/zensical/src/structure/nav.rs create mode 100644 crates/zensical/src/structure/nav/item.rs create mode 100644 crates/zensical/src/structure/nav/iter.rs create mode 100644 crates/zensical/src/structure/nav/meta.rs create mode 100644 crates/zensical/src/structure/page.rs create mode 100644 crates/zensical/src/structure/search.rs create mode 100644 crates/zensical/src/structure/search/item.rs create mode 100644 crates/zensical/src/structure/tag.rs create mode 100644 crates/zensical/src/structure/toc.rs create mode 100644 crates/zensical/src/template.rs create mode 100644 crates/zensical/src/template/filter.rs create mode 100644 crates/zensical/src/template/loader.rs create mode 100644 crates/zensical/src/watcher.rs create mode 100644 crates/zensical/src/workflow.rs create mode 100644 crates/zensical/src/workflow/cached.rs create mode 100644 pyproject.toml create mode 100644 python/zensical/.gitignore create mode 100644 python/zensical/__init__.py create mode 100644 python/zensical/bootstrap/.github/workflows/docs.yml create mode 100644 python/zensical/bootstrap/docs/index.md create mode 100644 python/zensical/bootstrap/docs/markdown.md create mode 100644 python/zensical/bootstrap/zensical.toml create mode 100644 python/zensical/config.py create mode 100644 python/zensical/extensions/__init__.py create mode 100644 python/zensical/extensions/emoji.py create mode 100644 python/zensical/extensions/links.py create mode 100644 python/zensical/extensions/preview.py create mode 100644 python/zensical/extensions/search.py create mode 100644 python/zensical/extensions/utilities/__init__.py create mode 100644 python/zensical/extensions/utilities/filter.py create mode 100644 python/zensical/main.py create mode 100644 python/zensical/markdown.py create mode 100644 python/zensical/zensical.pyi create mode 100755 scripts/commit.py create mode 100755 scripts/dev.py create mode 100755 scripts/prepare.py create mode 100644 uv.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0783a67 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,42 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# Top-level config +root = true + +# Default +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Rust +[*.rs] +indent_size = 4 + +# Python +[*.py] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d2f7be1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,25 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +.githooks/* linguist-vendored +scripts/* linguist-vendored diff --git a/.gitcliff.toml b/.gitcliff.toml new file mode 100644 index 0000000..88ad596 --- /dev/null +++ b/.gitcliff.toml @@ -0,0 +1,68 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +[git] +conventional_commits = true +filter_unconventional = true +commit_preprocessors = [ + # Transform our own commit message format into conventional commits format, + # so we can parse it with git-cliff. Our format is optimized for readability + # in git logs, and better caters to monorepos, since the package name is at + # the start of the commit message. + { pattern = '^([^:]+):([^\\s]+) - (.+)$', replace = "${2}(${1}): ${3}" }, +] +commit_parsers = [ + { message = "\\(workspace\\)", skip = true }, + { message = "^feature", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^refactor", group = "Refactoring" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance Improvements" }, + { message = "^test", group = "Testing" }, + { message = "^build", group = "Build System", skip = true }, + { message = "^style", group = "Styling", skip = true }, + { message = "^chore", group = "Chores", skip = true }, + { message = "^release", group = "Release", skip = true }, + { message = ".*", skip = true }, +] + +[changelog] +trim = true +render_always = true +body = """ +{% if version %} + ## [{{ version | trim_start_matches(pat="v") }}] - \ + {{ timestamp | date(format="%Y-%m-%d") }} +{% else %} + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim }} + {% for commit in commits %} + - {{ commit.id | truncate(length=8, end="") }}\ + {% if commit.scope %} **{{ commit.scope }}**{% endif %}\ + {% if commit.breaking %} [**breaking**]{% endif %} - \ + {{ commit.message }}\ + {% endfor %} +{% endfor %} +""" diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..f3027af --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +exec python3 "$(git rev-parse --show-toplevel)/scripts/commit.py" "$@" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79cdaab --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# ----------------------------------------------------------------------------- +# Rust, Python +# ----------------------------------------------------------------------------- + +# Environments +.venv + +# Artifacts +/target +__pycache__ +*.dll +*.so + +# Tracing +trace.json + +# ----------------------------------------------------------------------------- +# General +# ----------------------------------------------------------------------------- + +# Never ignore .gitkeep files +!**/.gitkeep + +# macOS internals +.DS_Store + +# Temporary files +tmp diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..116a3ac --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,25 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +line-length = 80 +fix = true diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..2408bda --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,26 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +fn_params_layout = "compressed" +max_width = 80 +struct_lit_width = 30 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..dab9476 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "rust-analyzer.check.command": "clippy", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true + }, + "[rust]": { + "editor.defaultFormatter": "rust-lang.rust-analyzer", + "editor.formatOnSave": true + } +} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..655f790 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1123 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "borrow-or-share" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fa326467c5d528c03e479661320269e7716d6b7d5d49bafd30890ce0c725696" + +[[package]] +name = "bstr" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "file-id" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "fluent-uri" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5" +dependencies = [ + "borrow-or-share", + "ref-cast", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.10.0", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchit" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea5f97102eb9e54ab99fb70bb175589073f554bdadfb74d9bd656482ea73e2a" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minijinja" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9f264d75233323f4b7d2f03aefe8a990690cdebfbfe26ea86bcbaec5e9ac990" +dependencies = [ + "memo-map", + "percent-encoding", + "self_cell", + "serde", + "serde_json", +] + +[[package]] +name = "minijinja-contrib" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "182ba1438db4679ddfa03792c183bdc2b9ce26b58e7d41a749e59b06497cf136" +dependencies = [ + "minijinja", + "serde", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.10.0", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" +dependencies = [ + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" +dependencies = [ + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "self_cell" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-chrome" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0a738ed5d6450a9fb96e86a23ad808de2b727fd1394585da5cdd6788ffe724" +dependencies = [ + "serde_json", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "log", + "rand", + "thiserror", + "utf-8", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zensical" +version = "0.0.0" +dependencies = [ + "ahash", + "crossbeam", + "fluent-uri", + "minijinja", + "minijinja-contrib", + "mio", + "pyo3", + "serde", + "serde_json", + "thiserror", + "tracing", + "tracing-chrome", + "tracing-subscriber", + "zensical-serve", + "zensical-watch", + "zrx", +] + +[[package]] +name = "zensical-serve" +version = "0.0.0" +dependencies = [ + "base64", + "crossbeam", + "httparse", + "httpdate", + "matchit", + "mio", + "percent-encoding", + "sha1_smol", + "slab", + "thiserror", + "tungstenite", +] + +[[package]] +name = "zensical-watch" +version = "0.0.0" +dependencies = [ + "ahash", + "crossbeam", + "file-id", + "notify", + "pyo3", + "thiserror", + "tracing", + "walkdir", + "zrx", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zrx" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0560eca9c0d0ebba809e4b3ea94465b685945103ea309cb4c33edbdbdae5a3de" +dependencies = [ + "zrx-diagnostic", + "zrx-executor", + "zrx-graph", + "zrx-id", + "zrx-path", + "zrx-scheduler", + "zrx-store", + "zrx-stream", +] + +[[package]] +name = "zrx-diagnostic" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cf90c723631486819bb50d9871072595f4f17b2f6c596534d9cbcf4b6c2ae54" + +[[package]] +name = "zrx-executor" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c0ace038e234e564aa9a352419b7eb8ea64996351affbfb8f2b1a593c3b24d4" +dependencies = [ + "crossbeam", + "thiserror", +] + +[[package]] +name = "zrx-graph" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8deb42a334884f7d30d4ceba77db865fe22ae0db9a2678e90d1449527c935ad" +dependencies = [ + "ahash", + "thiserror", +] + +[[package]] +name = "zrx-id" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53ca8d5b09df525295bcfc45a5fd35e16e9f47c16a07283a071d4518dc76befa" +dependencies = [ + "globset", + "percent-encoding", + "thiserror", + "zrx-path", +] + +[[package]] +name = "zrx-path" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d578267e852d4f325ce124ecbffe0530d9f9013d58a3cfacec75daf04ac40d0b" + +[[package]] +name = "zrx-scheduler" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "977abb2e19cbe3768d29eb7613f2fec66592ae44ccb673585b72b8ad1f17dda1" +dependencies = [ + "ahash", + "crossbeam", + "slab", + "thiserror", + "tracing", + "zrx-diagnostic", + "zrx-executor", + "zrx-graph", + "zrx-id", + "zrx-store", +] + +[[package]] +name = "zrx-store" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf66a14c3590ebf559c2beac93f28b2bf15192bd644a37e70b641d36cfec6e6d" +dependencies = [ + "ahash", + "litemap", + "slab", +] + +[[package]] +name = "zrx-stream" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c6087ba332dd45fc7a6f364e99f30d28405374ffabe7ac6787fc402ba86473" +dependencies = [ + "ahash", + "thiserror", + "tracing", + "zrx-graph", + "zrx-id", + "zrx-scheduler", + "zrx-store", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..42af916 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,75 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +edition = "2024" +rust-version = "1.86" +homepage = "https://github.com/zensical/zensical" +documentation = "https://github.com/zensical/zensical" +repository = "https://github.com/zensical/zensical" +authors = ["Zensical "] +license = "MIT" +publish = false + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } + +[workspace.dependencies] +zensical = { version = "0.0.0", path = "crates/zensical" } +zensical-serve = { version = "0.0.0", path = "crates/zensical-serve" } +zensical-watch = { version = "0.0.0", path = "crates/zensical-watch" } + +ahash = "0.8.12" +base64 = "0.22.1" +crossbeam = "0.8.4" +file-id = "0.2.3" +fluent-uri = "0.3.2" +httparse = "1.10.1" +httpdate = "1.0.3" +indicatif = "0.18.1" +matchit = "0.9.0" +mio = "1.1.0" +minijinja = "2.12.0" +minijinja-contrib = "2.12.0" +notify = "8.2.0" +percent-encoding = "2.3.2" +sha1_smol = "1.0.1" +slab = "0.4.9" +serde = "1.0.228" +serde_json = "1.0.145" +thiserror = "2.0.17" +tungstenite = { version = "0.28.0", default-features = false } +tracing = { version = "0.1.41" } +tracing-chrome = "0.7.2" +tracing-subscriber = "0.3.20" +walkdir = "2.5.0" +zrx = "0.0.3" + +[workspace.dependencies.pyo3] +version = "0.27.1" +features = ["extension-module", "abi3-py310"] diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..99945ab --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +Copyright (c) Zensical LLC + +- SPDX-License-Identifier: MIT +- Third-party contributions licensed under CLA + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c0a513 --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Zensical + +tbd. + +## License + +Copyright (c) Zensical LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/crates/zensical-serve/Cargo.toml b/crates/zensical-serve/Cargo.toml new file mode 100644 index 0000000..c1a2776 --- /dev/null +++ b/crates/zensical-serve/Cargo.toml @@ -0,0 +1,51 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +[package] +name = "zensical-serve" +version = "0.0.0" +description = "HTTP server" +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +base64.workspace = true +crossbeam.workspace = true +httparse.workspace = true +httpdate.workspace = true +matchit.workspace = true +mio = { workspace = true, features = ["net", "os-poll"] } +percent-encoding.workspace = true +sha1_smol.workspace = true +slab.workspace = true +thiserror.workspace = true +tungstenite.workspace = true diff --git a/crates/zensical-serve/src/handler.rs b/crates/zensical-serve/src/handler.rs new file mode 100644 index 0000000..f6b2d17 --- /dev/null +++ b/crates/zensical-serve/src/handler.rs @@ -0,0 +1,189 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Handler. + +use std::fmt; + +use super::http::response::ResponseExt; +use super::http::{Method, Request, Response, Status}; + +mod convert; +mod error; +pub mod matcher; +mod scope; +pub mod stack; + +pub use convert::TryIntoHandler; +pub use error::{Error, Result}; +pub use matcher::Matcher; +pub use scope::Scope; +pub use stack::Stack; + +// ---------------------------------------------------------------------------- +// Traits +// ---------------------------------------------------------------------------- + +/// Handler. +/// +/// Handlers represent the executable form of a request processing chain. Unlike +/// middlewares, which define composable layers of request processing, handlers +/// package those layers into a single unit of execution, always returning a +/// [`Response`] for every given [`Request`]. +/// +/// Note that a handler must be at the end of every request processing chain, +/// definitely answering the request with no next middleware to defer to. +pub trait Handler { + /// Handles the given request. + /// + /// This method is invoked with a request and is required to return a + /// response. It must be infallible and should not panic. + /// + /// # Examples + /// + /// This example shows how to implement a teapot handler responding with + /// "418 I'm a Teapot" status code when the client tries to `GET /coffee`, + /// while answering all other requests with "404 Not Found". Note that for + /// routing, using a [`Router`][] is usually a better choice. + /// + /// [`Router`]: crate::router::Router + /// + /// ``` + /// use zensical_serve::handler::{Handler, Teapot}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// + /// // Create request + /// let req = Request::new() + /// .method(Method::Get) + /// .uri("/coffee"); + /// + /// // Handle request with handler + /// let res = Teapot.handle(req); + /// assert_eq!(res.status, Status::ImATeapot); + /// ``` + fn handle(&self, req: Request) -> Response; +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Fallback handler. +/// +/// +/// This handler always returns "404 Not Found", and is ideal as a default +/// fallback handler for middlewares like [`Stack`][] and [`Router`][]. +/// +/// [`Stack`]: crate::handler::Stack +/// [`Router`]: crate::router::Router +pub struct NotFound; + +/// Teapot handler. +/// +/// This handler responds with "418 I'm a Teapot" status code when the client +/// tries to `GET /coffee`, answering all other requests with "404 Not Found". +/// Besides that, it doesn't do anything, but it's a good choice to quickly +/// test starting a server or to use in examples. +pub struct Teapot; + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Handler for NotFound { + /// Handles the given request. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::{Handler, NotFound}; + /// use zensical_serve::http::{Method, Request, Status}; + /// + /// // Create request + /// let req = Request::new() + /// .method(Method::Get) + /// .uri("/"); + /// + /// // Handle request with handler + /// let res = NotFound.handle(req); + /// assert_eq!(res.status, Status::NotFound); + /// ``` + #[inline] + fn handle(&self, _req: Request) -> Response { + Response::from_status(Status::NotFound) + } +} + +impl Handler for Teapot { + /// Handles the given request. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::{Handler, Teapot}; + /// use zensical_serve::http::{Method, Request, Status}; + /// + /// // Create request + /// let req = Request::new() + /// .method(Method::Get) + /// .uri("/coffee"); + /// + /// // Handle request with handler + /// let res = Teapot.handle(req); + /// assert_eq!(res.status, Status::ImATeapot); + /// ``` + #[inline] + fn handle(&self, req: Request) -> Response { + if req.method == Method::Get && req.uri.path == "/coffee" { + Response::from_status(Status::ImATeapot) + } else { + Response::from_status(Status::NotFound) + } + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Debug for Box { + /// Formats the handler for debugging. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Box") + } +} + +// ---------------------------------------------------------------------------- +// Blanket implementations +// ---------------------------------------------------------------------------- + +impl Handler for F +where + F: Fn(Request) -> R, + R: Into, +{ + #[inline] + fn handle(&self, req: Request) -> Response { + self(req).into() + } +} diff --git a/crates/zensical-serve/src/handler/convert.rs b/crates/zensical-serve/src/handler/convert.rs new file mode 100644 index 0000000..6847f5c --- /dev/null +++ b/crates/zensical-serve/src/handler/convert.rs @@ -0,0 +1,62 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Handler conversions. + +use super::error::Result; +use super::Handler; + +// ---------------------------------------------------------------------------- +// Traits +// ---------------------------------------------------------------------------- + +/// Attempt conversion into [`Handler`]. +pub trait TryIntoHandler { + /// Output type of conversion. + type Output: Handler; + + /// Attempts to convert into a handler. + /// + /// # Errors + /// + /// In case conversion fails, an error should be returned. + fn try_into_handler(self) -> Result; +} + +// ---------------------------------------------------------------------------- +// Blanket implementations +// ---------------------------------------------------------------------------- + +impl TryIntoHandler for H +where + H: Handler, +{ + type Output = Self; + + #[inline] + fn try_into_handler(self) -> Result { + Ok(self) + } +} diff --git a/crates/zensical-serve/src/handler/error.rs b/crates/zensical-serve/src/handler/error.rs new file mode 100644 index 0000000..05fc2f1 --- /dev/null +++ b/crates/zensical-serve/src/handler/error.rs @@ -0,0 +1,50 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Handler error. + +use std::result; +use thiserror::Error; + +use super::matcher; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Handler error. +#[derive(Debug, Error)] +pub enum Error { + /// Matcher error. + #[error(transparent)] + Matcher(#[from] matcher::Error), +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// Handler result. +pub type Result = result::Result; diff --git a/crates/zensical-serve/src/handler/matcher.rs b/crates/zensical-serve/src/handler/matcher.rs new file mode 100644 index 0000000..90e357f --- /dev/null +++ b/crates/zensical-serve/src/handler/matcher.rs @@ -0,0 +1,173 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Matcher. + +use std::str::FromStr; + +mod error; +mod params; +mod route; + +pub use error::{Error, Result}; +pub use params::Params; +pub use route::Route; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Matcher. +/// +/// This is a thin wrapper around the [`Router`][] data type of the [`matchit`] +/// crate to shield against unforeseen changes in the crate's implementation. +/// +/// [`Router`]: matchit::Router +#[derive(Debug, Default)] +pub struct Matcher { + /// Matcher implementation. + inner: matchit::Router, +} + +/// Match. +#[derive(Debug)] +pub struct Match<'k, 'v, T = ()> { + /// Match parameters. + pub params: Params<'k, 'v>, + /// Associated data. + pub data: T, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Matcher { + /// Creates a matcher. + /// + /// ``` + /// use zensical_serve::handler::Matcher; + /// + /// // Create matcher + /// let matcher = Matcher::<()>::new(); + /// ``` + #[must_use] + pub fn new() -> Self { + Self { inner: matchit::Router::new() } + } + + /// Adds a route to the matcher. + /// + /// # Errors + /// + /// This method returns [`Error::Insert`], if the route could not be added + /// to the matcher, including the reason for the failure. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use std::str::FromStr; + /// use zensical_serve::handler::matcher::Route; + /// use zensical_serve::handler::Matcher; + /// + /// // Create matcher and add route + /// let mut matcher = Matcher::new(); + /// matcher.add(Route::from_str("/coffee/{kind}")?, ())?; + /// # Ok(()) + /// # } + /// ``` + #[allow(clippy::needless_pass_by_value)] + pub fn add(&mut self, route: Route, value: T) -> Result { + self.inner + .insert(route.to_string(), value) + .map_err(Into::into) + } + + /// Attempts to resolve and match the given path. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use std::str::FromStr; + /// use zensical_serve::handler::matcher::Route; + /// use zensical_serve::handler::Matcher; + /// + /// // Create matcher and add route + /// let mut matcher = Matcher::new(); + /// matcher.add(Route::from_str("/coffee/{kind}")?, ())?; + /// + /// // Resolve route from path + /// let route = matcher.resolve("/coffee/vietnamese"); + /// assert!(route.is_some()); + /// # Ok(()) + /// # } + /// ``` + pub fn resolve<'v>(&self, path: &'v str) -> Option> { + self.inner.at(path).ok().map(|route| Match { + params: Params::new(route.params), + data: route.value, + }) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl FromStr for Matcher { + type Err = Error; + + /// Attempts to create a matcher from a string. + /// + /// This method is a convenient shortcut for creating a [`Matcher`] from a + /// single [`Route`], which can be used in middlewares for matching routes. + /// + /// # Errors + /// + /// In case conversion fails, an [`Error`] is returned. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::Matcher; + /// + /// // Create matcher from string + /// let matcher: Matcher = "/coffee/{kind}".parse()?; + /// # Ok(()) + /// # } + /// ``` + fn from_str(value: &str) -> Result { + let mut matcher = Self::new(); + matcher // fmt + .add(Route::from_str(value)?, ()) + .map(|()| matcher) + } +} diff --git a/crates/zensical-serve/src/handler/matcher/error.rs b/crates/zensical-serve/src/handler/matcher/error.rs new file mode 100644 index 0000000..a585b37 --- /dev/null +++ b/crates/zensical-serve/src/handler/matcher/error.rs @@ -0,0 +1,54 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Matcher error. + +use std::result; +use thiserror::Error; + +use super::route; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Matcher error. +#[derive(Debug, Error)] +pub enum Error { + /// Route error. + #[error(transparent)] + Route(#[from] route::Error), + + /// Route insert error. + #[error(transparent)] + Insert(#[from] matchit::InsertError), +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// Matcher result. +pub type Result = result::Result; diff --git a/crates/zensical-serve/src/handler/matcher/params.rs b/crates/zensical-serve/src/handler/matcher/params.rs new file mode 100644 index 0000000..08fc4df --- /dev/null +++ b/crates/zensical-serve/src/handler/matcher/params.rs @@ -0,0 +1,191 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Matcher parameters. + +use matchit::ParamsIter; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Matcher parameters. +/// +/// This is a thin wrapper around the [`Params`][] data type of the [`matchit`] +/// crate to shield against unforeseen changes in the crate's implementation. +/// +/// Note that [`Params`] should be used without lifetime parameters, as it's +/// designed to be used in a context where lifetimes are irrelevant, i.e., in +/// the signature of an [`Action`][], ensuring that third-party integrations +/// don't break when the implementation changes, albeit unlikely. +/// +/// [`Action`]: crate::router::Action +/// [`Params`]: matchit::Params +#[derive(Clone, Debug)] +pub struct Params<'k, 'v> { + /// Parameter list implementation. + inner: matchit::Params<'k, 'v>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'k, 'v> Params<'k, 'v> { + /// Creates matcher parameters. + /// + /// This method is used by the [`Matcher`][] to create matcher parameters + /// from the [`matchit::Params`] as returned by [`matchit`]. + /// + /// [`Matcher`]: crate::handler::Matcher + #[inline] + pub(crate) fn new(inner: matchit::Params<'k, 'v>) -> Self { + Params { inner } + } +} + +impl<'k, 'v> Params<'k, 'v> { + /// Returns the value for the given key. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response, Status}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .get("/coffee/{kind}", |req: Request, params: Params| { + /// if let Some(kind) = params.get("kind") { + /// Response::default() + /// } else { + /// Response::new().status(Status::BadRequest) + /// } + /// }); + /// ``` + #[inline] + pub fn get(&self, key: K) -> Option<&'v str> + where + K: AsRef, + { + self.inner.get(key) + } + + /// Returns whether the parameter is contained. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response, Status}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .get("/coffee/{kind}", |req: Request, params: Params| { + /// if params.contains("kind") { + /// Response::default() + /// } else { + /// Response::new().status(Status::BadRequest) + /// } + /// }); + /// ``` + #[inline] + #[must_use] + pub fn contains(&self, key: K) -> bool + where + K: AsRef, + { + self.inner.get(key).is_some() + } + + /// Returns an iterator over all parameters. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .get("/coffee/{kind}", |req: Request, params: Params| { + /// for (key, value) in params.iter() { + /// println!("{key}: {value}"); + /// } + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn iter(&self) -> ParamsIter<'_, 'k, 'v> { + self.inner.iter() + } +} + +#[allow(clippy::must_use_candidate)] +impl Params<'_, '_> { + /// Returns the number of parameters. + #[inline] + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Returns whether there are any parameters. + #[inline] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl<'a, 'k, 'v> IntoIterator for &'a Params<'k, 'v> { + type Item = (&'k str, &'v str); + type IntoIter = ParamsIter<'a, 'k, 'v>; + + /// Creates an iterator over all parameters. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .get("/coffee/{kind}", |req: Request, params: Params| { + /// for (key, value) in ¶ms { + /// println!("{key}: {value}"); + /// } + /// Response::default() + /// }); + /// ``` + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} diff --git a/crates/zensical-serve/src/handler/matcher/route.rs b/crates/zensical-serve/src/handler/matcher/route.rs new file mode 100644 index 0000000..bd4fe59 --- /dev/null +++ b/crates/zensical-serve/src/handler/matcher/route.rs @@ -0,0 +1,182 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Matcher route. + +use std::fmt; +use std::str::FromStr; + +mod error; + +pub use error::{Error, Result}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Matcher route. +/// +/// Routes are just non-empty strings that have been confirmed to start with `/` +/// and not end with `/`, which makes joining them significantly easier. Routes +/// might contain parameters, which are denoted by `{...}` brackets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Route { + /// Route path. + path: String, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Route { + /// Appends the given route to the route. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use std::str::FromStr; + /// use zensical_serve::handler::matcher::Route; + /// + /// // Create route + /// let route = Route::from_str("/coffee")?; + /// + /// // Append another route + /// let route = route.append("/{kind}".parse()?); + /// assert_eq!(route.to_string(), "/coffee/{kind}"); + /// # Ok(()) + /// # } + /// ``` + #[must_use] + pub fn append(&self, route: Self) -> Self { + if self.path == "/" { + route + } else if route.path == "/" { + self.clone() + } else { + // Compute the size of the new route path + let capacity = self.path.len() + route.path.len(); + let mut path = String::with_capacity(capacity); + + // Concatenate the two route paths + path.push_str(self.path.as_str()); + path.push_str(route.path.as_str()); + Self { path } + } + } +} + +#[allow(clippy::must_use_candidate)] +impl Route { + /// Returns the string representation. + #[inline] + pub fn as_str(&self) -> &str { + self.path.as_str() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl FromStr for Route { + type Err = Error; + + /// Attempts to create a route from a string. + /// + /// # Errors + /// + /// In case conversion fails, an [`Error`] is returned. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::matcher::Route; + /// + /// // Create route from string + /// let route: Route = "/coffee/{kind}".parse()?; + /// # Ok(()) + /// # } + /// ``` + fn from_str(value: &str) -> Result { + if value.is_empty() { + return Err(Error::Empty); + } + + // Ensure route starts with `/` + if !value.starts_with('/') { + return Err(Error::Relative(value.to_string())); + } + + // Ensure route doesn't end with `/` + if value.len() > 1 && value.ends_with('/') { + return Err(Error::Trailing(value.to_string())); + } + + // No errors occurred + Ok(Self { path: value.to_string() }) + } +} + +// ---------------------------------------------------------------------------- + +impl AsRef for Route { + /// Returns the string representation. + fn as_ref(&self) -> &str { + self.path.as_str() + } +} + +// ---------------------------------------------------------------------------- + +impl Default for Route { + /// Creates a default route. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::matcher::Route; + /// + /// // Create route + /// let route = Route::default(); + /// assert_eq!(route.as_str(), "/"); + /// ``` + fn default() -> Self { + Self { path: String::from("/") } + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Route { + /// Formats the route for display. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(&self.path) + } +} diff --git a/crates/zensical-serve/src/handler/matcher/route/error.rs b/crates/zensical-serve/src/handler/matcher/route/error.rs new file mode 100644 index 0000000..9a40d61 --- /dev/null +++ b/crates/zensical-serve/src/handler/matcher/route/error.rs @@ -0,0 +1,56 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Route error. + +use std::result; +use thiserror::Error; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Route error. +#[derive(Debug, Error)] +pub enum Error { + /// Route must start with '/'. + #[error("route must start with '/': {0}")] + Relative(String), + + /// Route must not end with '/'. + #[error("route must not end with '/': {0}")] + Trailing(String), + + /// Route must not be empty. + #[error("route must not be empty")] + Empty, +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// Route result. +pub type Result = result::Result; diff --git a/crates/zensical-serve/src/handler/scope.rs b/crates/zensical-serve/src/handler/scope.rs new file mode 100644 index 0000000..8dbf709 --- /dev/null +++ b/crates/zensical-serve/src/handler/scope.rs @@ -0,0 +1,108 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Scope. + +use super::matcher::Route; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Scope. +#[derive(Clone, Debug, Default)] +pub struct Scope { + // Base path for routes, optional. + pub route: Option, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Scope { + /// Creates a scope. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::Scope; + /// + /// // Create scope + /// let scope = Scope::new(); + /// ``` + #[must_use] + pub fn new() -> Self { + Self { route: None } + } + + /// Joins the scope with another scope. + #[must_use] + pub(crate) fn join(&self, scope: S) -> Self + where + S: Into, + { + let scope = scope.into(); + + // If both scopes define a route, append the route of the given scope + // to the route of the current scope. Otherwise, select the route. + let route = match (self.route.as_ref(), scope.route) { + (Some(head), Some(tail)) => Some(head.append(tail)), + (Some(head), None) => Some(head.clone()), + (None, Some(tail)) => Some(tail), + (None, None) => None, + }; + + // Return scope + Scope { route } + } +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl From for Scope { + /// Creates a scope from a route. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use std::str::FromStr; + /// use zensical_serve::handler::matcher::Route; + /// use zensical_serve::handler::Scope; + /// + /// // Create scope from route + /// let route = Route::from_str("/coffee/{kind}")?; + /// let scope = Scope::from(route); + /// # Ok(()) + /// # } + /// ``` + fn from(route: Route) -> Self { + Scope { route: Some(route) } + } +} diff --git a/crates/zensical-serve/src/handler/stack.rs b/crates/zensical-serve/src/handler/stack.rs new file mode 100644 index 0000000..f356c2b --- /dev/null +++ b/crates/zensical-serve/src/handler/stack.rs @@ -0,0 +1,296 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Stack. + +use crate::handler::{Handler, NotFound}; +use crate::http::{Request, Response}; +use crate::middleware::Middleware; + +use super::matcher::Matcher; + +mod builder; +mod factory; + +pub use builder::Builder; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Stack. +/// +/// Stacks allow to compose and unify multiple middlewares into one, passing the +/// request from one middleware to the next, until the last one is reached. Each +/// middleware can modify the request and/or response, short-circuit processing, +/// or even return a response directly. This allows for creating complex request +/// processing pipelines, where each middleware can handle a specific aspect, +/// of the pipeline, e.g., serving of static files, caching, etc. +/// +/// Any implementor of [`TryIntoMiddleware`][] can be added to the stack, which +/// includes [`Stack`] itself. This allows to create a tree of middlewares, as +/// well as middlewares scoped to certain paths using a [`Router`][], which can +/// contain further middlewares. +/// +/// It's middlewares all the way down. +/// +/// [`Router`]: crate::router::Router +/// [`TryIntoMiddleware`]: crate::middleware::TryIntoMiddleware +/// +/// # Examples +/// +/// ``` +/// # use std::error::Error; +/// # fn main() -> Result<(), Box> { +/// use zensical_serve::handler::{Handler, Stack, TryIntoHandler}; +/// use zensical_serve::http::{Method, Request, Response, Status}; +/// use zensical_serve::middleware::Middleware; +/// +/// // Create stack with middleware +/// let stack = Stack::new() +/// .with(|req: Request, next: &dyn Handler| { +/// if req.method == Method::Get && req.uri.path == "/coffee" { +/// Response::new().status(Status::ImATeapot) +/// } else { +/// next.handle(req) +/// } +/// }) +/// .try_into_handler()?; +/// +/// // Create request +/// let req = Request::new() +/// .method(Method::Get) +/// .uri("/coffee"); +/// +/// // Handle request with stack +/// let res = stack.handle(req); +/// assert_eq!(res.status, Status::ImATeapot); +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Default)] +pub struct Stack { + /// Middlewares. + middlewares: Vec>, + /// Matcher, optional. + /// + /// When a stack is added to a [`Router`][], the matcher ensures execution + /// only happens if the router's base path matches the request path as a + /// prefix. Stacks created outside of routers don't have an associated + /// matcher, and thus match any request passed to [`Stack::process`]. + /// + /// [`Router`]: crate::router::Router + matcher: Option, +} + +/// Stack handler. +/// +/// The stack handler keeps track of all middlewares that haven't been invoked +/// yet, i.e., are next in line to be called, and a reference to the handler +/// which should be invoked, when no middleware is left. The handler is passed +/// to [`Stack::process`], which is the implementation of [`Middleware`]. +struct StackHandler<'a> { + /// Remaining middlewares. + middlewares: &'a [Box], + /// Next handler. + next: &'a dyn Handler, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Stack { + /// Creates a stack. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::Stack; + /// + /// // Create stack + /// let stack = Stack::new(); + /// ``` + #[allow(clippy::new_ret_no_self)] + #[must_use] + pub fn new() -> Builder { + // Note that we deliberately return a builder here, and not a stack. + // While it would be idiomatic to call this method `builder` then, we + // chose to use `new` for consistency with routers and possibly other + // implementors that convert into stacks. + Builder::new() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Middleware for Stack { + /// Processes the given request. + /// + /// This method starts with the first middleware, and passes the request + /// from one middleware to the next. If no middleware is left, the handler + /// is invoked. Note that middlewares can also pass the request to the next + /// middleware and modify the returned response. + /// + /// In case the stack is used as part of a [`Router`][], prior to invoking + /// the first middleware, we check if the router's base path matches the + /// request path as a prefix. If it doesn't, the request is passed to the + /// next handler immediately. + /// + /// [`Router`]: crate::router::Router + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::{Handler, NotFound, Scope, Stack}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// use zensical_serve::middleware::{Middleware, TryIntoMiddleware}; + /// + /// // Create scope + /// let scope = Scope::default(); + /// + /// // Create stack with middleware + /// let stack = Stack::new() + /// .with(|req: Request, next: &dyn Handler| { + /// if req.method == Method::Get && req.uri.path == "/coffee" { + /// Response::new().status(Status::ImATeapot) + /// } else { + /// next.handle(req) + /// } + /// }) + /// .try_into_middleware(&scope)?; + /// + /// // Create request + /// let req = Request::new() + /// .method(Method::Get) + /// .uri("/coffee"); + /// + /// // Handle request with stack + /// let res = stack.process(req, &NotFound); + /// assert_eq!(res.status, Status::ImATeapot); + /// # Ok(()) + /// # } + /// ``` + fn process(&self, req: Request, next: &dyn Handler) -> Response { + if let Some(matcher) = &self.matcher { + let path = req.uri.path.trim_end_matches('/'); + + // Forward to next handler if path doesn't match + if matcher.resolve(path).is_none() { + return next.handle(req); + } + } + + // Create stack handler + let handler = StackHandler { + middlewares: &self.middlewares, + next, + }; + + // Handle request + handler.handle(req) + } +} + +// ---------------------------------------------------------------------------- + +impl Handler for Stack { + /// Handles the given request, passing it through the entire stack. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::{Handler, Stack, TryIntoHandler}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// + /// // Create stack with middleware + /// let stack = Stack::new() + /// .with(|req: Request, next: &dyn Handler| { + /// if req.method == Method::Get && req.uri.path == "/coffee" { + /// Response::new().status(Status::ImATeapot) + /// } else { + /// next.handle(req) + /// } + /// }) + /// .try_into_handler()?; + /// + /// // Create request + /// let req = Request::new() + /// .method(Method::Get) + /// .uri("/coffee"); + /// + /// // Handle request with stack + /// let res = stack.handle(req); + /// assert_eq!(res.status, Status::ImATeapot); + /// # Ok(()) + /// # } + /// ``` + #[inline] + fn handle(&self, req: Request) -> Response { + self.process(req, &NotFound) + } +} + +impl Handler for StackHandler<'_> { + /// Handles the given request. + /// + /// This method is called by the stack to process the request. It checks + /// if there are any middlewares left, and if so, it removes the first one, + /// creates a new stack handler with the remaining middlewares, and invokes + /// it. If no middlewares are left, the next handler is invoked. + fn handle(&self, req: Request) -> Response { + match self.middlewares { + [] => self.next.handle(req), + [middleware, middlewares @ ..] => { + let next = StackHandler { middlewares, next: self.next }; + middleware.process(req, &next) + } + } + } +} + +// ---------------------------------------------------------------------------- + +impl FromIterator> for Stack { + /// Creates a stack from an iterator. + /// + /// Note that this is primarily intended for internal use, as stacks are + /// usually created through method chaining via [`Builder::with`]. + fn from_iter(iter: T) -> Self + where + T: IntoIterator>, + { + Self { + middlewares: Vec::from_iter(iter), + matcher: None, + } + } +} diff --git a/crates/zensical-serve/src/handler/stack/builder.rs b/crates/zensical-serve/src/handler/stack/builder.rs new file mode 100644 index 0000000..f5fe38c --- /dev/null +++ b/crates/zensical-serve/src/handler/stack/builder.rs @@ -0,0 +1,240 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Stack builder. + +use std::str::FromStr; + +use crate::handler::matcher::{Matcher, Route}; +use crate::handler::{Error, Result, Scope, TryIntoHandler}; +use crate::middleware::{Middleware, TryIntoMiddleware}; + +use super::factory::Factory; +use super::Stack; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Stack builder. +#[derive(Debug)] +pub struct Builder { + /// Middleware factories. + middlewares: Vec>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Builder { + /// Creates a stack builder. + /// + /// Note that the canonical way to create a [`Stack`] is to invoke the + /// [`Stack::new`] method, which creates an instance of [`Builder`]. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::stack::Builder; + /// + /// // Create stack builder + /// let builder = Builder::new(); + /// ``` + #[allow(clippy::new_without_default)] + #[must_use] + pub fn new() -> Self { + Self { middlewares: Vec::new() } + } + + /// Extends the stack with the given middleware. + /// + /// Anything that can be converted into a [`Middleware`] can be added to + /// the stack, including middlewares, routers, stacks and closures. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::{Handler, Stack}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// + /// // Create stack with middleware + /// let stack = Stack::new() + /// .with(|req: Request, next: &dyn Handler| { + /// if req.method == Method::Get && req.uri.path == "/coffee" { + /// Response::new().status(Status::ImATeapot) + /// } else { + /// next.handle(req) + /// } + /// }); + /// ``` + #[inline] + #[must_use] + pub fn with(mut self, middleware: T) -> Self + where + T: TryIntoMiddleware, + { + self.add(middleware); + self + } + + /// Adds a middleware to the stack. + /// + /// Note that [`Builder::with`] is the recommended way to compose stacks + /// from middlewares. This method is primarily needed by the [`Router`][]. + /// + /// [`Router`]: crate::router::Router + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::{Handler, Stack}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// + /// // Create stack and add middleware + /// let mut stack = Stack::new(); + /// stack.add(|req: Request, next: &dyn Handler| { + /// if req.method == Method::Get && req.uri.path == "/coffee" { + /// Response::new().status(Status::ImATeapot) + /// } else { + /// next.handle(req) + /// } + /// }); + /// ``` + pub fn add(&mut self, middleware: T) + where + T: TryIntoMiddleware, + { + self.middlewares.push(Box::new(|scope: &Scope| { + middleware + .try_into_middleware(scope) + .map(|middleware| Box::new(middleware) as Box) + })); + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl TryIntoMiddleware for Builder { + type Output = Stack; + + /// Attempts to convert the stack into a middleware. + /// + /// # Errors + /// + /// In case conversion fails, an [`Error`] is returned. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::{Handler, Scope, Stack}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// use zensical_serve::middleware::TryIntoMiddleware; + /// + /// // Create scope + /// let scope = Scope::default(); + /// + /// // Create stack with middleware + /// let stack = Stack::new() + /// .with(|req: Request, next: &dyn Handler| { + /// if req.method == Method::Get && req.uri.path == "/coffee" { + /// Response::new().status(Status::ImATeapot) + /// } else { + /// next.handle(req) + /// } + /// }) + /// .try_into_middleware(&scope)?; + /// # Ok(()) + /// # } + /// ``` + fn try_into_middleware(self, scope: &Scope) -> Result { + let route = scope.route.as_ref(); + + // If the stack is part of a router, we create a matcher that checks if + // the router's base path matches the request path as a prefix + let matcher = route + .map(|base| -> Result<_> { + let mut matcher = Matcher::new(); + let rest = Route::from_str("/{*rest}") + .map_err(|err| Error::Matcher(err.into()))?; + + // Middlewares do not receive path parameters, which is why we + // just use a wildcard to implement prefix matching on paths + matcher + .add(base.append(rest), ()) + .map_err(Into::into) + .map(|()| matcher) + }) + .transpose()?; + + // Create and collect middlewares into a stack + let iter = self.middlewares.into_iter().map(|f| f(scope)); + iter.collect::>() + .map(|middlewares| Stack { middlewares, matcher }) + } +} + +impl TryIntoHandler for Builder { + type Output = Stack; + + /// Attempts to convert the stack into a handler. + /// + /// This method is equivalent to calling [`Stack::try_into_middleware`] + /// with [`Scope::default`], scoping all middlewares to `/`. + /// + /// # Errors + /// + /// In case conversion fails, an [`Error`] is returned. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::{Handler, Stack, TryIntoHandler}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// + /// // Create stack with middleware + /// let stack = Stack::new() + /// .with(|req: Request, next: &dyn Handler| { + /// if req.method == Method::Get && req.uri.path == "/coffee" { + /// Response::new().status(Status::ImATeapot) + /// } else { + /// next.handle(req) + /// } + /// }) + /// .try_into_handler()?; + /// # Ok(()) + /// # } + /// ``` + fn try_into_handler(self) -> Result { + let scope = Scope::default(); + self.try_into_middleware(&scope) + } +} diff --git a/crates/zensical-serve/src/handler/stack/factory.rs b/crates/zensical-serve/src/handler/stack/factory.rs new file mode 100644 index 0000000..3d67c5b --- /dev/null +++ b/crates/zensical-serve/src/handler/stack/factory.rs @@ -0,0 +1,71 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Factory. + +use std::fmt; + +use crate::handler::{Result, Scope}; +use crate::middleware::Middleware; + +// ---------------------------------------------------------------------------- +// Traits +// ---------------------------------------------------------------------------- + +/// Factory. +/// +/// While stacks and middlewares are naturally composed bottom-up due to the +/// builder-like nature of their interface, factories allow us to turn this on +/// its head, and create middlewares top-down, which ensures that the [`Scope`] +/// can efficiently and correctly propagate through nested stacks. +/// +/// Factories are like type-erased implementations of [`TryIntoMiddleware`][], +/// and return boxed implementations of [`Middleware`][], and are essentially +/// and implementation detail of the [`Stack`][], Implementors should always +/// implement [`TryIntoMiddleware`][]. +/// +/// [`TryIntoMiddleware`]: crate::middleware::TryIntoMiddleware +/// [`Stack`]: crate::handler::Stack +pub trait Factory: FnOnce(&Scope) -> Result> {} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl fmt::Debug for Box { + /// Formats the factory for debugging. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Box") + } +} + +// ---------------------------------------------------------------------------- +// Blanket implementations +// ---------------------------------------------------------------------------- + +#[rustfmt::skip] +impl Factory for F +where + F: FnOnce(&Scope) -> Result> {} diff --git a/crates/zensical-serve/src/http.rs b/crates/zensical-serve/src/http.rs new file mode 100644 index 0000000..b1422e9 --- /dev/null +++ b/crates/zensical-serve/src/http.rs @@ -0,0 +1,34 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP protocol. + +pub mod component; +pub mod request; +pub mod response; + +pub use component::{Header, Method, Status}; +pub use request::{Query, Request, Uri}; +pub use response::Response; diff --git a/crates/zensical-serve/src/http/component.rs b/crates/zensical-serve/src/http/component.rs new file mode 100644 index 0000000..ccba42a --- /dev/null +++ b/crates/zensical-serve/src/http/component.rs @@ -0,0 +1,36 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP component. + +mod error; +mod header; +mod method; +mod status; + +pub use error::{Error, Result}; +pub use header::Header; +pub use method::Method; +pub use status::Status; diff --git a/crates/zensical-serve/src/http/component/error.rs b/crates/zensical-serve/src/http/component/error.rs new file mode 100644 index 0000000..0d4bfec --- /dev/null +++ b/crates/zensical-serve/src/http/component/error.rs @@ -0,0 +1,52 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP component error. + +use std::result; +use thiserror::Error; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// HTTP component error. +#[derive(Debug, Error)] +pub enum Error { + /// Invalid method. + #[error("invalid method: {0}")] + Method(String), + + /// Invalid header. + #[error("invalid header: {0}")] + Header(String), +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// HTTP component result. +pub type Result = result::Result; diff --git a/crates/zensical-serve/src/http/component/header.rs b/crates/zensical-serve/src/http/component/header.rs new file mode 100644 index 0000000..5c56bac --- /dev/null +++ b/crates/zensical-serve/src/http/component/header.rs @@ -0,0 +1,390 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP header. + +use std::collections::HashMap; +use std::fmt; +use std::str::FromStr; +use std::sync::LazyLock; + +use super::error::{Error, Result}; + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl AsRef for Header { + /// Returns the string representation. + #[inline] + fn as_ref(&self) -> &str { + self.name() + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Header { + /// Formats the header for display. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.name()) + } +} + +// ---------------------------------------------------------------------------- +// Macros +// ---------------------------------------------------------------------------- + +/// Defines and implements HTTP headers. +macro_rules! define_and_impl_header { + ( + $( + // Header group + $(#[$_:meta])* + $group:ident: + { + $( + // Header definition + $(#[$comment:meta])* + $name:ident = $header:expr + ),+ + $(,)? + } + )+ + ) => { + /// HTTP header. + /// + /// This enum contains all common HTTP headers that can be used in a + /// [`Request`][] or [`Response`][]. Be aware that it's an opinionated + /// implementation, and should by no means be considered complete. It's + /// solely intended for conveniently handling headers in middlewares. + /// + /// Also, consider the following headers: + /// + /// - [`Header::SetCookie`] + /// - [`Header::ProxyAuthenticate`] + /// - [`Header::WwwAuthenticate`] + /// - [`Header::Trailer`] + /// + /// While the HTTP specification allows those specific headers to appear + /// multiple times, our implementation only supports setting them once. + /// + /// [`Request`]: crate::connection::request::Request + /// [`Response`]: crate::connection::response::Response + #[allow(dead_code)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] + pub enum Header { + $( + $( + $(#[$comment])* + $name, + )+ + )+ + } + + impl Header { + /// Returns the header name. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Header; + /// + /// // Create header + /// let header = Header::ContentType; + /// + /// // Obtain header name + /// assert_eq!(header.name(), "Content-Type"); + /// ``` + #[must_use] + pub const fn name(&self) -> &'static str { + match self { + $( + $( + Header::$name => $header, + )+ + )+ + } + } + } + + /// Lookup table for HTTP headers (case-insensitive). + static HEADER_LOOKUP_TABLE: LazyLock> = + LazyLock::new(|| { + HashMap::from_iter([ + $( + $( + ($header.to_lowercase(), Header::$name), + )+ + )+ + ]) + }); + + impl FromStr for Header { + type Err = Error; + + /// Attempts to create a header from a string. + /// + /// # Errors + /// + /// This method returns [`Error::Header`], if the string does not + /// match one of the known headers. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::http::Header; + /// + /// // Create header from string + /// let header: Header = "Content-Type".parse()?; + /// # Ok(()) + /// # } + /// ``` + fn from_str(value: &str) -> Result { + HEADER_LOOKUP_TABLE + .get(&value.to_lowercase()) + .copied() + .ok_or_else(|| Error::Header(value.to_string())) + } + } + } +} + +// ---------------------------------------------------------------------------- + +define_and_impl_header! { + + /// General headers + General: { + /// Accept + Accept = "Accept", + /// Accept-Charset + AcceptCharset = "Accept-Charset", + /// Accept-Encoding + AcceptEncoding = "Accept-Encoding", + /// Accept-Language + AcceptLanguage = "Accept-Language", + /// Accept-Ranges + AcceptRanges = "Accept-Ranges", + /// Age + Age = "Age", + /// Allow + Allow = "Allow", + /// Alt-Svc + AltSvc = "Alt-Svc", + /// Authorization + Authorization = "Authorization", + /// Cache-Control + CacheControl = "Cache-Control", + /// Connection + Connection = "Connection", + /// Content-Disposition + ContentDisposition = "Content-Disposition", + /// Content-Encoding + ContentEncoding = "Content-Encoding", + /// Content-Language + ContentLanguage = "Content-Language", + /// Content-Length + ContentLength = "Content-Length", + /// Content-Location + ContentLocation = "Content-Location", + /// Content-Range + ContentRange = "Content-Range", + /// Content-Security-Policy + ContentSecurityPolicy = "Content-Security-Policy", + /// Content-Type + ContentType = "Content-Type", + /// Cookie + Cookie = "Cookie", + /// Date + Date = "Date", + /// ETag + ETag = "ETag", + /// Expect + Expect = "Expect", + /// Expires + Expires = "Expires", + /// Forwarded + Forwarded = "Forwarded", + /// From + From = "From", + /// Host + Host = "Host", + /// If-Match + IfMatch = "If-Match", + /// If-Modified-Since + IfModifiedSince = "If-Modified-Since", + /// If-None-Match + IfNoneMatch = "If-None-Match", + /// If-Range + IfRange = "If-Range", + /// If-Unmodified-Since + IfUnmodifiedSince = "If-Unmodified-Since", + /// Keep-Alive + KeepAlive = "Keep-Alive", + /// Last-Modified + LastModified = "Last-Modified", + /// Link + Link = "Link", + /// Location + Location = "Location", + /// Max-Forwards + MaxForwards = "Max-Forwards", + /// Origin + Origin = "Origin", + /// Pragma + Pragma = "Pragma", + /// Priority + Priority = "Priority", + /// Proxy-Authenticate + ProxyAuthenticate = "Proxy-Authenticate", + /// Proxy-Authorization + ProxyAuthorization = "Proxy-Authorization", + /// Range + Range = "Range", + /// Referer + Referer = "Referer", + /// Referrer-Policy + ReferrerPolicy = "Referrer-Policy", + /// Retry-After + RetryAfter = "Retry-After", + /// Server + Server = "Server", + /// Set-Cookie + SetCookie = "Set-Cookie", + /// Strict-Transport-Security + StrictTransportSecurity = "Strict-Transport-Security", + /// TE + TE = "TE", + /// Trailer + Trailer = "Trailer", + /// Transfer-Encoding + TransferEncoding = "Transfer-Encoding", + /// Upgrade + Upgrade = "Upgrade", + /// Upgrade-Insecure-Requests + UpgradeInsecureRequests = "Upgrade-Insecure-Requests", + /// User-Agent + UserAgent = "User-Agent", + /// Vary + Vary = "Vary", + /// Via + Via = "Via", + /// Warning + Warning = "Warning", + /// WWW-Authenticate + WwwAuthenticate = "WWW-Authenticate", + } + + /// CORS headers + CrossOriginResourceSharing: { + /// Access-Control-Allow-Credentials + AccessControlAllowCredentials = "Access-Control-Allow-Credentials", + /// Access-Control-Allow-Headers + AccessControlAllowHeaders = "Access-Control-Allow-Headers", + /// Access-Control-Allow-Methods + AccessControlAllowMethods = "Access-Control-Allow-Methods", + /// Access-Control-Allow-Origin + AccessControlAllowOrigin = "Access-Control-Allow-Origin", + /// Access-Control-Expose-Headers + AccessControlExposeHeaders = "Access-Control-Expose-Headers", + /// Access-Control-Max-Age + AccessControlMaxAge = "Access-Control-Max-Age", + /// Access-Control-Request-Headers + AccessControlRequestHeaders = "Access-Control-Request-Headers", + /// Access-Control-Request-Method + AccessControlRequestMethod = "Access-Control-Request-Method", + } + + /// Security headers + Security: { + /// X-Content-Type-Options + XContentTypeOptions = "X-Content-Type-Options", + /// X-DNS-Prefetch-Control + XDnsPrefetchControl = "X-DNS-Prefetch-Control", + /// X-Frame-Options + XFrameOptions = "X-Frame-Options", + /// X-XSS-Protection + XXssProtection = "X-XSS-Protection", + } + + /// Proxy headers + Proxy: { + /// X-Forwarded-For + XForwardedFor = "X-Forwarded-For", + /// X-Forwarded-Host + XForwardedHost = "X-Forwarded-Host", + /// X-Forwarded-Proto + XForwardedProto = "X-Forwarded-Proto", + } + + /// Fetch headers + Fetch: { + /// Sec-Fetch-Dest + SecFetchDest = "Sec-Fetch-Dest", + /// Sec-Fetch-Mode + SecFetchMode = "Sec-Fetch-Mode", + /// Sec-Fetch-Site + SecFetchSite = "Sec-Fetch-Site", + /// Sec-Fetch-User + SecFetchUser = "Sec-Fetch-User", + /// Sec-Purpose + SecPurpose = "Sec-Purpose", + } + + /// Client hint headers + ClientHint: { + /// Accept-CH + AcceptClientHint = "Accept-CH", + /// Sec-CH-UA + SecClientHintUserAgent = "Sec-CH-UA", + /// Sec-CH-UA-Mobile + SecClientHintUserAgentMobile = "Sec-CH-UA-Mobile", + /// Sec-CH-UA-Platform + SecClientHintUserAgentPlatform = "Sec-CH-UA-Platform", + } + + /// WebSocket headers + WebSocket: { + /// Sec-WebSocket-Accept + SecWebSocketAccept = "Sec-WebSocket-Accept", + /// Sec-WebSocket-Extensions + SecWebSocketExtensions = "Sec-WebSocket-Extensions", + /// Sec-WebSocket-Key + SecWebSocketKey = "Sec-WebSocket-Key", + /// Sec-WebSocket-Protocol + SecWebSocketProtocol = "Sec-WebSocket-Protocol", + /// Sec-WebSocket-Version + SecWebSocketVersion = "Sec-WebSocket-Version", + } + + /// Miscellaneous headers + Miscellaneous: { + /// X-Requested-With + XRequestedWith = "X-Requested-With", + } +} diff --git a/crates/zensical-serve/src/http/component/method.rs b/crates/zensical-serve/src/http/component/method.rs new file mode 100644 index 0000000..429493b --- /dev/null +++ b/crates/zensical-serve/src/http/component/method.rs @@ -0,0 +1,165 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP method. + +use std::collections::HashMap; +use std::fmt; +use std::str::FromStr; +use std::sync::LazyLock; + +use super::error::{Error, Result}; + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl AsRef for Method { + /// Returns the string representation. + #[inline] + fn as_ref(&self) -> &str { + self.name() + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Method { + /// Formats the method for display. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str(self.name()) + } +} + +// ---------------------------------------------------------------------------- +// Macros +// ---------------------------------------------------------------------------- + +/// Defines and implements HTTP methods. +macro_rules! define_and_impl_method { + ( + $( + // Method definition + $(#[$comment:meta])* + $name:ident = $method:expr + ),+ + $(,)? + ) => { + /// HTTP method. + #[allow(dead_code)] + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] + pub enum Method { + $( + $(#[$comment])* + $name, + )+ + } + + impl Method { + /// Returns the method name. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Method; + /// + /// // Create method + /// let method = Method::Get; + /// + /// // Obtain method name + /// assert_eq!(method.name(), "GET"); + /// ``` + #[must_use] + pub const fn name(&self) -> &'static str { + match self { + $( + Method::$name => $method, + )+ + } + } + } + + /// Lookup table for HTTP methods (case-insensitive). + static METHOD_LOOKUP_TABLE: LazyLock> = + LazyLock::new(|| { + HashMap::from_iter([ + $( + ($method.to_uppercase(), Method::$name), + )+ + ]) + }); + + impl FromStr for Method { + type Err = Error; + + /// Attempts to create a method from a string. + /// + /// # Errors + /// + /// This method returns [`Error::Method`], if the string does not + /// match one of the known methods. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::http::Method; + /// + /// // Create method from string + /// let method: Method = "GET".parse()?; + /// # Ok(()) + /// # } + /// ``` + fn from_str(value: &str) -> Result { + METHOD_LOOKUP_TABLE + .get(&value.to_uppercase()) + .copied() + .ok_or_else(|| Error::Method(value.to_string())) + } + } + } +} + +// ---------------------------------------------------------------------------- + +define_and_impl_method! { + /// GET method + Get = "GET", + /// HEAD method + Head = "HEAD", + /// POST method + Post = "POST", + /// PUT method + Put = "PUT", + /// DELETE method + Delete = "DELETE", + /// OPTIONS method + Options = "OPTIONS", + /// TRACE method + Trace = "TRACE", + /// PATCH method + Patch = "PATCH", +} diff --git a/crates/zensical-serve/src/http/component/status.rs b/crates/zensical-serve/src/http/component/status.rs new file mode 100644 index 0000000..7bc3324 --- /dev/null +++ b/crates/zensical-serve/src/http/component/status.rs @@ -0,0 +1,263 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP status. + +use std::fmt; + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl AsRef for Status { + /// Returns the string representation. + #[inline] + fn as_ref(&self) -> &str { + self.name() + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Status { + /// Formats the status for display. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let code = *self as u16; + f.write_str(code.to_string().as_str())?; + f.write_str(" ")?; + f.write_str(self.name()) + } +} + +// ---------------------------------------------------------------------------- +// Macros +// ---------------------------------------------------------------------------- + +/// Defines and implements HTTP status codes. +macro_rules! define_and_impl_status { + ( + $( + // Status group + $(#[$_:meta])* + $group:ident: + { + $( + // Status definition + $(#[$comment:meta])* + $name:ident = $code:expr, $reason:expr + ),+ + $(,)? + } + )+ + ) => { + /// HTTP status. + #[allow(clippy::enum_variant_names)] + #[allow(dead_code)] + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum Status { + $( + $( + $(#[$comment])* + $name = $code, + )+ + )+ + } + + impl Status { + /// Returns the status name. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Status; + /// + /// // Create status + /// let status = Status::NotModified; + /// + /// // Obtain status name + /// assert_eq!(status.name(), "Not Modified"); + /// ``` + #[must_use] + pub const fn name(&self) -> &'static str { + match self { + $( + $( + Status::$name => $reason, + )+ + )+ + } + } + } + }; +} + +// ---------------------------------------------------------------------------- + +define_and_impl_status! { + + /// 1xx Informational + Informational: { + /// 100 Continue + Continue = 100, "Continue", + /// 101 Switching Protocols + SwitchingProtocols = 101, "Switching Protocols", + /// 102 Processing + Processing = 102, "Processing", + /// 103 Early Hints + EarlyHints = 103, "Early Hints", + } + + /// 2xx Success + Success: { + /// 200 OK + Ok = 200, "OK", + /// 201 Created + Created = 201, "Created", + /// 202 Accepted + Accepted = 202, "Accepted", + /// 203 Non-Authoritative Information + NonAuthoritativeInformation = 203, "Non-Authoritative Information", + /// 204 No Content + NoContent = 204, "No Content", + /// 205 Reset Content + ResetContent = 205, "Reset Content", + /// 206 Partial Content + PartialContent = 206, "Partial Content", + /// 207 Multi-Status + MultiStatus = 207, "Multi-Status", + /// 208 Already Reported + AlreadyReported = 208, "Already Reported", + /// 226 IM Used + ImUsed = 226, "IM Used", + } + + /// 3xx Redirection + Redirection: { + /// 300 Multiple Choices + MultipleChoices = 300, "Multiple Choices", + /// 301 Moved Permanently + MovedPermanently = 301, "Moved Permanently", + /// 302 Found + Found = 302, "Found", + /// 303 See Other + SeeOther = 303, "See Other", + /// 304 Not Modified + NotModified = 304, "Not Modified", + /// 305 Use Proxy + UseProxy = 305, "Use Proxy", + /// 307 Temporary Redirect + TemporaryRedirect = 307, "Temporary Redirect", + /// 308 Permanent Redirect + PermanentRedirect = 308, "Permanent Redirect", + } + + /// 4xx Client Error + ClientError: { + /// 400 Bad Request + BadRequest = 400, "Bad Request", + /// 401 Unauthorized + Unauthorized = 401, "Unauthorized", + /// 402 Payment Required + PaymentRequired = 402, "Payment Required", + /// 403 Forbidden + Forbidden = 403, "Forbidden", + /// 404 Not Found + NotFound = 404, "Not Found", + /// 405 Method Not Allowed + MethodNotAllowed = 405, "Method Not Allowed", + /// 406 Not Acceptable + NotAcceptable = 406, "Not Acceptable", + /// 407 Proxy Authentication Required + ProxyAuthenticationRequired = 407, "Proxy Authentication Required", + /// 408 Request Timeout + RequestTimeout = 408, "Request Timeout", + /// 409 Conflict + Conflict = 409, "Conflict", + /// 410 Gone + Gone = 410, "Gone", + /// 411 Length Required + LengthRequired = 411, "Length Required", + /// 412 Precondition Failed + PreconditionFailed = 412, "Precondition Failed", + /// 413 Payload Too Large + PayloadTooLarge = 413, "Payload Too Large", + /// 414 URI Too Long + UriTooLong = 414, "URI Too Long", + /// 415 Unsupported Media Type + UnsupportedMediaType = 415, "Unsupported Media Type", + /// 416 Range Not Satisfiable + RangeNotSatisfiable = 416, "Range Not Satisfiable", + /// 417 Expectation Failed + ExpectationFailed = 417, "Expectation Failed", + /// 418 I'm a Teapot + ImATeapot = 418, "I'm a Teapot", + /// 421 Misdirected Request + MisdirectedRequest = 421, "Misdirected Request", + /// 422 Unprocessable Entity + UnprocessableEntity = 422, "Unprocessable Entity", + /// 423 Locked + Locked = 423, "Locked", + /// 424 Failed Dependency + FailedDependency = 424, "Failed Dependency", + /// 425 Too Early + TooEarly = 425, "Too Early", + /// 426 Upgrade Required + UpgradeRequired = 426, "Upgrade Required", + /// 428 Precondition Required + PreconditionRequired = 428, "Precondition Required", + /// 429 Too Many Requests + TooManyRequests = 429, "Too Many Requests", + /// 431 Request Header Fields Too Large + RequestHeaderFieldsTooLarge = 431, "Request Header Fields Too Large", + /// 451 Unavailable For Legal Reasons + UnavailableForLegalReasons = 451, "Unavailable For Legal Reasons", + } + + /// 5xx Server Error + ServerError: { + /// 500 Internal Server Error + InternalServerError = 500, "Internal Server Error", + /// 501 Not Implemented + NotImplemented = 501, "Not Implemented", + /// 502 Bad Gateway + BadGateway = 502, "Bad Gateway", + /// 503 Service Unavailable + ServiceUnavailable = 503, "Service Unavailable", + /// 504 Gateway Timeout + GatewayTimeout = 504, "Gateway Timeout", + /// 505 HTTP Version Not Supported + HttpVersionNotSupported = 505, "HTTP Version Not Supported", + /// 506 Variant Also Negotiates + VariantAlsoNegotiates = 506, "Variant Also Negotiates", + /// 507 Insufficient Storage + InsufficientStorage = 507, "Insufficient Storage", + /// 508 Loop Detected + LoopDetected = 508, "Loop Detected", + /// 510 Not Extended + NotExtended = 510, "Not Extended", + /// 511 Network Authentication Required + NetworkAuthenticationRequired = 511, "Network Authentication Required", + } +} diff --git a/crates/zensical-serve/src/http/request.rs b/crates/zensical-serve/src/http/request.rs new file mode 100644 index 0000000..1b69a9b --- /dev/null +++ b/crates/zensical-serve/src/http/request.rs @@ -0,0 +1,329 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP request. + +use std::borrow::Cow; +use std::fmt; +use std::path::{Component, Path}; +use std::str::{self, FromStr}; + +use super::component::{Header, Method, Status}; + +mod error; +mod headers; +mod uri; + +pub use error::{Error, Result}; +pub use headers::Headers; +pub use uri::{Query, Uri}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP request. +/// +/// The regular way to create a [`Request`] is to use [`Request::from_bytes`], +/// which parses a given slice of bytes. The returned [`Request`] is bound to +/// the lifetime of the byte slice, avoiding unnecessary allocations where +/// possible, except for the [`BTreeMap`][] used for headers. +/// +/// [`BTreeMap`]: std::collections::BTreeMap +/// +/// # Examples +/// +/// ``` +/// use zensical_serve::http::{Method, Request}; +/// +/// // Create request +/// let req = Request::new() +/// .method(Method::Get) +/// .uri("/"); +/// ``` +#[derive(Clone, Debug)] +pub struct Request<'a> { + /// Request method. + pub method: Method, + /// Request URI. + pub uri: Uri<'a>, + /// Request headers. + pub headers: Headers<'a>, + /// Request body. + pub body: Cow<'a, [u8]>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'a> Request<'a> { + /// Creates a request. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Request; + /// + /// // Create request + /// let req = Request::new(); + /// ``` + #[inline] + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates a request from the given bytes. + /// + /// HTTP requests are parsed using the [`httparse`] crate, which is one of + /// the few dependencies that we rely on as it provides an efficient, fast, + /// and well-tested parser. The returned [`Request`] will be bound to the + /// lifetime of the input, avoiding allocations where possible. + /// + /// This method performs several validations in order to protect against the + /// most common security vulnerabilities, including length checks and path + /// traversal attempts. Note that NUL characters are already rejected by + /// [`httparse`], so we don't need to handle them again. + /// + /// # Errors + /// + /// This method returns [`Error::Incomplete`], if the given buffer contained + /// insufficient data to provide a meaningful answer, [`Error::Parser`], if + /// the buffer contained invalid data, and [`Error::Component`], when the + /// parsed request contains an invalid [`Method`] or [`Header`]. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::http::{Method, Request}; + /// + /// // Create request from bytes + /// let req = Request::from_bytes(b"GET / HTTP/1.1\r\n\r\n")?; + /// assert_eq!(req.method, Method::Get); + /// assert_eq!(req.uri.path, "/"); + /// # Ok(()) + /// # } + /// ``` + #[allow(clippy::missing_panics_doc)] + pub fn from_bytes(bytes: &'a [u8]) -> Result { + if bytes.len() > 8 * 1024 * 1024 { + return Err(Error::Validation(Status::PayloadTooLarge)); + } + + // Initialize buffer for headers and request parser + let mut headers = [httparse::EMPTY_HEADER; 64]; + let mut req = httparse::Request::new(&mut headers); + + // Parse request using the `httparse` crate, and create a new request + // from the parsed data. Note that we only use the `httparse` crate and + // not the `http` crate, as the later provides a rather inconvenient + // interface for writing middlewares comfortably. + match req.parse(bytes).map_err(Error::from)? { + httparse::Status::Partial => Err(Error::Incomplete), + httparse::Status::Complete(n) => { + let body = Cow::Borrowed(&bytes[n..]); + + // Unpack request method and URI - if parsing succeeded, we can + // be confident that method and path, both options, must exist + let method = req.method.expect("invariant").parse()?; + let path = req.path.expect("invariant"); + if path.len() > 2 * 1024 { + return Err(Error::Validation(Status::UriTooLong)); + } + + // Ensure that the request URI path starts with a slash, as we + // do not support proxy requests, and probably never will + let uri = Uri::from(path); + if !uri.path.starts_with('/') { + return Err(Error::Validation(Status::BadRequest)); + } + + // Ensure that the request URI path doesn't attempt a traversal, + // but do a quick check first to see whether the percent-decoded + // path actually contains a `..` sequence. This allows us to + // short-circuit the common case when it does not. + if uri.path.contains("..") { + let mut iter = Path::new(uri.path.as_ref()).components(); + if iter.any(|component| component == Component::ParentDir) { + return Err(Error::Validation(Status::BadRequest)); + } + } + + // Unpack request headers - ensure that header's do not exceed + // certain safe limits, but just skip any unknown headers + let iter = req.headers.iter(); + let iter = iter + .take_while(|header| !header.name.is_empty()) + .filter_map(|header| { + // Ensure header value field doesn't exceed 4kb, or we + // should fail for security reasons. 4kb should be more + // than enough for any sane header value, including + // cookies, user agents, and authorization tokens. + if header.value.len() > 4 * 1024 { + let status = Status::RequestHeaderFieldsTooLarge; + return Some(Err(Error::Validation(status))); + } + + // Convert header name and value to strings, and parse + // header name into a `Header` component to have type- + // safety in middlewares and handlers. If we don't know + // the header, we can just skip and ignore it. + str::from_utf8(header.value).ok().and_then(|value| { + let res = Header::from_str(header.name); + res.ok().map(|name| (name, value)).map(Ok) + }) + }); + + // Collect headers, parsing URI and return request + let headers = iter.collect::>()?; + Ok(Request { method, uri, headers, body }) + } + } + } +} + +impl<'a> Request<'a> { + /// Sets the method of the request. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Method, Request}; + /// + /// // Create request and set method + /// let req = Request::new() + /// .method(Method::Post); + /// ``` + #[inline] + #[must_use] + pub fn method(mut self, method: Method) -> Self { + self.method = method; + self + } + + /// Sets the URI of the request. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Method, Request}; + /// + /// // Create request and set URI + /// let req = Request::new() + /// .uri("/"); + /// ``` + #[inline] + #[must_use] + pub fn uri(mut self, uri: U) -> Self + where + U: Into>, + { + self.uri = uri.into(); + self + } + + /// Adds a header to the request. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Header, Request}; + /// + /// // Create request and add header + /// let req = Request::new() + /// .header(Header::Accept, "text/plain"); + /// ``` + #[allow(clippy::needless_pass_by_value)] + #[inline] + #[must_use] + pub fn header(mut self, header: Header, value: V) -> Self + where + V: ToString, + { + self.headers.insert(header, value.to_string()); + self + } + + /// Sets the body of the request. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Request; + /// + /// // Create request and set body + /// let req = Request::new() + /// .body("Hello, world!"); + /// ``` + #[inline] + #[must_use] + pub fn body(mut self, body: B) -> Self + where + B: Into>, + { + self.body = Cow::Owned(body.into()); + self + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Default for Request<'_> { + /// Creates a default request. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Request; + /// + /// // Create request + /// let req = Request::default(); + /// ``` + #[inline] + fn default() -> Self { + Self { + method: Method::Get, + uri: Uri::default(), + headers: Headers::default(), + body: Cow::Borrowed(&[]), + } + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Request<'_> { + /// Formats the response for display. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{} {} HTTP/1.1\r\n", self.method, self.uri)?; + write!(f, "{}\r\n", self.headers)?; + write!(f, "[Body: {} bytes]\r\n", self.body.len()) + } +} diff --git a/crates/zensical-serve/src/http/request/error.rs b/crates/zensical-serve/src/http/request/error.rs new file mode 100644 index 0000000..1d7f3a3 --- /dev/null +++ b/crates/zensical-serve/src/http/request/error.rs @@ -0,0 +1,81 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP request error. + +use std::result; +use thiserror::Error; + +use crate::http::{component, Status}; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// HTTP request error. +#[derive(Debug, Error)] +pub enum Error { + /// HTTP parser error. + #[error(transparent)] + Parser(httparse::Error), + + /// HTTP component error. + #[error(transparent)] + Component(#[from] component::Error), + + /// HTTP request incomplete. + #[error("request incomplete")] + Incomplete, + + /// HTTP request validation failed. + #[error("request validation failed: {0}")] + Validation(Status), +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl From for Error { + /// Creates an error from an HTTP parser error. + /// + /// We validate the maximum number of headers with the size of the buffer + /// that we pass to [`httparse`], so we don't need to check it again. + #[inline] + fn from(err: httparse::Error) -> Self { + if let httparse::Error::TooManyHeaders = err { + Error::Validation(Status::RequestHeaderFieldsTooLarge) + } else { + Error::Parser(err) + } + } +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// HTTP request result. +pub type Result = result::Result; diff --git a/crates/zensical-serve/src/http/request/headers.rs b/crates/zensical-serve/src/http/request/headers.rs new file mode 100644 index 0000000..f34b759 --- /dev/null +++ b/crates/zensical-serve/src/http/request/headers.rs @@ -0,0 +1,236 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP request headers. + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::fmt; + +use crate::http::Header; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP request headers. +/// +/// The header map for HTTP requests can store borrowed and owned values, which +/// allows for zero-copy parsing of headers, since the [`Request`][] is already +/// borrowed. Using a [`Cow`] allows middlewares to alter the headers, limiting +/// allocations to the case where headers are added or modified. +/// +/// As keys are integers, it's better to use a [`BTreeMap`] than a [`HashMap`], +/// because the latter is 3x slower for integer keys. +/// +/// [`HashMap`]: std::collections::HashMap +/// [`Request`]: crate::http::Request +/// +/// # Examples +/// +/// ``` +/// use zensical_serve::http::request::Headers; +/// use zensical_serve::http::Header; +/// +/// // Create header map and add header +/// let mut headers = Headers::new(); +/// headers.insert(Header::Accept, "text/plain"); +/// +/// // Obtain string representation +/// println!("{headers}"); +/// ``` +#[derive(Clone, Debug, Default)] +pub struct Headers<'a> { + /// Ordered map of headers. + inner: BTreeMap>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'a> Headers<'a> { + /// Creates a header map. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::request::Headers; + /// + /// // Create header map + /// let headers = Headers::new(); + /// ``` + #[inline] + #[must_use] + pub fn new() -> Self { + Self { inner: BTreeMap::new() } + } + + /// Returns the value for the given header. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::request::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::Accept, "text/plain"); + /// + /// // Obtain reference to header value + /// let value = headers.get(Header::Accept); + /// ``` + #[inline] + #[must_use] + pub fn get(&self, header: Header) -> Option<&str> { + self.inner.get(&header).map(AsRef::as_ref) + } + + /// Returns whether the header is contained. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::request::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::Accept, "text/plain"); + /// + /// // Ensure presence of header + /// let check = headers.contains(Header::Accept); + /// assert_eq!(check, true); + /// ``` + #[inline] + #[must_use] + pub fn contains(&self, header: Header) -> bool { + self.inner.contains_key(&header) + } + + /// Updates the given header. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::request::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::Accept, "text/plain"); + /// ``` + #[inline] + pub fn insert(&mut self, header: Header, value: V) + where + V: Into>, + { + self.inner.insert(header, value.into()); + } + + /// Removes the given header. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::request::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::Accept, "text/plain"); + /// + /// // Remove header + /// headers.remove(Header::Accept); + /// ``` + #[inline] + pub fn remove(&mut self, header: Header) { + self.inner.remove(&header); + } +} + +#[allow(clippy::must_use_candidate)] +impl Headers<'_> { + /// Returns the number of headers. + #[inline] + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Returns whether there are any headers. + #[inline] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl<'a> FromIterator<(Header, &'a str)> for Headers<'a> { + /// Creates a header map from an iterator. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::request::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map from iterator + /// let headers = Headers::from_iter([ + /// (Header::Accept, "text/plain"), + /// (Header::AcceptLanguage, "en"), + /// ]); + /// ``` + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + let mut headers = Headers::new(); + for (header, value) in iter { + headers.insert(header, value); + } + headers + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Headers<'_> { + /// Formats the header map for display. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + for (header, value) in &self.inner { + f.write_str(header.name())?; + f.write_str(": ")?; + f.write_str(value)?; + f.write_str("\r\n")?; + } + + // No errors occurred + Ok(()) + } +} diff --git a/crates/zensical-serve/src/http/request/uri.rs b/crates/zensical-serve/src/http/request/uri.rs new file mode 100644 index 0000000..a950ac9 --- /dev/null +++ b/crates/zensical-serve/src/http/request/uri.rs @@ -0,0 +1,176 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP request URI. + +use std::borrow::Cow; +use std::fmt; + +mod encoding; +mod query; + +use encoding::{decode, encode}; +pub use query::Query; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP request URI. +/// +/// This is a lightweight, but definitely not spec-compliant, URI parser. The +/// sane thing would be to just use the [`url`][] crate, but it pulls in a huge +/// number of dependencies, which would double or triple the footprint of our +/// executable, and increase churn of dependencies for no immediate upside. +/// +/// For now, we just assume that paths always start with a `/`, which is sane +/// to assume for a local web server that is not intended for proxying. +/// +/// [`url`]: https://crates.io/crates/url +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Uri<'a> { + /// Request path. + pub path: Cow<'a, str>, + /// Query string. + pub query: Query<'a>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'a> Uri<'a> { + /// Creates a request URI. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Uri; + /// + /// // Create request URI + /// let uri = Uri::new(); + /// ``` + #[inline] + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates a request URI from a path and query string. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Uri; + /// + /// // Create request URI from parts + /// let uri = Uri::from_parts("/path", "key=value"); + /// ``` + #[inline] + #[must_use] + pub fn from_parts(path: P, query: Q) -> Self + where + P: Into>, + Q: Into>, + { + Uri { + path: path.into(), + query: query.into(), + } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl<'a> From<&'a str> for Uri<'a> { + /// Creates a request URI from a string. + /// + /// Note that we can't implement [`FromStr`][] for [`Uri`] because of the + /// required `&'a str` lifetime, which is not compatible with the trait. + /// + /// [`FromStr`]: std::str::FromStr + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Uri; + /// + /// // Create request URI from string + /// let uri = Uri::from("/path?key=value"); + /// ``` + fn from(value: &'a str) -> Self { + match value.split_once('?') { + Some((path, query)) => Uri { + path: decode(path), + query: Query::from(query), + }, + None => Uri { + path: decode(value), + query: Query::default(), + }, + } + } +} + +// ---------------------------------------------------------------------------- + +impl Default for Uri<'_> { + /// Creates a default request URI. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Uri; + /// + /// // Create request URI + /// let uri = Uri::default(); + #[inline] + fn default() -> Self { + Uri { + path: Cow::Borrowed("/"), + query: Query::default(), + } + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Uri<'_> { + /// Formats the request URI for display. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(encode(&self.path).as_ref())?; + + // Write query string, if any + if !self.query.is_empty() { + f.write_str("?")?; + self.query.fmt(f)?; + } + + // No errors occurred + Ok(()) + } +} diff --git a/crates/zensical-serve/src/http/request/uri/encoding.rs b/crates/zensical-serve/src/http/request/uri/encoding.rs new file mode 100644 index 0000000..04908f6 --- /dev/null +++ b/crates/zensical-serve/src/http/request/uri/encoding.rs @@ -0,0 +1,60 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Encoding. + +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet}; +use std::borrow::Cow; + +// ---------------------------------------------------------------------------- +// Constants +// ---------------------------------------------------------------------------- + +/// Character set to be percent-encoded. +#[rustfmt::skip] +const SET: &AsciiSet = &percent_encoding::CONTROLS + .add(b' ').add(b'"').add(b'#').add(b'%').add(b'<').add(b'>').add(b'[') + .add(b']').add(b'^').add(b'`').add(b'{').add(b'|').add(b'}').add(b'?') + .add(b'~'); + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Encodes a string used in a path. +#[inline] +#[must_use] +pub fn encode(value: &str) -> Cow<'_, str> { + utf8_percent_encode(value, SET).into() +} + +// ---------------------------------------------------------------------------- + +/// Decodes a string used in a path. +#[inline] +#[must_use] +pub fn decode(value: &str) -> Cow<'_, str> { + percent_decode_str(value).decode_utf8_lossy() +} diff --git a/crates/zensical-serve/src/http/request/uri/query.rs b/crates/zensical-serve/src/http/request/uri/query.rs new file mode 100644 index 0000000..8add1de --- /dev/null +++ b/crates/zensical-serve/src/http/request/uri/query.rs @@ -0,0 +1,355 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP query string. + +use std::borrow::Cow; +use std::{fmt, iter, str}; + +mod encoding; + +use encoding::{decode, encode}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP query string. +/// +/// As with the other components that can be part of a [`Request`][], the query +/// string can store borrowed and owned values, which allows for very efficient +/// handling, while still being able to comfortably overwrite parameters of +/// query strings in middlewares and tests, if necessary. +/// +/// When parsing a query string with [`Query::from`], the keys and values will +/// be percent-decoded and stored decoded in a parameter list, as query strings +/// might have multiple values for the same key, and ordering always needs to +/// be preserved when formatting with [`fmt::Display`]. Note that only those +/// characters for which percent-encoding is required will be percent-encoded +/// when printing the query string. +/// +/// [`Request`]: crate::http::Request +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Query<'a> { + /// List of parameters. + inner: Vec>, +} + +/// HTTP query string parameter. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct Param<'a> { + /// Parameter key. + key: Cow<'a, str>, + /// Parameter value. + value: Cow<'a, str>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'a> Query<'a> { + /// Creates a query string. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Query; + /// + /// // Create query string + /// let query = Query::new(); + /// ``` + #[inline] + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Returns the first parameter value for the given key. + /// + /// If the parameter appears multiple times in the query string, only the + /// first value is returned. Use [`Query::get_all`] to retrieve all values. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Query; + /// + /// // Create query string and add parameter + /// let mut query = Query::new(); + /// query.add("key", "value"); + /// + /// // Obtain reference to parameter value + /// let value = query.get("key"); + /// ``` + pub fn get(&self, key: K) -> Option<&str> + where + K: AsRef, + { + self.inner.iter().find_map(|param| { + (param.key == key.as_ref()).then_some(param.value.as_ref()) + }) + } + + /// Returns an iterator over all parameter values for the given key. + /// + /// This is particularly necessary for query string parameters which can be + /// repeated, such as form submissions with multiple checkboxes. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Query; + /// + /// // Create query string and add parameters + /// let mut query = Query::new(); + /// query.add("key", "a"); + /// query.add("key", "b"); + /// + /// // Iterate over parameter values + /// for value in query.get_all("key") { + /// println!("{value}"); + /// } + /// ``` + pub fn get_all(&self, key: K) -> impl Iterator + where + K: AsRef, + { + self.inner.iter().filter_map(move |param| { + (param.key == key.as_ref()).then_some(param.value.as_ref()) + }) + } + + /// Returns whether the parameter is contained. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Query; + /// + /// // Create query string and add parameter + /// let mut query = Query::new(); + /// query.add("key", "value"); + /// + /// // Ensure presence of parameter + /// let check = query.contains("key"); + /// assert_eq!(check, true); + /// ``` + pub fn contains(&self, key: K) -> bool + where + K: AsRef, + { + self.inner.iter().any(|param| param.key == key.as_ref()) + } + + /// Adds the given key-value pair as a parameter. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Query; + /// + /// // Create query string and add parameter + /// let mut query = Query::new(); + /// query.add("key", "value"); + /// ``` + pub fn add(&mut self, key: K, value: V) + where + K: Into>, + V: Into>, + { + self.inner.push(Param { + key: key.into(), + value: value.into(), + }); + } + + /// Removes the given parameter. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Query; + /// + /// // Create query string and add parameter + /// let mut query = Query::new(); + /// query.add("key", "value"); + /// + /// // Remove parameter + /// query.remove("key"); + /// ``` + pub fn remove(&mut self, key: K) + where + K: AsRef, + { + self.inner.retain(|param| param.key != key.as_ref()); + } +} + +#[allow(clippy::must_use_candidate)] +impl Query<'_> { + /// Returns the number of parameters. + #[inline] + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Returns whether there are any parameters. + #[inline] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl<'a> From<&'a str> for Query<'a> { + /// Creates a query string from a string. + /// + /// The query string is parsed from the given string, which is expected to + /// be in the format of a query string, i.e., a sequence of key-value pairs + /// connected with `&`, but with the initial `?` separator removed. Both + /// keys and values are percent-decoded and stored. + /// + /// Note that we can't implement [`FromStr`][] for [`Query`] because of the + /// required `&'a str` lifetime, which is not compatible with the trait. + /// + /// [`FromStr`]: std::str::FromStr + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Query; + /// + /// // Create query string from string + /// let query = Query::from("query=search&limit=25"); + /// ``` + #[allow(clippy::missing_panics_doc)] + fn from(value: &'a str) -> Self { + let mut pairs = Vec::new(); + + // Initialize start and pair index + let mut start = 0; + let mut index = 0; + + // Extract key-value pairs from string after conversion - we append a + // sentinel `&` separator to the end of the string, which makes parsing + // much simpler, as we don't need to replicate the logic for the last + // key-value pair outside of the loop + let chars = value.char_indices(); + for (i, char) in chars.chain(iter::once((value.len(), '&'))) { + match char { + // If the current character is a `=` separator, we consumed a + // key (which may be empty), so we start a new key-value pair. + // Note that the `=` separator can also appear multiple times, + // in which case it's treated as a verbatim character. + '=' if index == pairs.len() => { + pairs.push((decode(&value[start..i]), Cow::Borrowed(""))); + start = i + 1; + } + + // If the current character is a `&` separator, we consumed a + // key-value pair, or just a key, both of which might be empty + '&' if start != i.saturating_sub(1) => { + if index < pairs.len() && pairs[index].1.is_empty() { + pairs[index].1 = decode(&value[start..i]); + } else { + pairs.push(( + decode(&value[start..i]), + Cow::Borrowed(""), + )); + } + + // Continue after separator + start = i + 1; + index += 1; + } + + // Consume all other characters + _ => {} + } + } + + // Create query string from key-value pairs + Query::from_iter(pairs) + } +} + +// ---------------------------------------------------------------------------- + +impl<'a, K, V> FromIterator<(K, V)> for Query<'a> +where + K: Into>, + V: Into>, +{ + /// Creates a query string from an iterator. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Query; + /// + /// // Create query string from iterator + /// let query = Query::from_iter([ + /// ("query", "search"), + /// ("limit", "25"), + /// ]); + /// ``` + fn from_iter(iter: T) -> Self + where + T: IntoIterator, + { + let mut query = Query::new(); + for (key, value) in iter { + query.add(key, value); + } + query + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Query<'_> { + /// Formats the query string for display. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, param) in self.inner.iter().enumerate() { + if i > 0 { + f.write_str("&")?; + } + + // Write parameter key and value, if any + f.write_str(encode(¶m.key).as_ref())?; + if !param.value.is_empty() { + f.write_str("=")?; + f.write_str(encode(¶m.value).as_ref())?; + } + } + + // No errors occurred + Ok(()) + } +} diff --git a/crates/zensical-serve/src/http/request/uri/query/encoding.rs b/crates/zensical-serve/src/http/request/uri/query/encoding.rs new file mode 100644 index 0000000..0978356 --- /dev/null +++ b/crates/zensical-serve/src/http/request/uri/query/encoding.rs @@ -0,0 +1,64 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Encoding. + +use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet}; +use std::borrow::Cow; + +// ---------------------------------------------------------------------------- +// Constants +// ---------------------------------------------------------------------------- + +/// Character set to be percent-encoded. +#[rustfmt::skip] +const SET: &AsciiSet = &percent_encoding::CONTROLS + .add(b' ').add(b'"').add(b'#').add(b'%').add(b'<').add(b'>').add(b'[') + .add(b']').add(b'^').add(b'`').add(b'{').add(b'|').add(b'}').add(b'='); + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Encodes a string used in a query string. +#[inline] +#[must_use] +pub fn encode(value: &str) -> Cow<'_, str> { + utf8_percent_encode(value, SET).into() +} + +/// Decodes a string used in a query string. +#[inline] +#[must_use] +pub fn decode(value: &str) -> Cow<'_, str> { + if value.contains('+') { + percent_decode_str(&value.replace('+', " ")) + .decode_utf8_lossy() + .into_owned() + .into() + } else { + percent_decode_str(value).decode_utf8_lossy() + } +} diff --git a/crates/zensical-serve/src/http/response.rs b/crates/zensical-serve/src/http/response.rs new file mode 100644 index 0000000..b1c9a8b --- /dev/null +++ b/crates/zensical-serve/src/http/response.rs @@ -0,0 +1,251 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP response. + +use std::fmt; + +use super::component::{Header, Status}; + +mod convert; +mod error; +mod ext; +mod headers; + +pub use error::{Error, Result}; +pub use ext::ResponseExt; +pub use headers::Headers; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP response. +/// +/// While all members of this struct are public, there are also some dedicated +/// methods with identical names, providing a builder-like interface. However, +/// before creating a response using this struct directly, consider using the +/// [`ResponseExt`] trait, which provides several convenient constructors. +/// +/// # Examples +/// +/// ``` +/// use zensical_serve::http::{Header, Response, Status}; +/// +/// // Create response +/// let res = Response::new() +/// .status(Status::Ok) +/// .header(Header::ContentType, "text/plain") +/// .header(Header::ContentLength, 13) +/// .body("Hello, world!"); +/// ``` +#[derive(Clone, Debug)] +pub struct Response { + /// Response status. + pub status: Status, + /// Response headers. + pub headers: Headers, + /// Response body. + pub body: Vec, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Response { + /// Creates a response. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Response; + /// + /// // Create response + /// let res = Response::new(); + /// ``` + #[inline] + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Converts the response into bytes. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Header, Response, Status}; + /// + /// // Create response + /// let res = Response::new() + /// .status(Status::Ok) + /// .header(Header::ContentType, "text/plain") + /// .header(Header::ContentLength, 13) + /// .body("Hello, world!"); + /// + /// // Convert response into bytes + /// let bytes = res.into_bytes(); + /// ``` + #[must_use] + pub fn into_bytes(self) -> Vec { + // Compute an estimate for the response size - we know that we need 8 + // bytes for the HTTP/1.1 prefix + 36 bytes for the status code + info, + // both with 2 bytes for the CRLF at the end. Then, for each header, we + // estimate an average size of 64 bytes per header (which might be more + // than necessary, but that's okay), and reserve just enough space for + // the body + 2 bytes for the CLRF that preceeds it. + let capacity = (8 + 2) + + 4 + 32 + 2 // fmt + + self.headers.len() * 64 + 2 // fmt + + self.body.len(); + + // Create pre-sized buffer and append prefix and status + let mut buffer = Vec::with_capacity(capacity); + buffer.extend_from_slice(b"HTTP/1.1 "); + buffer.extend_from_slice(self.status.to_string().as_bytes()); + buffer.extend_from_slice(b"\r\n"); + + // Append all headers to buffer + for (header, value) in &self.headers { + buffer.extend_from_slice(header.name().as_bytes()); + buffer.extend_from_slice(b": "); + buffer.extend_from_slice(value.as_bytes()); + buffer.extend_from_slice(b"\r\n"); + } + + // Append empty line and body to buffer, if given + buffer.extend_from_slice(b"\r\n"); + if !self.body.is_empty() { + buffer.extend_from_slice(&self.body); + } + + // Return buffer + buffer + } +} + +impl Response { + /// Sets the status of the response. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Response, Status}; + /// + /// // Create response and set status + /// let res = Response::new() + /// .status(Status::Ok); + /// ``` + #[inline] + #[must_use] + pub fn status(mut self, status: Status) -> Self { + self.status = status; + self + } + + /// Adds a header to the response. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Header, Response}; + /// + /// // Create response and add header + /// let res = Response::new() + /// .header(Header::ContentType, "text/plain"); + /// ``` + #[inline] + #[must_use] + pub fn header(mut self, header: Header, value: V) -> Self + where + V: ToString, + { + self.headers.insert(header, value); + self + } + + /// Sets the body of the response. + /// + /// __Warning__: Albeit the [`Header::ContentLength`] header is required in + /// most cases, it's not automatically set when using this method, since it + /// belongs to the low-level [`Response`] interface. Please consider using + /// the much more convenient [`ResponseExt`] methods. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Response; + /// + /// // Create response and set body + /// let res = Response::new() + /// .body("Hello, world!"); + /// ``` + #[inline] + #[must_use] + pub fn body(mut self, body: B) -> Self + where + B: Into>, + { + self.body = body.into(); + self + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Default for Response { + /// Creates a default response. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::Response; + /// + /// // Create response + /// let res = Response::default(); + /// ``` + #[inline] + fn default() -> Self { + Self { + status: Status::Ok, + headers: Headers::default(), + body: Vec::default(), + } + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Response { + /// Formats the response for display. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "HTTP/1.1 {}\r\n", self.status)?; + write!(f, "{}\r\n", self.headers)?; + write!(f, "[Body: {} bytes]\r\n", self.body.len()) + } +} diff --git a/crates/zensical-serve/src/http/response/convert.rs b/crates/zensical-serve/src/http/response/convert.rs new file mode 100644 index 0000000..02cebaf --- /dev/null +++ b/crates/zensical-serve/src/http/response/convert.rs @@ -0,0 +1,65 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP response conversions. + +use std::error::Error; +use std::result::Result; + +use crate::http::Status; + +use super::ext::ResponseExt; +use super::Response; + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl From> for Response +where + E: Error, +{ + /// Creates a response from a result. + /// + /// If the result is an error, the "500 Internal Server Error" status code + /// is returned as a response, which indicates an unrecoverable error. + /// + /// # Examples + /// + /// ``` + /// use std::io::Error; + /// use zensical_serve::http::{Response, Status}; + /// + /// // Create response from error + /// let err = Error::from_raw_os_error(1); + /// let res = Response::from(Err(err)); + /// assert_eq!(res.status, Status::InternalServerError); + /// ``` + fn from(result: Result) -> Self { + result.unwrap_or_else(|_| { + Response::from_status(Status::InternalServerError) + }) + } +} diff --git a/crates/zensical-serve/src/http/response/error.rs b/crates/zensical-serve/src/http/response/error.rs new file mode 100644 index 0000000..304e43f --- /dev/null +++ b/crates/zensical-serve/src/http/response/error.rs @@ -0,0 +1,48 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP response error. + +use std::{io, result}; +use thiserror::Error; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// HTTP response error. +#[derive(Debug, Error)] +pub enum Error { + /// I/O error. + #[error(transparent)] + Io(#[from] io::Error), +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// HTTP response result. +pub type Result = result::Result; diff --git a/crates/zensical-serve/src/http/response/ext.rs b/crates/zensical-serve/src/http/response/ext.rs new file mode 100644 index 0000000..c14b1bd --- /dev/null +++ b/crates/zensical-serve/src/http/response/ext.rs @@ -0,0 +1,138 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP response. + +use httpdate::fmt_http_date; +use std::fs; +use std::path::Path; + +use crate::http::{Header, Status}; + +use super::{Response, Result}; + +// ---------------------------------------------------------------------------- +// Traits +// ---------------------------------------------------------------------------- + +/// Extension trait for the `Response` type providing additional functionality. +pub trait ResponseExt: Sized { + /// Creates a response from a file. + fn from_file

(path: P) -> Result + where + P: AsRef, + { + let path = path.as_ref(); + let mime = match path.extension().and_then(|ext| ext.to_str()) { + Some("html" | "htm") => "text/html; charset=utf-8", + Some("css") => "text/css", + Some("js") => "application/javascript", + Some("json") => "application/json", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("svg") => "image/svg+xml", + Some("ico") => "image/x-icon", + Some("pdf") => "application/pdf", + Some("mp4") => "video/mp4", + Some("txt") => "text/plain; charset=utf-8", + Some("xml") => "application/xml", + _ => "application/octet-stream", + }; + + // Create the response from file + fs::read(path).map_err(Into::into).and_then(|content| { + let res = Response::new() + .status(Status::Ok) + .header(Header::ContentType, mime) + .header(Header::ContentLength, content.len()) + .body(content); + + // Retrieve file metadata and add date, if applicable + let meta = fs::metadata(path)?; + let meta = meta.modified().map(fmt_http_date).ok(); + if let Some(date) = meta { + Ok(res.header(Header::LastModified, date)) + } else { + Ok(res) + } + }) + } + + /// Creates a response from plain text. + fn from_text(content: S) -> Response + where + S: Into, + { + Response::new() // fmt + .status(Status::Ok) + .text(content) + } + + /// Creates a response from a status code. + /// + /// This is a convenience method to create a response with a status code + /// and a text body, particularly useful for error handling. + #[must_use] + fn from_status(status: Status) -> Response { + Response::new() // fmt + .status(status) + .text(status.name()) + } + + /// Creates a redirect response. + #[must_use] + fn redirect(location: L) -> Response + where + L: ToString, + { + Response::new() + .status(Status::Found) + .header(Header::Location, location) + .header(Header::ContentLength, 0) + } + + /// Sets the given text as the body of the response. + fn text(self, content: S) -> Response + where + S: Into; +} + +// ---------------------------------------------------------------------------- +// Blanket implementations +// ---------------------------------------------------------------------------- + +impl ResponseExt for Response { + /// Sets the given text as the body of the response. + fn text(self, content: S) -> Response + where + S: Into, + { + let content = content.into(); + self.header(Header::ContentType, "text/plain; charset=utf-8") + .header(Header::ContentLength, content.len()) + .body(content) + } +} diff --git a/crates/zensical-serve/src/http/response/headers.rs b/crates/zensical-serve/src/http/response/headers.rs new file mode 100644 index 0000000..cf68e67 --- /dev/null +++ b/crates/zensical-serve/src/http/response/headers.rs @@ -0,0 +1,258 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP response headers. + +use std::collections::btree_map::Iter; +use std::collections::BTreeMap; +use std::fmt; + +use crate::http::Header; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP response headers. +/// +/// The header map for HTTP responses stores owned values, so we don't need to +/// bother the middleware signatures with lifetimes, which would make writing +/// middlewares much more complicated. Here, we prefer a simple interface over +/// one that optimizes for performance. +/// +/// As keys are integers, it's better to use a [`BTreeMap`] than a [`HashMap`], +/// because the latter is 3x slower for integer keys. +/// +/// [`HashMap`]: std::collections::HashMap +/// +/// # Examples +/// +/// ``` +/// use zensical_serve::http::response::Headers; +/// use zensical_serve::http::Header; +/// +/// // Create header map and add header +/// let mut headers = Headers::new(); +/// headers.insert(Header::ContentType, "text/plain"); +/// +/// // Obtain string representation +/// println!("{headers}"); +/// ``` +#[derive(Clone, Debug, Default)] +pub struct Headers { + /// Ordered map of headers. + inner: BTreeMap, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Headers { + /// Creates a header map. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::response::Headers; + /// + /// // Create header map + /// let headers = Headers::new(); + /// ``` + #[inline] + #[must_use] + pub fn new() -> Self { + Self { inner: BTreeMap::new() } + } + + /// Returns the value for the given header. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::response::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::ContentType, "text/plain"); + /// + /// // Obtain reference to header value + /// let value = headers.get(Header::ContentType); + /// ``` + #[inline] + #[must_use] + pub fn get(&self, header: Header) -> Option<&str> { + self.inner.get(&header).map(AsRef::as_ref) + } + + /// Returns whether the header is contained. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::response::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::ContentType, "text/plain"); + /// + /// // Ensure presence of header + /// let check = headers.contains(Header::ContentType); + /// assert_eq!(check, true); + /// ``` + #[inline] + #[must_use] + pub fn contains(&self, header: Header) -> bool { + self.inner.contains_key(&header) + } + + /// Updates the given header. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::response::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::ContentType, "text/plain"); + /// ``` + #[allow(clippy::needless_pass_by_value)] + #[inline] + pub fn insert(&mut self, header: Header, value: V) + where + V: ToString, + { + self.inner.insert(header, value.to_string()); + } + + /// Removes the given header. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::response::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::ContentType, "text/plain"); + /// + /// // Remove header + /// headers.remove(Header::ContentType); + /// ``` + #[inline] + pub fn remove(&mut self, header: Header) { + self.inner.remove(&header); + } + + /// Returns an iterator over the header map. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::response::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::ContentType, "text/plain"); + /// + /// // Iterate over header map + /// for (header, value) in headers.iter() { + /// println!("{header}: {value}"); + /// } + /// ``` + #[inline] + pub fn iter(&self) -> Iter<'_, Header, String> { + self.inner.iter() + } +} + +#[allow(clippy::must_use_candidate)] +impl Headers { + /// Returns the number of headers. + #[inline] + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Returns whether there are any headers. + #[inline] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl<'a> IntoIterator for &'a Headers { + type Item = (&'a Header, &'a String); + type IntoIter = Iter<'a, Header, String>; + + /// Creates an iterator over the header map. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::response::Headers; + /// use zensical_serve::http::Header; + /// + /// // Create header map and add header + /// let mut headers = Headers::new(); + /// headers.insert(Header::ContentType, "text/plain"); + /// + /// // Iterate over header map + /// for (header, value) in &headers { + /// println!("{header}: {value}"); + /// } + /// ``` + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Display for Headers { + /// Formats the header map for display. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + for (header, value) in &self.inner { + f.write_str(header.name())?; + f.write_str(": ")?; + f.write_str(value)?; + f.write_str("\r\n")?; + } + + // No errors occurred + Ok(()) + } +} diff --git a/crates/zensical-serve/src/lib.rs b/crates/zensical-serve/src/lib.rs new file mode 100644 index 0000000..ecebb64 --- /dev/null +++ b/crates/zensical-serve/src/lib.rs @@ -0,0 +1,40 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Non-blocking HTTP/1.1 server with WebSocket support. +//! +//! Note that this is currently not intended to be used in production, although +//! it should generally already be a secure and fast server. It's deliberately +//! implemented with sync Rust to keep dependencies minimal and avoid async +//! runtimes, and to keep it as simple as possible. + +#![allow(clippy::doc_markdown)] +#![allow(clippy::missing_errors_doc)] + +pub mod handler; +pub mod http; +pub mod middleware; +pub mod router; +pub mod server; diff --git a/crates/zensical-serve/src/middleware.rs b/crates/zensical-serve/src/middleware.rs new file mode 100644 index 0000000..20a38bb --- /dev/null +++ b/crates/zensical-serve/src/middleware.rs @@ -0,0 +1,136 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Middleware. + +use std::fmt; + +use super::handler::Handler; +use super::http::{Request, Response}; + +mod convert; +mod files; +mod path; +mod websocket; + +pub use convert::TryIntoMiddleware; +pub use files::StaticFiles; +pub use path::{BasePath, NormalizePath, TrailingSlash}; +pub use websocket::WebSocketHandshake; + +// ---------------------------------------------------------------------------- +// Traits +// ---------------------------------------------------------------------------- + +/// Middleware. +/// +/// Middlewares are the building blocks of any composable request processing +/// pipeline. They can be used to modify, handle or answer a given [`Request`], +/// with a [`Response`], or forward it to the next [`Handler`], which can be +/// another middleware or the final handler. +/// +/// Note that a middleware consumes the request, which aligns with the idea of +/// a request moving through a pipeline. Besides closures which exactly match +/// the signature of [`Middleware::process`], this trait is implemented for +/// the following data types: +/// +/// - [`Stack`][]: Stack of middlewares. +/// - [`Router`][]: Router with parametrizable routes. +/// +/// [`Router`]: crate::router::Router +/// [`Stack`]: crate::handler::Stack +pub trait Middleware: 'static { + /// Processes the given request. + /// + /// This method is invoked with a request and is expected to either process + /// the request and return a response, or pass it on to the given handler. + /// Request processing is infallible, which means that errors must always + /// be handled gracefully, e.g., by returning a 404 response. + /// + /// # Examples + /// + /// This example shows how to implement a teapot middleware responding with + /// "418 I'm a Teapot" status code when the client tries to `GET /coffee`, + /// while passing all other requests to the next [`Handler`]. Note that for + /// routing, using [`Router`][] is usually a better choice. + /// + /// [`Router`]: crate::router::Router + /// + /// ``` + /// use zensical_serve::handler::{Handler, NotFound}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// use zensical_serve::middleware::Middleware; + /// + /// // Define middleware + /// struct Teapot; + /// + /// // Create middleware implementation + /// impl Middleware for Teapot { + /// fn process(&self, req: Request, next: &dyn Handler) -> Response { + /// if req.method == Method::Get && req.uri.path == "/coffee" { + /// Response::new().status(Status::ImATeapot) + /// } else { + /// next.handle(req) + /// } + /// } + /// } + /// + /// // Create request + /// let req = Request::new() + /// .method(Method::Get) + /// .uri("/coffee"); + /// + /// // Handle request with middleware + /// let res = Teapot.process(req, &NotFound); + /// assert_eq!(res.status, Status::ImATeapot); + /// ``` + fn process(&self, req: Request, next: &dyn Handler) -> Response; +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl fmt::Debug for Box { + /// Formats the middleware for debugging. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Box") + } +} + +// ---------------------------------------------------------------------------- +// Blanket implementations +// ---------------------------------------------------------------------------- + +impl Middleware for F +where + F: Fn(Request, &dyn Handler) -> R + 'static, + R: Into, +{ + #[inline] + fn process(&self, req: Request, next: &dyn Handler) -> Response { + self(req, next).into() + } +} diff --git a/crates/zensical-serve/src/middleware/convert.rs b/crates/zensical-serve/src/middleware/convert.rs new file mode 100644 index 0000000..bafe149 --- /dev/null +++ b/crates/zensical-serve/src/middleware/convert.rs @@ -0,0 +1,70 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Middleware. + +use super::Middleware; +use crate::handler::{Result, Scope}; + +// ---------------------------------------------------------------------------- +// Traits +// ---------------------------------------------------------------------------- + +/// Attempt conversion into [`Middleware`]. +pub trait TryIntoMiddleware: 'static { + /// Output type of conversion. + type Output: Middleware; + + /// Attempts to convert into a middleware. + /// + /// Since conversion can be fallible, it's a good idea to move validation + /// prior to middleware instantiation into this method. This allows to keep + /// the number of fallible methods as low as possible and allows for a more + /// fluent API, as well as better error handling. + /// + /// Although middlewares are usually boxed, we return a concrete type, as + /// it enables the compiler to employ monomorphization, if applicable. + /// + /// # Errors + /// + /// In case conversion fails, an error should be returned. + fn try_into_middleware(self, scope: &Scope) -> Result; +} + +// ---------------------------------------------------------------------------- +// Blanket implementations +// ---------------------------------------------------------------------------- + +impl TryIntoMiddleware for M +where + M: Middleware, +{ + type Output = Self; + + #[inline] + fn try_into_middleware(self, _scope: &Scope) -> Result { + Ok(self) + } +} diff --git a/crates/zensical-serve/src/middleware/files.rs b/crates/zensical-serve/src/middleware/files.rs new file mode 100644 index 0000000..3756299 --- /dev/null +++ b/crates/zensical-serve/src/middleware/files.rs @@ -0,0 +1,142 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Middleware for serving static files. + +use httpdate::parse_http_date; +use std::fs; +use std::io::Result; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use crate::handler::Handler; +use crate::http::response::ResponseExt; +use crate::http::{Header, Method, Request, Response, Status}; +use crate::middleware::Middleware; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Middleware for serving static files. +/// +/// Since the static files middleware is fallible during construction, we might +/// consider implementing [`TryIntoMiddleware`] for it later on. +/// +/// [`TryIntoMiddleware`]: crate::middleware::TryIntoMiddleware +pub struct StaticFiles { + /// Base path. + base: PathBuf, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl StaticFiles { + /// Creates a middleware for serving static files. + pub fn new

(path: P) -> Result + where + P: Into, + { + let path = path.into(); + path.canonicalize().map(|base| Self { base }) + } + + /// Handle fallback cases (file not found, wrong method, etc.) + fn fallback(&self, req: Request, next: &dyn Handler) -> Response { + let res = next.handle(req); + + // In case the path was not found, try to load `404.html` + if res.status == Status::NotFound { + let full = self.base.join("404.html"); + if let Ok(res) = Response::from_file(full) { + return res; + } + } + + // Otherwise, return original request + res + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Middleware for StaticFiles { + /// Processes the given request. + fn process(&self, req: Request, next: &dyn Handler) -> Response { + if !matches!(req.method, Method::Get | Method::Head) { + return self.fallback(req, next); + } + + // Remove leading slash from path. In case the path ends with a slash, + // add "index.html", so we can correctly resolve the associated file + let path = PathBuf::from(req.uri.path.trim_start_matches('/')); + let mut full = self.base.join(&path); + if req.uri.path.ends_with('/') { + full.push("index.html"); + } + + // Attempt to load file, or delegate to fallback + let Ok(mut res) = Response::from_file(&full) else { + return self.fallback(req, next); + }; + + // Ensure a date is always set, as required by HTTP/1.1 + res.headers + .insert(Header::Date, httpdate::fmt_http_date(SystemTime::now())); + + // In case we received a head request, remove body - we should rather + // make this more granular by just checking for the file + if req.method == Method::Head { + return res.body([]); + } + + // Try to obtain and parse header from request + let option = req.headers.get(Header::IfModifiedSince); + let Ok(header) = option.map(parse_http_date).transpose() else { + return res; + }; + + // In case we can both extract the date from the header and the file + // system lookup is successful, check if we can just return a 304 + if let (Some(date), Ok(meta)) = (header, fs::metadata(full)) { + if let Ok(mut last) = meta.modified() { + // Subtract one second to account for rounding issues + last -= Duration::from_secs(1); + if date >= last { + return Response::new() + .status(Status::NotModified) + .header(Header::ContentLength, 0); + } + } + } + + // Otherwise just return response + res + } +} diff --git a/crates/zensical-serve/src/middleware/path.rs b/crates/zensical-serve/src/middleware/path.rs new file mode 100644 index 0000000..4376220 --- /dev/null +++ b/crates/zensical-serve/src/middleware/path.rs @@ -0,0 +1,32 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Middleware for request path manipulations. + +mod base; +mod normalize; + +pub use base::BasePath; +pub use normalize::{NormalizePath, TrailingSlash}; diff --git a/crates/zensical-serve/src/middleware/path/base.rs b/crates/zensical-serve/src/middleware/path/base.rs new file mode 100644 index 0000000..0287069 --- /dev/null +++ b/crates/zensical-serve/src/middleware/path/base.rs @@ -0,0 +1,91 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! tbd + +use std::borrow::Cow; +use std::str::FromStr; + +use crate::handler::matcher::{Result, Route}; +use crate::handler::Handler; +use crate::http::response::ResponseExt; +use crate::http::{Request, Response, Uri}; +use crate::middleware::Middleware; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// tbd +pub struct BasePath { + // Base path. + base: Route, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl BasePath { + /// Creates a base path middleware. + pub fn new

(path: P) -> Result + where + P: AsRef, + { + Route::from_str(path.as_ref()) + .map_err(Into::into) + .map(|base| Self { base }) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Middleware for BasePath { + /// Processes the given request. + fn process(&self, mut req: Request, next: &dyn Handler) -> Response { + let base = self.base.as_str(); + if base == "/" { + return next.handle(req); + } + + // 1. Handle root redirect if enabled + if req.uri.path == "/" { + return Response::redirect(base); + } + + // 2. Strip prefix, if it exists + if req.uri.path.starts_with(base) { + req.uri = Uri::from_parts( + Cow::Owned(req.uri.path.trim_start_matches(base).to_string()), + req.uri.query, + ); + } + + // Forward with modified request + next.handle(req) + } +} diff --git a/crates/zensical-serve/src/middleware/path/normalize.rs b/crates/zensical-serve/src/middleware/path/normalize.rs new file mode 100644 index 0000000..a9f8965 --- /dev/null +++ b/crates/zensical-serve/src/middleware/path/normalize.rs @@ -0,0 +1,209 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Middleware for request path normalization. + +use std::path::Path; + +use crate::handler::Handler; +use crate::http::response::ResponseExt; +use crate::http::{Request, Response, Uri}; +use crate::middleware::Middleware; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Trailing slash behavior. +/// +/// This behavior determines how the [`NormalizePath`] middleware normalizes +/// request paths, appending or removing a trailing slash to them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TrailingSlash { + /// Append trailing slash. + Append, + /// Remove trailing slash. + Remove, +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Middleware for request path normalization. +/// +/// This middleware normalizes the request path according to the configured +/// trailing slash behavior. Using [`NormalizePath::default`] is recommended, +/// as it appends a trailing slash in case the requested resource is not a +/// file allowing the server to automatically serve directory indexes. +/// +/// # Examples +/// +/// ``` +/// # use std::error::Error; +/// # fn main() -> Result<(), Box> { +/// use zensical_serve::handler::{Handler, Stack, TryIntoHandler}; +/// use zensical_serve::http::{Header, Method, Request, Status}; +/// use zensical_serve::middleware::NormalizePath; +/// +/// // Create stack with middleware +/// let stack = Stack::new() +/// .with(NormalizePath::default()) +/// .try_into_handler()?; +/// +/// // Create request +/// let req = Request::new() +/// .method(Method::Get) +/// .uri("/coffee"); +/// +/// // Handle request with stack +/// let res = stack.handle(req); +/// assert_eq!(res.status, Status::Found); +/// assert_eq!(res.headers.get(Header::Location), Some("/coffee/")); +/// # Ok(()) +/// # } +/// ``` +pub struct NormalizePath { + /// Trailing slash behavior. + slash: TrailingSlash, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl NormalizePath { + /// Creates a middleware for request path normalization. + /// + /// Consider using [`NormalizePath::default`] in case you want to use the + /// recommended default behavior of appending a trailing slash, which is + /// required for directory index serving, i.e., returning the `index.html` + /// file in case a directory is requested. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::middleware::{NormalizePath, TrailingSlash}; + /// + /// // Create middleware + /// let middleware = NormalizePath::new(TrailingSlash::Append); + /// ``` + #[must_use] + pub fn new(slash: TrailingSlash) -> Self { + Self { slash } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Middleware for NormalizePath { + /// Processes the given request. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::{NotFound, Stack}; + /// use zensical_serve::http::{Header, Request, Status}; + /// use zensical_serve::middleware::{Middleware, NormalizePath}; + /// + /// // Create middleware + /// let middleware = NormalizePath::default(); + /// + /// // Create request + /// let req = Request::new() + /// .uri("/coffee"); + /// + /// // Handle request with middleware + /// let res = middleware.process(req, &NotFound); + /// assert_eq!(res.status, Status::Found); + /// assert_eq!(res.headers.get(Header::Location), Some("/coffee/")); + /// # Ok(()) + /// # } + /// ``` + fn process(&self, req: Request, next: &dyn Handler) -> Response { + // Create a path from a string reference, as it allows us to efficiently + // check if it has an extension, regardless of which slashes are used in + // file system paths. If it doesn't have an extension, it's either a + // directory on the filesystem, or may point to a registered route. + let path = Path::new(req.uri.path.as_ref()); + if req.uri.path == "/" || path.extension().is_some() { + return next.handle(req); + } + + // Depending on the trailing slash behavior, we need to check if the + // request path has a trailing slash. If it does not match the desired + // behavior, we send a redirect response to the client, instructing it + // to request the resource with the correct path. We deliberately do + // not send a "301 Moved Permanently" status code, as this would cause + // the client to cache the redirect indefinitely, which is not what we + // want. Additionally, this allows us to detect when links point to + // non-canonical URLs, e.g., to automatically fix them in the sources. + match (self.slash, req.uri.path.ends_with('/')) { + // Append slash and return redirect + (TrailingSlash::Append, false) => { + let mut path = req.uri.path.into_owned(); + path.push('/'); + Response::redirect(Uri::from_parts(path, req.uri.query)) + } + + // Remove slash and return redirect + (TrailingSlash::Remove, true) => { + let mut path = req.uri.path.into_owned(); + path.pop(); + Response::redirect(Uri::from_parts(path, req.uri.query)) + } + + // Pass through all other requests + _ => next.handle(req), + } + } +} + +// ---------------------------------------------------------------------------- + +impl Default for NormalizePath { + /// Creates a default middleware for request path normalization. + /// + /// By default, the middleware appends a trailing slash to the request path, + /// and returns it as part of a "302 Found" response. It's the recommended + /// default behavior, as it allows the server to handle requests for + /// directories and files in a consistent manner. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::middleware::NormalizePath; + /// + /// // Create middleware + /// let middleware = NormalizePath::default(); + /// ``` + fn default() -> Self { + Self { slash: TrailingSlash::Append } + } +} diff --git a/crates/zensical-serve/src/middleware/websocket.rs b/crates/zensical-serve/src/middleware/websocket.rs new file mode 100644 index 0000000..814126f --- /dev/null +++ b/crates/zensical-serve/src/middleware/websocket.rs @@ -0,0 +1,210 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Middleware for WebSocket handshakes. + +use base64::prelude::*; +use sha1_smol::Sha1; + +use crate::handler::Handler; +use crate::http::response::ResponseExt; +use crate::http::{Header, Method, Request, Response, Status}; + +use super::Middleware; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Middleware for WebSocket handshakes. +/// +/// This middleware handles the WebSocket handshake process, ensuring that +/// the request meets the necessary criteria for a successful upgrade to +/// WebSocket. It checks for the presence of required headers, validates +/// the method, and generates the appropriate response headers. +/// +/// # Examples +/// +/// ``` +/// # use std::error::Error; +/// # fn main() -> Result<(), Box> { +/// use zensical_serve::handler::{Handler, Stack, TryIntoHandler}; +/// use zensical_serve::http::{Header, Method, Request, Status}; +/// use zensical_serve::middleware::WebSocketHandshake; +/// +/// // Create stack with middleware +/// let stack = Stack::new() +/// .with(WebSocketHandshake::default()) +/// .try_into_handler()?; +/// +/// // Create request +/// let req = Request::new() +/// .method(Method::Get) +/// .header(Header::Connection, "Upgrade") +/// .header(Header::Upgrade, "websocket") +/// .header(Header::SecWebSocketKey, "dGhlIHNhbXBsZSBub25jZQ==") +/// .header(Header::SecWebSocketVersion, "13"); +/// +/// // Handle request with stack +/// let res = stack.handle(req); +/// assert_eq!(res.status, Status::SwitchingProtocols); +/// assert_eq!(res.headers.get(Header::Connection), Some("Upgrade")); +/// assert_eq!(res.headers.get(Header::Upgrade), Some("websocket")); +/// # Ok(()) +/// # } +/// ``` +#[derive(Default)] +pub struct WebSocketHandshake; + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl WebSocketHandshake { + /// Creates a middleware for WebSocket handshakes. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::middleware::WebSocketHandshake; + /// + /// // Create middleware + /// let middleware = WebSocketHandshake::new(); + /// ``` + #[must_use] + pub fn new() -> Self { + Self + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Middleware for WebSocketHandshake { + /// Processes the given request. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::{NotFound, Stack}; + /// use zensical_serve::http::{Header, Method, Request, Status}; + /// use zensical_serve::middleware::{Middleware, WebSocketHandshake}; + /// + /// // Create middleware + /// let middleware = WebSocketHandshake::default(); + /// + /// // Create request + /// let req = Request::new() + /// .method(Method::Get) + /// .header(Header::Connection, "Upgrade") + /// .header(Header::Upgrade, "websocket") + /// .header(Header::SecWebSocketKey, "dGhlIHNhbXBsZSBub25jZQ==") + /// .header(Header::SecWebSocketVersion, "13"); + /// + /// // Handle request with middleware + /// let res = middleware.process(req, &NotFound); + /// assert_eq!(res.status, Status::SwitchingProtocols); + /// assert_eq!(res.headers.get(Header::Connection), Some("Upgrade")); + /// assert_eq!(res.headers.get(Header::Upgrade), Some("websocket")); + /// # Ok(()) + /// # } + /// ``` + fn process(&self, req: Request, next: &dyn Handler) -> Response { + // Since we want to quickly forward requests that are not upgrades to + // the next handler, we first check for presence of the upgrade header + let Some(upgrade) = req.headers.get(Header::Upgrade) else { + return next.handle(req); + }; + + // We're only interested in WebSocket upgrades, so again, forward all + // other upgrade requests to the next handler. If the request is indeed + // a WebSocket upgrade, from here on, we check all preconditions, and + // return errors as per RFC in case they are not met. + if !upgrade.eq_ignore_ascii_case("websocket") { + return next.handle(req); + } + + // 1. Ensure method is GET + if req.method != Method::Get { + return Response::from_status(Status::MethodNotAllowed) + .header(Header::Allow, "GET"); + } + + // 2.1 Ensure connection header is present + let Some(connection) = req.headers.get(Header::Connection) else { + return Response::from_status(Status::BadRequest); + }; + + // 2.2 Ensure connection header contains upgrade + let mut iter = connection.split(',').map(str::trim); + if !iter.any(|value| value.eq_ignore_ascii_case("upgrade")) { + return Response::from_status(Status::BadRequest); + } + + // 3. Ensure WebSocket version is 13 + if Some("13") != req.headers.get(Header::SecWebSocketVersion) { + return Response::from_status(Status::UpgradeRequired) + .header(Header::Upgrade, "websocket") + .header(Header::SecWebSocketVersion, "13"); + } + + // 4. Ensure WebSocket key is present + let Some(key) = req.headers.get(Header::SecWebSocketKey) else { + return Response::from_status(Status::BadRequest); + }; + + // Return response for WebSocket handshake + let accept = generate_accept_key(key); + Response::new() + .status(Status::SwitchingProtocols) + .header(Header::Upgrade, "websocket") + .header(Header::Connection, "Upgrade") + .header(Header::SecWebSocketAccept, accept) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Generates the accept key for the WebSocket handshake. +/// +/// This follows RFC 6455 Section 4.2.2, which requires: +/// +/// 1. Concatenating the client key with the GUID +/// 2. Computing the SHA-1 hash of the result +/// 3. Base64 encoding the hash +fn generate_accept_key(key: K) -> String +where + K: AsRef<[u8]>, +{ + let mut hasher = Sha1::new(); + hasher.update(key.as_ref()); + hasher.update(b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + BASE64_STANDARD.encode(hasher.digest().bytes()) +} diff --git a/crates/zensical-serve/src/router.rs b/crates/zensical-serve/src/router.rs new file mode 100644 index 0000000..d05c61b --- /dev/null +++ b/crates/zensical-serve/src/router.rs @@ -0,0 +1,489 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Router. + +use std::str::FromStr; + +use super::handler::matcher::Route; +use super::handler::stack::{self, Stack}; +use super::handler::{Error, Result, Scope, TryIntoHandler}; +use super::http::Method; +use super::middleware::{Middleware, TryIntoMiddleware}; + +// Re-export for convenient usage with routers +pub use super::handler::matcher::Params; + +mod action; +mod routes; + +pub use action::Action; +use routes::Routes; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Builder. +/// +/// Routers are built using a combination of stacks and routes, which can be +/// combined into a single stack when converting with [`TryIntoMiddleware`]. +#[derive(Debug)] +enum Builder { + /// Stack builder. + Stack(stack::Builder), + /// Routes builder. + Routes(routes::Builder), +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Router. +/// +/// Routers allow to scope specific actions to a combination of HTTP methods +/// and path patterns, making them essentially a specialization of [`Stack`]. +/// Additionally, routers allow for the addition of middlewares, which are +/// grouped into stacks, and can be defined before and after routes. +#[derive(Debug)] +pub struct Router { + /// Builders. + builders: Vec, + /// Base path. + path: String, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Router { + /// Creates a router. + /// + /// The given path is prepended to all routes that are created as part of + /// the router. Using [`Router::default`] is equivalent to passing `/`. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::router::Router; + /// + /// // Create router + /// let router = Router::new("/"); + /// ``` + pub fn new

(path: P) -> Self + where + P: Into, + { + Self { + builders: Vec::new(), + path: path.into(), + } + } + + /// Adds a `GET` route to the router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .get("/", |req: Request, params: Params| { + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn get(self, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + self.route(Method::Get, path, action) + } + + /// Adds a `POST` route to the router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .post("/", |req: Request, params: Params| { + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn post(self, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + self.route(Method::Post, path, action) + } + + /// Adds a `PUT` route to the router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .put("/", |req: Request, params: Params| { + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn put(self, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + self.route(Method::Put, path, action) + } + + /// Adds a `DELETE` route to the router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .delete("/", |req: Request, params: Params| { + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn delete(self, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + self.route(Method::Delete, path, action) + } + + /// Adds a `PATCH` route to the router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .patch("/", |req: Request, params: Params| { + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn patch(self, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + self.route(Method::Patch, path, action) + } + + /// Adds a `HEAD` route to the router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .head("/", |req: Request, params: Params| { + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn head(self, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + self.route(Method::Head, path, action) + } + + /// Adds a `OPTIONS` route to the router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .options("/", |req: Request, params: Params| { + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn options(self, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + self.route(Method::Options, path, action) + } + + /// Adds a `TRACE` route to the router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::http::{Request, Response}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .trace("/", |req: Request, params: Params| { + /// Response::default() + /// }); + /// ``` + #[inline] + #[must_use] + pub fn trace(self, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + self.route(Method::Trace, path, action) + } + + /// Adds a middleware to the router. + /// + /// Middlewares can be added at any point in the router stack, including + /// before or after routes. This allows for flexible routing and middleware + /// combinations, as routes are themselves combines into middlewares, when + /// the router is converted into a middleware. + /// + /// Anything that can be converted into a [`Middleware`] can be added to + /// the stack, including middlewares, routers, stacks and closures. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::handler::Handler; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// use zensical_serve::router::Router; + /// + /// // Create router with middleware + /// let stack = Router::default() + /// .with(|req: Request, next: &dyn Handler| { + /// if req.method == Method::Get && req.uri.path == "/coffee" { + /// Response::new().status(Status::ImATeapot) + /// } else { + /// next.handle(req) + /// } + /// }); + /// ``` + #[must_use] + pub fn with(mut self, middleware: T) -> Self + where + T: TryIntoMiddleware, + { + // Consecutive middlewares are grouped into stacks, so we must ensure + // that the current item is a stack builder, and add the middleware + if let Some(Builder::Stack(builder)) = self.builders.last_mut() { + builder.add(middleware); + } else { + let mut builder = Stack::new(); + builder.add(middleware); + self.builders.push(Builder::Stack(builder)); + } + + // Return self for chaining + self + } + + /// Adds a route to the router. + fn route(mut self, method: Method, path: P, action: A) -> Self + where + P: Into, + A: Action, + { + // Consecutive routes are grouped into matchers, so we must ensure + // that the current item is a routes builder, and add the route + if let Some(Builder::Routes(builder)) = self.builders.last_mut() { + builder.add(method, path, action); + } else { + let mut builder = Routes::builder(); + builder.add(method, path, action); + self.builders.push(Builder::Routes(builder)); + } + + // Return self for chaining + self + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl TryIntoMiddleware for Router { + type Output = Stack; + + /// Attempts to convert the router into a middleware. + /// + /// # Errors + /// + /// In case conversion fails, an [`Error`][] is returned. + /// + /// [`Error`]: crate::handler::Error + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::Scope; + /// use zensical_serve::http::{Request, Response, Status}; + /// use zensical_serve::middleware::TryIntoMiddleware; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create scope + /// let scope = Scope::default(); + /// + /// // Create router and convert into middleware + /// let router = Router::default() + /// .get("/coffee", |req: Request, params: Params| { + /// Response::new().status(Status::ImATeapot) + /// }) + /// .try_into_middleware(&scope)?; + /// # Ok(()) + /// # } + /// ``` + fn try_into_middleware(self, scope: &Scope) -> Result { + let path = Route::from_str(&self.path) + .map_err(|err| Error::Matcher(err.into()))?; + + // Join the parent scope with the scope derived from the router's base + // path, which is then used for constructing routes and stacks + let scope = scope.join(path); + + // Transform builders into middlewares - routers can host builders for + // stacks and routes, both of which are converted into middlewares, and + // then collected into a stack that can be converted into a handler. + // Routes are validated and checked during conversion. + let iter = self.builders.into_iter().map(|item| match item { + // Convert stack into middleware + Builder::Stack(builder) => builder + .try_into_middleware(&scope) + .map(|middleware| Box::new(middleware) as Box), + + // Convert routes into middleware + Builder::Routes(builder) => builder + .try_into_middleware(&scope) + .map(|middleware| Box::new(middleware) as Box), + }); + + // Collect middlewares into a stack + iter.collect() + } +} + +impl TryIntoHandler for Router { + type Output = Stack; + + /// Attempts to convert the router into a handler. + /// + /// This method is equivalent to calling [`Router::try_into_middleware`] + /// with [`Scope::default`], scoping all middlewares and routes to `/`. + /// + /// # Errors + /// + /// In case conversion fails, an [`Error`][] is returned. + /// + /// [`Error`]: crate::handler::Error + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::TryIntoHandler; + /// use zensical_serve::http::{Request, Response, Status}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and convert into handler + /// let router = Router::default() + /// .get("/coffee", |req: Request, params: Params| { + /// Response::new().status(Status::ImATeapot) + /// }) + /// .try_into_handler()?; + /// # Ok(()) + /// # } + /// ``` + fn try_into_handler(self) -> Result { + let scope = Scope::default(); + self.try_into_middleware(&scope) + } +} + +// ---------------------------------------------------------------------------- + +impl Default for Router { + /// Creates a default router. + /// + /// # Examples + /// + /// ``` + /// use zensical_serve::router::Router; + /// + /// // Create router + /// let router = Router::default(); + /// ``` + fn default() -> Self { + Self { + builders: Vec::default(), + path: String::from("/"), + } + } +} diff --git a/crates/zensical-serve/src/router/action.rs b/crates/zensical-serve/src/router/action.rs new file mode 100644 index 0000000..3bdaede --- /dev/null +++ b/crates/zensical-serve/src/router/action.rs @@ -0,0 +1,115 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Action. + +use std::fmt; + +use crate::http::{Request, Response}; +use crate::router::Params; + +// ---------------------------------------------------------------------------- +// Traits +// ---------------------------------------------------------------------------- + +/// Action. +/// +/// If a route is matched, the registered action is called with the [`Request`] +/// and [`Params`], which were extracted from the route, if any. Currently, an +/// action is required to always return a [`Response`], which means it will be +/// considered the end of the processing chain. +/// +/// Of course it's possible to add middlewares after routes, but it's important +/// to understand that they are only executed if none of the routes matched. +pub trait Action: 'static { + /// Handles the given request with parameters. + /// + /// This method is invoked with a request and parameters and is required to + /// return a response. It must be infallible and should not panic. Note that + /// actions are rather an internal concept, which are automatically created + /// when registering routes in a [`Router`]. + /// + /// # Examples + /// + /// This example shows how to implement a teapot route responding with + /// "418 I'm a Teapot" status code when the client tries to `GET /coffee`, + /// while answering all other requests with "404 Not Found". This example + /// uses a [`Router`], the idiomatic method to implement routing. + /// + /// [`Router`]: crate::router::Router + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::{Handler, TryIntoHandler}; + /// use zensical_serve::http::{Method, Request, Response, Status}; + /// use zensical_serve::router::{Router, Params}; + /// + /// // Create router and add route + /// let router = Router::default() + /// .get("/coffee", |req: Request, params: Params| { + /// Response::new().status(Status::ImATeapot) + /// }) + /// .try_into_handler()?; + /// + /// // Create request + /// let req = Request::new() + /// .method(Method::Get) + /// .uri("/coffee"); + /// + /// // Handle request with router + /// let res = router.handle(req); + /// assert_eq!(res.status, Status::ImATeapot); + /// # Ok(()) + /// # } + /// ``` + fn handle(&self, req: Request, params: Params) -> Response; +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl fmt::Debug for Box { + /// Formats the action for debugging. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Box") + } +} + +// ---------------------------------------------------------------------------- +// Blanket implementations +// ---------------------------------------------------------------------------- + +impl Action for F +where + F: Fn(Request, Params) -> R + 'static, + R: Into, +{ + #[inline] + fn handle(&self, req: Request, params: Params) -> Response { + self(req, params).into() + } +} diff --git a/crates/zensical-serve/src/router/routes.rs b/crates/zensical-serve/src/router/routes.rs new file mode 100644 index 0000000..d46be93 --- /dev/null +++ b/crates/zensical-serve/src/router/routes.rs @@ -0,0 +1,106 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Routes. + +use std::collections::BTreeMap; + +use crate::handler::matcher::{Match, Matcher}; +use crate::handler::Handler; +use crate::http::{Method, Request, Response}; +use crate::middleware::Middleware; + +use super::action::Action; + +mod builder; + +pub use builder::Builder; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Routes. +/// +/// Matchers are compiled from a set of routes, which are stored in a tree-like +/// structure, implemented as part of the [`matchit`] crate. Each set of routes +/// is scoped to a specific request method, which is used to determine what to +/// check for when a request is received. +#[derive(Debug)] +pub struct Routes { + /// Map methods to matchers. + matchers: BTreeMap>>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Routes { + /// Creates a routes builder. + #[must_use] + pub fn builder() -> Builder { + Builder::new() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Middleware for Routes { + /// Processes the given request. + /// + /// This method matches a given request against all registered routes. If + /// a match is found, the corresponding action is called. If not, it is + /// forwarded to the next handler, which can be another middleware or the + /// final handler in the processing chain. + fn process(&self, req: Request, next: &dyn Handler) -> Response { + if let Some(routes) = self.matchers.get(&req.method) { + // If path is borrowed, which is the normal case for parsing, this + // will only clone the reference, not the contents of the string + let path = req.uri.path.clone(); + + // Next, we canonicalize the path by removing the trailing slash if + // it's not the root path, as the path might have been normalized. + // This is because the matcher doesn't support optional trailing + // slashes, so routes are never allowed to end with a slash. + let path = if path == "/" { + path.as_ref() + } else { + path.trim_end_matches('/') + }; + + // Finally, we resolve the path against the matcher, and invoke the + // corresponding action if it matches a registered route + if let Some(Match { data: action, params }) = routes.resolve(path) { + return action.handle(req, params); + } + } + + // Forward to next handler + next.handle(req) + } +} diff --git a/crates/zensical-serve/src/router/routes/builder.rs b/crates/zensical-serve/src/router/routes/builder.rs new file mode 100644 index 0000000..668d72a --- /dev/null +++ b/crates/zensical-serve/src/router/routes/builder.rs @@ -0,0 +1,117 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Routes builder. + +use std::collections::BTreeMap; +use std::str::FromStr; + +use crate::handler::{Error, Matcher, Result, Scope}; +use crate::http::Method; +use crate::middleware::TryIntoMiddleware; +use crate::router::{Action, Route}; + +use super::Routes; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Routes builder. +#[allow(clippy::type_complexity)] +#[derive(Debug)] +pub struct Builder { + /// Map methods to routes. + routes: BTreeMap)>>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Builder { + /// Creates a routes builder. + #[allow(clippy::new_without_default)] + #[must_use] + pub fn new() -> Self { + Self { routes: BTreeMap::new() } + } + + /// Adds a route to the routes. + /// + /// Note that this method is infallible, as routes are converted into paths + /// when building the matchers, not when they're added. This is particularly + /// necessary for the [`matchit`] crate, which requires all routes to be + /// unique, but also allows for a more streamlined API. + pub fn add(&mut self, method: Method, path: P, action: A) + where + P: Into, + A: Action, + { + self.routes + .entry(method) + .or_default() + .push((path.into(), Box::new(action))); + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl TryIntoMiddleware for Builder { + type Output = Routes; + + /// Attempts to convert the routes into a middleware. + fn try_into_middleware(self, scope: &Scope) -> Result { + // Obtain the matcher's base path from the given scope, and prepend it + // to all routes, allowing for the creation of nested routers + let base = match scope.route.as_ref() { + Some(route) => route, + None => &Route::default(), + }; + + // Transform all registered routes into a single router for each method, + // after checking whether each route is valid and does not overlap with + // any other route. Note that non-overlap is checked by the third-party + // router, which is a requirement for its matching algorithm. + let iter = self.routes.into_iter().map(|(method, items)| { + let mut matcher = Matcher::new(); + for (path, action) in items { + let path = Route::from_str(&path) + .map_err(|err| Error::Matcher(err.into()))?; + + // Join the matcher's base path with the route path and add it + // to the matcher, associating it with the registered action + matcher.add(base.append(path), action)?; + } + Ok((method, matcher)) + }); + + // Collect methods and routes into an ordered map + iter.collect::>>() + .map(|routes| Routes { matchers: routes }) + } +} diff --git a/crates/zensical-serve/src/server.rs b/crates/zensical-serve/src/server.rs new file mode 100644 index 0000000..da04e43 --- /dev/null +++ b/crates/zensical-serve/src/server.rs @@ -0,0 +1,285 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP server. + +use crossbeam::channel::{Receiver, TryRecvError}; +use mio::net::{TcpListener, TcpStream}; +use mio::{Interest, Token, Waker}; +use slab::Slab; +use std::io::ErrorKind; +use std::net::ToSocketAddrs; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tungstenite::protocol::Role; +use tungstenite::{Message, WebSocket}; + +use super::handler::{Handler, TryIntoHandler}; +use super::server::connection::{Connection, Signal, Upgrade}; + +mod builder; +mod connection; +mod error; +mod poller; + +pub use builder::Builder; +pub use error::{Error, Result}; +use poller::Poller; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP server. +/// +/// This implementation is still experimental and subject to change – it's not +/// finished yet, but should already work reliably for previews and reloads. We +/// plan to rework connection handling in the future once we start working on +/// additional server features. +pub struct Server +where + H: Handler, +{ + /// Handler for incoming requests. + handler: H, + /// Poller for I/O events. + events: Poller, + /// Acceptors for incoming connections. + acceptors: Vec, + /// HTTP connections. + connections: Slab, + /// WebSocket clients. + clients: Slab>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Server +where + H: Handler, +{ + /// Creates a server. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::Teapot; + /// use zensical_serve::server::Server; + /// + /// // Create server + /// let server = Server::new(Teapot, "127.0.0.1:8080")?; + /// # Ok(()) + /// # } + /// ``` + #[inline] + pub fn new(handler: T, addr: A) -> Result + where + T: TryIntoHandler, + A: ToSocketAddrs, + { + Self::builder(handler)?.bind(addr)?.listen() + } + + /// Creates a server builder. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::Teapot; + /// use zensical_serve::server::Server; + /// + /// // Create server builder + /// let mut builder = Server::builder(Teapot)?; + /// # Ok(()) + /// # } + /// ``` + #[inline] + pub fn builder(handler: T) -> Result> + where + T: TryIntoHandler, + { + Builder::new(handler) + } + + /// Polls the server for incoming events. + /// + /// The receiver is used to get notifications about file changes. + #[allow(clippy::too_many_lines)] + #[inline] + pub fn poll( + &mut self, receiver: Option<&Receiver>, + ) -> Result { + self.events.poll(Some(Duration::from_secs(10)))?; + + // Check if we need to clean up timed out connections + let now = Instant::now(); + let mut timed_out = Vec::new(); + + // Collect timed out connections + for (n, conn) in &self.connections { + if conn.is_timed_out(now) { + timed_out.push(n); + } + } + + // Clean up timed out connections + for n in timed_out { + if let Some(conn) = self.connections.try_remove(n) { + let mut socket = conn.into_socket(); + self.events.deregister(&mut socket)?; + } + } + + // Handle events + let start = self.acceptors.len(); + for event in &self.events { + let token = event.token(); + let n: usize = token.into(); + + // Received a waker event + if n == usize::MAX { + if let Some(receiver) = receiver { + loop { + match receiver.try_recv() { + Ok(path) => { + self.clients.retain(|_, socket| { + socket + .send(Message::Text( + path.clone().into(), + )) + .is_ok() + }); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + return Err(Error::Disconnected); + } + } + } + } + continue; + } + + // Check if the event is for an acceptor or a connection + if let Some(acceptor) = self.acceptors.get(n) { + // Accept new connections - note that we need to run this in a + // loop, as browsers might open several new connections at once + loop { + match acceptor.accept() { + Ok((socket, _addr)) => { + let n = self + .connections + .insert(Connection::new(socket)); + self.events.register( + self.connections[n].socket(), + Token(start + n), + Interest::READABLE, + )?; + } + + // Everything else except would block is an error + Err(err) => { + if err.kind() != ErrorKind::WouldBlock { + eprintln!("Accept error: {err}"); + } + break; + } + } + } + } else if let Some(conn) = self.connections.get_mut(n - start) { + // Collect signals to process, which we do after processing all + // events in order make the borrow checker happy + let mut signals = Vec::new(); + if event.is_readable() { + signals.push((conn.read(&self.handler)?, n)); + } + if event.is_writable() { + signals.push((conn.write()?, n)); + } + + // Handle signals after reading or writing on the socket - this + // tells us what to do next with the connection + for (signal, n) in signals { + match signal { + // Change of interest - reregister with poller + Signal::Interest(mut interest) => { + let conn = &mut self.connections[n - start]; + if conn.is_writing() { + interest |= Interest::WRITABLE; + } + self.events.reregister( + conn.socket(), + Token(n), + interest, + )?; + } + + // Close connection and deregister from poller + Signal::Close => { + let conn = self.connections.remove(n - start); + let mut socket = conn.into_socket(); + self.events.deregister(&mut socket)?; + } + + // Upgrade connection + Signal::Upgrade(upgrade) => { + let Upgrade::WebSocket(config) = upgrade; + + // Remove connection from HTTP pool and handle as + // a WebSocket from now on. We currently don't + // support listening on WebSockets, but we'll add + // that later once we work on browser communication. + let conn = self.connections.remove(n - start); + let mut socket = conn.into_socket(); + self.events.deregister(&mut socket)?; + self.clients.insert(WebSocket::from_raw_socket( + socket, + Role::Server, + Some(config), + )); + } + + // Continue without changes + Signal::Continue => {} + } + } + } + } + + // Keep on polling + Ok(true) + } + + // Return waker for waking server from poll loop + pub fn waker(&self) -> Arc { + self.events.waker().clone() + } +} diff --git a/crates/zensical-serve/src/server/builder.rs b/crates/zensical-serve/src/server/builder.rs new file mode 100644 index 0000000..a17648e --- /dev/null +++ b/crates/zensical-serve/src/server/builder.rs @@ -0,0 +1,159 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP server builder. + +use mio::net::TcpListener; +use mio::{Interest, Token}; +use slab::Slab; +use std::net::{SocketAddr, ToSocketAddrs}; + +use crate::handler::{Handler, TryIntoHandler}; + +use super::poller::Poller; +use super::{Error, Result, Server}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP server builder. +pub struct Builder { + /// Handler for incoming requests. + handler: H, + /// Socket addresses to bind to. + addrs: Vec, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Builder +where + H: Handler, +{ + /// Creates a server builder. + /// + /// Note that the canonical way to create a [`Server`] is to invoke the + /// [`Server::builder`] method, which creates an instance of [`Builder`]. + /// However, if only a single address needs to be bound, it can be done + /// directly using the [`Server::new`] method. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::Teapot; + /// use zensical_serve::server::Builder; + /// + /// // Create server builder + /// let mut builder = Builder::new(Teapot)?; + /// # Ok(()) + /// # } + /// ``` + pub fn new(handler: T) -> Result + where + T: TryIntoHandler, + { + handler + .try_into_handler() + .map_err(Into::into) + .map(|handler| Self { handler, addrs: Vec::new() }) + } + + /// Adds a socket address to bind to. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::Teapot; + /// use zensical_serve::server::Builder; + /// + /// // Create server builder and add address + /// let mut builder = Builder::new(Teapot)?; + /// builder.bind("127.0.0.1:8080")?; + /// # Ok(()) + /// # } + /// ``` + #[inline] + pub fn bind(mut self, addr: A) -> Result + where + A: ToSocketAddrs, + { + addr.to_socket_addrs().map_err(Into::into).map(|addrs| { + self.addrs.extend(addrs); + self + }) + } + + /// Creates the server and binds to the configured addresses. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_serve::handler::Teapot; + /// use zensical_serve::server::Builder; + /// + /// // Create server builder and bind to address + /// let mut builder = Builder::new(Teapot)?; + /// let server = builder + /// .bind("127.0.0.1:8080")? + /// .listen()?; + /// # Ok(()) + /// # } + /// ``` + pub fn listen(self) -> Result> { + if self.addrs.is_empty() { + return Err(Error::NoAddress); + } + + // Create a new poller, then bind listeners to all configured addresses, + // register them for event notifications, and create and return server + Poller::new().and_then(|poller| { + let iter = self.addrs.into_iter().enumerate(); + let iter = iter.map(|(n, addr)| { + let mut listener = TcpListener::bind(addr)?; + poller + .register(&mut listener, Token(n), Interest::READABLE) + .map(|()| listener) + }); + + // Collect listeners from iterator and return server + iter.collect::>().map(|acceptors: Vec<_>| Server { + handler: self.handler, + events: poller, + acceptors, + connections: Slab::new(), + clients: Slab::new(), + }) + }) + } +} diff --git a/crates/zensical-serve/src/server/connection.rs b/crates/zensical-serve/src/server/connection.rs new file mode 100644 index 0000000..3b3d326 --- /dev/null +++ b/crates/zensical-serve/src/server/connection.rs @@ -0,0 +1,285 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP connection. + +use mio::net::TcpStream; +use mio::Interest; +use std::io::{Cursor, ErrorKind, Read, Write}; +use std::mem; +use std::time::Instant; +use tungstenite::protocol::WebSocketConfig; + +use crate::handler::Handler; +use crate::http::request::Error; +use crate::http::response::ResponseExt; +use crate::http::{Request, Response, Status}; +use crate::server::Result; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Connection action after handling an event +pub enum Signal { + /// Continue with the specified interest. + Interest(Interest), + /// Continue without changing the current interest. + Continue, + /// Upgrade the connection. + Upgrade(Upgrade), + /// Connection was closed. + Close, +} + +/// Connection upgrade. +#[derive(Debug)] +pub enum Upgrade { + /// Upgrade to WebSocket. + WebSocket(WebSocketConfig), +} + +// ---------------------------------------------------------------------------- + +/// Internal buffer state. +#[derive(Debug)] +enum Buffer { + /// Currently reading data. + Reading(Vec), + /// Currently writing data, with optional upgrade. + Writing(Cursor>, Option), +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// HTTP connection. +#[derive(Debug)] +pub struct Connection { + /// TCP socket. + socket: TcpStream, + /// Read/write buffer. + buffer: Buffer, + /// Last activity time. + time: Instant, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Connection { + /// Creates a connection. + pub fn new(socket: TcpStream) -> Self { + Connection { + socket, + buffer: Buffer::Reading(Vec::new()), + time: Instant::now(), + } + } + + /// Consumes the connection and returns the underlying socket. + pub fn into_socket(self) -> TcpStream { + self.socket + } + + /// Returns a mutable reference to the underlying socket. + pub fn socket(&mut self) -> &mut TcpStream { + &mut self.socket + } + + /// Attempt to read data from the socket. + #[allow(clippy::unnecessary_wraps)] + pub fn read(&mut self, handler: &H) -> Result + where + H: Handler, + { + if let Buffer::Reading(buffer) = &mut self.buffer { + self.time = Instant::now(); + // We try to read all remaining data - if the connection would + // block, we return and wait for the next readable event + let (res, upgrade) = { + let mut temp = [0u8; 1024]; + match self.socket.read(&mut temp) { + Ok(0) => { + return Ok(Signal::Close); + } + + // If we successfully read (some) bytes, try to parse and + // handle the request, or otherwise continue reading + Ok(bytes) => { + buffer.extend_from_slice(&temp[..bytes]); + match Request::from_bytes(buffer) { + // Request was parsed successfully, which means we + // process it, and switch to writing in order to + // return the response to the client. We also check + // if we need to switch protocols. + Ok(req) => { + let res = handler.handle(req); + let upgrade = (res.status + == Status::SwitchingProtocols) + .then_some(Upgrade::WebSocket( + WebSocketConfig::default(), + )); + (res, upgrade) + } + + // Request could not be parsed, as it is incomplete, + // so we keep reading + Err(Error::Incomplete) => { + return Ok(Signal::Interest( + Interest::READABLE, + )); + } + + // In case there was a validation error, return it + Err(Error::Validation(status)) => { + let res = Response::from_status(status); + (res, None) + } + + // If there was another parsing error, return 400 + Err(_) => { + let res = + Response::from_status(Status::BadRequest); + (res, None) + } + } + } + + // If the connection would block, return and wait for the + // next writable event to be available. + Err(err) if err.kind() == ErrorKind::WouldBlock => { + return Ok(Signal::Continue); + } + + // In case of other errors, close the connection - for now + // well just print the error, and add proper handling later + Err(err) => { + match err.kind() { + ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::BrokenPipe + | ErrorKind::UnexpectedEof => { + // All of those are expected errors, so we just + // fall through here without printing anything + } + _ => { + eprintln!("Error: {err}"); + } + } + return Ok(Signal::Close); + } + } + }; + + // If we've processed all data, check if the request was an upgrade, + // and if so, remember it to switch to the WebSocket protocol. + let _ = mem::replace( + &mut self.buffer, + Buffer::Writing(Cursor::new(res.into_bytes()), upgrade), + ); + } + + // Switch back to writing state + Ok(Signal::Interest(Interest::WRITABLE)) + } + + /// Attempt to write data to the socket. + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::unnecessary_wraps)] + pub fn write(&mut self) -> Result { + if let Buffer::Writing(cursor, _) = &mut self.buffer { + self.time = Instant::now(); + // We try to write all remaining data - if the connection would + // block, we return and wait for the next writable event + loop { + let pos = cursor.position() as usize; + if pos >= cursor.get_ref().len() { + break; + } + + // Attempt to write remaining bytes + let buffer = cursor.get_ref(); + match self.socket.write(&buffer[pos..]) { + Ok(0) => { + return Ok(Signal::Close); + } + + // If we successfully wrote some bytes, update the position + // and continue writing if there's more to send + Ok(bytes) => { + cursor.set_position((pos + bytes) as u64); + } + + // If the connection would block, return and wait for the + // next writable event to be available. + Err(err) if err.kind() == ErrorKind::WouldBlock => { + return Ok(Signal::Continue); + } + + // In case of other errors, close the connection - for now + // well just print the error, and add proper handling later + Err(err) => { + match err.kind() { + ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::BrokenPipe + | ErrorKind::UnexpectedEof => { + // All of those are expected errors, so we just + // fall through here without printing anything + } + _ => { + eprintln!("Error: {err}"); + } + } + } + } + } + } + + // If we've written all data, check if the request was an upgrade, and + // if so, return it to switch to the WebSocket protocol. + let buffer = + mem::replace(&mut self.buffer, Buffer::Reading(Vec::new())); + if let Buffer::Writing(_, Some(upgrade)) = buffer { + return Ok(Signal::Upgrade(upgrade)); + } + + // Switch back to reading state + Ok(Signal::Interest(Interest::READABLE)) + } + + /// Returns whether the connection is currently writing data. + pub fn is_writing(&self) -> bool { + matches!(self.buffer, Buffer::Writing(_, _)) + } + + /// Check if connection has timed out + pub fn is_timed_out(&self, now: Instant) -> bool { + now.duration_since(self.time).as_secs() > 30 + } +} diff --git a/crates/zensical-serve/src/server/error.rs b/crates/zensical-serve/src/server/error.rs new file mode 100644 index 0000000..26ddeed --- /dev/null +++ b/crates/zensical-serve/src/server/error.rs @@ -0,0 +1,75 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! HTTP server error. + +use std::{io, result}; +use thiserror::Error; + +use crate::handler; +use crate::http::{request, response}; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// HTTP server error. +#[derive(Debug, Error)] +pub enum Error { + /// I/O error. + #[error(transparent)] + Io(#[from] io::Error), + + /// Tungstenite error. + #[error(transparent)] + Tungstenite(#[from] tungstenite::Error), + + /// HTTP request error. + #[error(transparent)] + Request(#[from] request::Error), + + /// HTTP response error. + #[error(transparent)] + Response(#[from] response::Error), + + /// Handler error. + #[error(transparent)] + Handler(#[from] handler::Error), + + /// Missing bind address. + #[error("missing bind address")] + NoAddress, + + /// Client disconnected. + #[error("client disconnected")] + Disconnected, +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// HTTP server result. +pub type Result = result::Result; diff --git a/crates/zensical-serve/src/server/poller.rs b/crates/zensical-serve/src/server/poller.rs new file mode 100644 index 0000000..a9c96a3 --- /dev/null +++ b/crates/zensical-serve/src/server/poller.rs @@ -0,0 +1,168 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Poller for I/O events. + +use mio::event::{Event, Iter, Source}; +use mio::{Events, Interest, Poll, Token, Waker}; +use std::sync::Arc; +use std::time::Duration; + +use super::error::Result; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Poller for I/O events. +pub struct Poller { + /// Poll instance. + poll: Poll, + /// Event queue. + events: Events, + /// Waker. + waker: Arc, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Poller { + /// Creates a poller. + pub fn new() -> Result { + Self::with_capacity(1024) + } + + /// Creates a poller with the given capacity. + pub fn with_capacity(capacity: usize) -> Result { + let res = Poll::new().and_then(|poll| { + // The poller could be successfully created, so we can create the + // waker by using the last token, which allows us to map connection + // tokens to slab indices more easily, so we can efficiently manage + // connections without further lookups + let token = Token(usize::MAX); + Waker::new(poll.registry(), token).map(|waker| Self { + waker: Arc::new(waker), + events: Events::with_capacity(capacity), + poll, + }) + }); + + // Return poller or convert error + res.map_err(Into::into) + } + + /// Register a source for polling. + #[inline] + pub fn register( + &self, source: &mut S, token: Token, interest: Interest, + ) -> Result + where + S: Source, + { + self.poll + .registry() + .register(source, token, interest) + .map_err(Into::into) + } + + /// Register a source for polling. + #[inline] + pub fn reregister( + &self, source: &mut S, token: Token, interest: Interest, + ) -> Result + where + S: Source, + { + self.poll + .registry() + .reregister(source, token, interest) + .map_err(Into::into) + } + + /// Register a source for polling. + #[inline] + pub fn deregister(&self, source: &mut S) -> Result + where + S: Source, + { + self.poll // fmt + .registry() + .deregister(source) + .map_err(Into::into) + } + + /// Waits for readiness events and returns the poller. + #[inline] + pub fn poll(&mut self, timeout: Option) -> Result { + self.poll + .poll(&mut self.events, timeout) + .map_err(Into::into) + } + + /// Returns the waker. + #[inline] + #[must_use] + pub fn waker(&self) -> Arc { + self.waker.clone() + } + + /// Returns an iterator over the events. + #[inline] + pub fn iter(&self) -> Iter<'_> { + self.events.iter() + } +} + +#[allow(clippy::must_use_candidate)] +impl Poller { + /// Returns the number of events. + #[inline] + pub fn len(&self) -> usize { + self.events.iter().count() + } + + /// Returns whether there are any events. + #[inline] + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl<'a> IntoIterator for &'a Poller { + type Item = &'a Event; + type IntoIter = Iter<'a>; + + /// Returns an iterator over the events. + #[inline] + fn into_iter(self) -> Iter<'a> { + self.iter() + } +} diff --git a/crates/zensical-watch/Cargo.toml b/crates/zensical-watch/Cargo.toml new file mode 100644 index 0000000..bee5fec --- /dev/null +++ b/crates/zensical-watch/Cargo.toml @@ -0,0 +1,53 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +[package] +name = "zensical-watch" +version = "0.0.0" +description = "Resilient file watcher" +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +ahash.workspace = true +crossbeam.workspace = true +file-id.workspace = true +notify.workspace = true +pyo3.workspace = true +thiserror.workspace = true +tracing = { workspace = true, optional = true } +walkdir.workspace = true +zrx.workspace = true + +[features] +default = [] +tracing = ["dep:tracing"] diff --git a/crates/zensical-watch/src/agent.rs b/crates/zensical-watch/src/agent.rs new file mode 100644 index 0000000..67338f7 --- /dev/null +++ b/crates/zensical-watch/src/agent.rs @@ -0,0 +1,213 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File agent. + +use crossbeam::channel::{unbounded, Sender}; +use std::path::{Path, PathBuf}; +use std::thread::{Builder, JoinHandle}; +use std::time::Duration; +use std::{fmt, fs}; + +mod error; +pub mod event; +mod handler; +mod manager; +mod monitor; + +pub use error::{Error, Result}; +pub use event::Event; +pub use handler::Handler; +pub use manager::Manager; +pub use monitor::{Kind, Monitor}; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// File agent action. +#[derive(Debug)] +pub enum Action { + /// Watch path. + Watch(PathBuf), + /// Unwatch path. + Unwatch(PathBuf), + // /// Refresh path. + // Refresh(PathBuf), +} + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// File agent. +pub struct Agent { + /// Debounce timeout. + timeout: Duration, + /// Action sender. + sender: Sender, + /// Join handle for the agent thread. + thread: JoinHandle, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Agent { + /// Creates a file agent. + /// + /// # Panics + /// + /// Panics if thread creation fails. + pub fn new(timeout: Duration, f: F) -> Self + where + F: FnMut(Result) -> Result + Send + 'static, + { + let (sender, receiver) = unbounded(); + let h = move || -> Result<()> { + let mut handler = Handler::builder() + .receiver(receiver) + .handler(f) + .monitor(Monitor::default()) + .build()?; + + // Start event loop, which will automatically exit when the file + // agent is dropped, since the sender disconnects the receiver + loop { + handler.handle(timeout)?; + } + }; + + // We deliberately use unwrap here, as the capability to spawn threads + // is a fundamental requirement of the file agent + let thread = Builder::new() + .name(String::from("zrx/monitor")) + .spawn(h) + .unwrap(); + + // Return file agent + Self { timeout, sender, thread } + } + + /// Watches the given path. + /// + /// This method submits an [`Action`] to watch the given path, which is + /// processed in the next iteration of the agent's event loop. + /// + /// # Errors + /// + /// If action submission fails, [`Error::Disconnected`] is returned. This + /// can practically never happen, as the channel is dropped on shutdown. + /// Other than that, the given path must exist and be accessible, as it is + /// canonicalized before being processed. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use std::time::Duration; + /// use zensical_watch::Agent; + /// + /// // Create file agent and start watching + /// let agent = Agent::new(Duration::from_millis(20), |event| { + /// println!("Event: {:?}", event); + /// Ok(()) + /// }); + /// agent.watch(".")?; + /// # Ok(()) + /// # } + /// ``` + pub fn watch

(&self, path: P) -> Result + where + P: AsRef, + { + self.sender + .send(Action::Watch(fs::canonicalize(path)?)) + .map_err(Into::into) + } + + /// Unwatches the given path. + /// + /// This method submits an [`Action`] to unwatch the given path, which is + /// processed in the next iteration of the file agent's event loop. + /// + /// # Errors + /// + /// If action submission fails, [`Error::Disconnected`] is returned. This + /// can practically never happen, as the channel is dropped on shutdown. + /// Other than that, the given path must exist and be accessible, as it is + /// canonicalized before being processed. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use std::time::Duration; + /// use zensical_watch::Agent; + /// + /// // Create file agent and start watching + /// let agent = Agent::new(Duration::from_millis(20), |event| { + /// println!("Event: {:?}", event); + /// Ok(()) + /// }); + /// agent.watch(".")?; + /// + /// // Stop watching + /// agent.unwatch(".")?; + /// # Ok(()) + /// # } + /// ``` + pub fn unwatch

(&self, path: P) -> Result + where + P: AsRef, + { + self.sender + .send(Action::Unwatch(fs::canonicalize(path)?)) + .map_err(Into::into) + } + + /// Checks whether the agent thread has terminated. + #[must_use] + pub fn is_terminated(&self) -> bool { + self.thread.is_finished() + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl fmt::Debug for Agent { + /// Formats the file agent for debugging. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Agent") + .field("timeout", &self.timeout) + .field("pending", &self.sender.len()) + .finish_non_exhaustive() + } +} diff --git a/crates/zensical-watch/src/agent/error.rs b/crates/zensical-watch/src/agent/error.rs new file mode 100644 index 0000000..a82c9e1 --- /dev/null +++ b/crates/zensical-watch/src/agent/error.rs @@ -0,0 +1,118 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File agent error. + +use crossbeam::channel::{RecvError, SendError}; +use pyo3::exceptions::{PyIOError, PyRuntimeError}; +use pyo3::PyErr; +use std::{io, result}; +use thiserror::Error; +use zrx::scheduler::session; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// File agent error. +#[derive(Debug, Error)] +pub enum Error { + /// I/O error. + #[error(transparent)] + Io(#[from] io::Error), + + /// Notify error. + #[error(transparent)] + Notify(#[from] notify::Error), + + /// Walk directory error. + #[error(transparent)] + WalkDir(#[from] walkdir::Error), + + /// Watcher disconnected. + #[error("watcher disconnected")] + Disconnected, +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl From> for Error { + /// Creates an error from a send error. + #[inline] + fn from(_: SendError) -> Self { + Error::Disconnected + } +} + +impl From for Error { + /// Creates an error from a receive error. + #[inline] + fn from(_: RecvError) -> Self { + Error::Disconnected + } +} + +impl From for Error { + /// Creates an error from a session error. + #[inline] + fn from(_: session::Error) -> Self { + Error::Disconnected + } +} + +// ---------------------------------------------------------------------------- + +impl From for PyErr { + /// Converts a file agent error to a Python error. + #[inline] + fn from(value: Error) -> Self { + match value { + Error::Io(err) => { + let message = format!("I/O error: {err}"); + PyIOError::new_err(message) + } + Error::Notify(err) => { + let message = format!("Notify error: {err}"); + PyRuntimeError::new_err(message) + } + Error::WalkDir(err) => { + let message = format!("I/O error: {err}"); + PyIOError::new_err(message) + } + Error::Disconnected => { + PyRuntimeError::new_err("Watcher disconnected") + } + } + } +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// File agent result. +pub type Result = result::Result; diff --git a/crates/zensical-watch/src/agent/event.rs b/crates/zensical-watch/src/agent/event.rs new file mode 100644 index 0000000..1fe4295 --- /dev/null +++ b/crates/zensical-watch/src/agent/event.rs @@ -0,0 +1,132 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File event. + +use std::fs::FileType; +use std::path::PathBuf; +use std::sync::Arc; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// File kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// File. + File, + /// Folder. + Folder, + /// Symbolic link. + Link, +} + +// ---------------------------------------------------------------------------- + +/// File event. +#[derive(Clone, Debug)] +pub enum Event { + /// Creation event. + Create { + /// File kind. + kind: Kind, + /// File path. + path: Arc, + }, + + /// Modification event. + Modify { + /// File kind. + kind: Kind, + /// File path. + path: Arc, + }, + + /// Rename event. + Rename { + /// File kind. + kind: Kind, + /// File source path. + from: Arc, + /// File target path. + to: Arc, + }, + + /// Removal event. + Remove { + /// File kind. + kind: Kind, + /// File path. + path: Arc, + }, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Event { + /// Returns the file kind of the event. + #[must_use] + pub fn kind(&self) -> Kind { + match self { + Event::Create { kind, .. } => *kind, + Event::Modify { kind, .. } => *kind, + Event::Rename { kind, .. } => *kind, + Event::Remove { kind, .. } => *kind, + } + } + /// Returns the file path of the event. + /// + /// Note that the returned path is wrapped in an [`Arc`] for reasons of + /// efficiency, as the file manager just returns references to paths. + #[must_use] + pub fn path(&self) -> Arc { + Arc::clone(match self { + Event::Create { path, .. } => path, + Event::Modify { path, .. } => path, + Event::Rename { to, .. } => to, + Event::Remove { path, .. } => path, + }) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl From for Kind { + /// Converts a file type to a file kind. + fn from(value: FileType) -> Self { + if value.is_dir() { + Kind::Folder + } else if value.is_symlink() { + Kind::Link + } else { + Kind::File + } + } +} diff --git a/crates/zensical-watch/src/agent/handler.rs b/crates/zensical-watch/src/agent/handler.rs new file mode 100644 index 0000000..cc81042 --- /dev/null +++ b/crates/zensical-watch/src/agent/handler.rs @@ -0,0 +1,166 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File handler. + +use crossbeam::channel::{after, never, select_biased, Receiver}; +use notify::EventKind; +use std::mem; +use std::path::PathBuf; +use std::time::Duration; + +use super::error::Result; +use super::event::Event; +use super::manager::Manager; +use super::monitor::Monitor; +use super::Action; + +mod builder; + +use builder::Builder; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// File handler. +pub struct Handler { + /// Action receiver. + receiver: Receiver, // replace with vector of handlers? + /// Event handler. + #[allow(clippy::struct_field_names)] + handler: Box) -> Result>, + /// File monitor. + monitor: Monitor, + /// File manager. + manager: Manager, + /// Queue for paths from events. + queue: Vec, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Handler { + /// Creates a handler builder. + /// + /// # Examples + /// + /// ``` + /// use zensical_watch::agent::Handler; + /// + /// // Create file handler builder + /// let mut handler = Handler::builder(); + /// ``` + #[inline] + #[must_use] + pub fn builder() -> Builder { + Builder::new() + } + + /// Handles messages from the file agent and the file monitor. + #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] + pub fn handle(&mut self, timeout: Duration) -> Result { + // When receiving events from the file system, we debounce processing + // by the given timeout, as we need to give the file system some time + // to settle down. This ensures, that we can correctly handle renames, + // as some file watcher backends send creation and removal events. + let wait = (!self.queue.is_empty()).then_some(timeout); + + // Select over the receiver, which is the control channel for the file + // agent, the monitor, the file watcher backend, and the timeout, which + // is used to debounce events. Note that we use `select_biased` here to + // prioritize ordering of processing. + select_biased! { + // Handle messages from the file agent, which are sent whenever the + // owner instructs it to watch or unwatch a given path + recv(self.receiver) -> message => { + let res = match message? { + Action::Watch(path) => { + self.monitor.watch(&path).map(|_| { + self.queue.push(path); + }) + }, + Action::Unwatch(path) => { + self.monitor.unwatch(&path).map(|_| { + self.queue.push(path); + }) + }, + }; + + // Handle errors + if let Err(err) = res { + (self.handler)(Err(err.into()))?; + } + } + + // Handle messages from the file monitor, which are sent whenever + // a file system event is detected on a watched path + recv(self.monitor.as_receiver()) -> message => { + let res = message?.map(|event| { + self.queue.extend(filter(event.kind, event.paths)); + }); + if let Err(err) = res { + (self.handler)(Err(err.into()))?; + } + } + + // Handle timeouts, which are used to debounce events, and happen + // when the queue isn't empty, and nothing happened for a time + recv(wait.map_or_else(never, after)) -> _ => { + let paths = mem::take(&mut self.queue); + for res in self.manager.handle(paths) { + (self.handler)(res)?; + } + } + } + + Ok(()) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Filters a file event. +/// +/// This function normalizes the events emitted by [`notify`] to our [`Event`] +/// variant. Not all events are handled, as we only care about file and folder +/// creation, modification, and removal. Thus access events, and other events +/// are not emitted by the returned iterator. +#[inline] +fn filter

(kind: EventKind, paths: P) -> impl Iterator +where + P: IntoIterator, +{ + paths.into_iter().filter_map(move |path| match kind { + EventKind::Create(_) => Some(path), + EventKind::Modify(_) => Some(path), + EventKind::Remove(_) => Some(path), + _ => None, + }) +} diff --git a/crates/zensical-watch/src/agent/handler/builder.rs b/crates/zensical-watch/src/agent/handler/builder.rs new file mode 100644 index 0000000..16b8219 --- /dev/null +++ b/crates/zensical-watch/src/agent/handler/builder.rs @@ -0,0 +1,118 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File handler builder. + +use crossbeam::channel::Receiver; + +use super::{Action, Event, Handler, Manager, Monitor, Result}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// File handler builder. +pub struct Builder { + /// Action receiver. + receiver: Option>, + /// Event handler. + handler: Option) -> Result>>, + /// File monitor. + monitor: Option, + /// File manager. + manager: Option, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Builder { + /// Creates a file handler builder. + /// + /// Note that the canonical way to create a [`Handler`] is to invoke the + /// [`Handler::builder`] method, which creates an instance of [`Builder`]. + /// This is also why we don't implement [`Default`] - the builder itself + /// should be considered an implementation detail. + /// + /// # Examples + /// + /// ``` + /// use zensical_watch::agent::Handler; + /// + /// // Create file handler builder + /// let mut handler = Handler::builder(); + /// ``` + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self { + receiver: None, + handler: None, + monitor: None, + manager: None, + } + } + + /// Sets the receiver for actions. + pub fn receiver(mut self, receiver: Receiver) -> Self { + self.receiver = Some(receiver); + self + } + + /// Sets the sender for messages. + pub fn handler(mut self, handler: F) -> Self + where + F: 'static + Send, + F: FnMut(Result) -> Result, + { + self.handler = Some(Box::new(handler)); + self + } + + /// Sets the file monitor. + pub fn monitor(mut self, monitor: Monitor) -> Self { + self.monitor = Some(monitor); + self + } + + /// Sets the file manager. + pub fn manager(mut self, manager: Manager) -> Self { + self.manager = Some(manager); + self + } + + /// Builds the file handler. + pub fn build(self) -> Result { + let receiver = self.receiver.ok_or("Receiver is required").unwrap(); + let handler = self.handler.ok_or("Handler is required").unwrap(); + Ok(Handler { + receiver, + handler, + monitor: self.monitor.unwrap_or_default(), + manager: self.manager.unwrap_or_default(), + queue: Vec::new(), + }) + } +} diff --git a/crates/zensical-watch/src/agent/handler/error.rs b/crates/zensical-watch/src/agent/handler/error.rs new file mode 100644 index 0000000..7fc74e1 --- /dev/null +++ b/crates/zensical-watch/src/agent/handler/error.rs @@ -0,0 +1,81 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File handler error. + +use crossbeam::channel::{RecvError, SendError}; +use std::{io, result}; +use thiserror::Error; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// File handler error. +#[derive(Debug, Error)] +pub enum Error { + /// I/O error. + #[error(transparent)] + Io(#[from] io::Error), + + /// Notify error. + #[error(transparent)] + Notify(#[from] notify::Error), + + /// Walk directory error. + #[error(transparent)] + WalkDir(#[from] walkdir::Error), + + /// Channel disconnected. + #[error("channel disconnected")] + Disconnected, +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl From> for Error { + /// Creates an error from a send error. + #[inline] + fn from(_: SendError) -> Self { + Error::Disconnected + } +} + +impl From for Error { + /// Creates an error from a receive error. + #[inline] + fn from(_: RecvError) -> Self { + Error::Disconnected + } +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// File handler result. +pub type Result = result::Result; diff --git a/crates/zensical-watch/src/agent/manager.rs b/crates/zensical-watch/src/agent/manager.rs new file mode 100644 index 0000000..2162161 --- /dev/null +++ b/crates/zensical-watch/src/agent/manager.rs @@ -0,0 +1,808 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File manager. + +use ahash::{HashMap, HashSet}; +use file_id::FileId; +use std::collections::btree_map::Entry; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::{fs, io}; +use walkdir::{DirEntry, WalkDir}; + +use super::event::{Event, Kind}; +use super::Result; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// File manager. +/// +/// The file manager represents a constituted effort to normalize events across +/// different file watcher backends, which all exhibit their own behaviors and +/// do not reliably emit the same events. This is particularly challenging for +/// symbolic links, as well as events that create deeply nested structures. +/// +/// It tries to be a better version of the debouncer implementation provided by +/// the [`notify`] crate, but is significantly more complex. Note that symbolic +/// links are explicitly tracked to consistently propagate changes of files or +/// folders that reside inside them to all other instances. While all backends +/// supported by [`notify`] itself are handled by this implementation, there +/// might be some problems with symbolic links inside Docker. +/// +/// Note that every [`PathBuf`] is wrapped in an [`Arc`] to reduce memory usage, +/// as the file manager requires multiple copies of the same path in order to +/// accurately track locations, identifiers and symbolic links. By using shared +/// references, memory usage is reduced by a factor of 3 or more. Also note that +/// this needs to be an [`Arc`] and not an [`Rc`][], as the file agent, which +/// controls the file monitor and manager, runs on a separate thread. +/// +/// [`Rc`]: std::rc::Rc +/// +/// # Features +/// +/// - Automatically watches files and folders in a given directory +/// - Automatically tracks file system events at arbitrary depths +/// - Propagates folder renames to all files and folders inside it +/// - Propagates events to all instances of a symbolic link +/// - Limits symbolic links to actively watched paths for security +/// +/// # Examples +/// +/// ``` +/// use std::path::PathBuf; +/// use zensical_watch::agent::Manager; +/// +/// // Create file manager +/// let mut manager = Manager::new(); +/// +/// // Handle and register paths +/// for result in manager.handle(["."]) { +/// println!("{:?}", result); +/// } +/// ``` +#[derive(Debug, Default)] +pub struct Manager { + /// File paths map. + paths: BTreeMap, (FileId, Kind)>, + /// Symbolic links map. + links: BTreeMap, Vec>>, + /// File identifiers map. + ids: HashMap>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Manager { + /// Creates a file manager. + /// + /// # Examples + /// + /// ``` + /// use zensical_watch::agent::Manager; + /// + /// // Create file manager + /// let manager = Manager::new(); + /// ``` + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Handles a set of paths and generates events. + /// + /// This method takes an iterator of paths, and then, depending on whether + /// the files, folders or symbolic links referred to by those paths still + /// exist, deduces the corresponding events from those paths and the + /// the internal state of the manager. + /// + /// The manager keeps track of all paths and file identifiers, as there's + /// no other way to accurately determine what kind of event has occurred, + /// since file watcher backends are not consistent in behavior, especially + /// for symbolic links. Sadly, it's really a huge mess. This is also why + /// the manager queries the file system itself, to learn which files still + /// exist, and which have been renamed or removed. We do our best to track + /// and determine what has happened, but it's yet not perfect. + /// + /// Note that the given paths are deduplicated, as they are expected to be + /// extracted from a series of [`notify`] events, which are buffered before + /// they're passed to this function, so renames are not split into removals + /// and creations. The manager tries to make sure all events are accurate. + /// + /// # Examples + /// + /// ``` + /// use std::path::PathBuf; + /// use zensical_watch::agent::Manager; + /// + /// // Create file manager + /// let mut manager = Manager::new(); + /// + /// // Handle and register paths + /// for result in manager.handle(["."]) { + /// println!("{:?}", result); + /// } + /// ``` + pub fn handle(&mut self, paths: T) -> Vec> + where + T: IntoIterator, + T::Item: Into, + { + let mut results = Vec::new(); + let mut changes = BTreeMap::new(); + + // 1st pass: filter out all paths that point to files or folders that + // exist, and associate them with their OS-dependent file identifiers. + // Also, only keep unique paths, as some file watcher backends emit + // multiple events, but we use a vector to preserve the ordering. + let mut once = HashSet::default(); + let paths = paths + .into_iter() + .map(Into::into) + .filter(|path| once.insert(path.clone())) + .filter_map(|path| { + // If the path points to a file or folder, the event is either + // a creation or modification, or the target path of a rename + let Ok(id) = get_file_id(&path) else { + return Some(path); + }; + + // Usually, there's no previous entry when inserting a new path. + // However, some file watcher backends like `kqueue` might emit + // events for paths inside symbolic links, which is when there + // already is an entry for the given file identifier. + match changes.entry(id) { + Entry::Vacant(entry) => { + entry.insert(path); + } + + // By canonicalizing the path, and checking whether it's + // the same as the original, we check whether the path is + // inside a symbolic link. If the path can be canonicalized + // and is different from the previous one, we replace it. + Entry::Occupied(mut entry) => { + if let Ok(to) = fs::canonicalize(&path) { + if *entry.get() != to { + entry.insert(path); + } + } + } + } + + // Return nothing, as the path was consumed + None + }) + .collect::>(); + + // 2nd pass: filter out all non-existing paths that we can match to a + // previously seen path, coalescing them into a rename event + let paths = paths + .into_iter() + .filter_map(|path| { + // If we've already seen the path, and we got another path with + // the same file identifier in this iteration, we know that the + // path was renamed, and we can coalesce the two events into a + // single rename instead of a removal and creation + if let Some((id, _)) = self.paths.get(&path) { + if let Some(to) = changes.remove(id) { + results.append(&mut self.handle_rename(&to)); + return None; + } + } + + // The path does not point to a file or folder, and it's also + // not part of a rename, so we know that it must be a removal + Some(path) + }) + .collect::>(); + + // We now know that the remaining changes are either file or folder + // creations or modifications, depending on if we've seen them before + for path in changes.into_values() { + if self.paths.contains_key(&path) { + results.append(&mut self.handle_modify(&path)); + } else { + results.append(&mut self.handle_create(&path)); + } + } + + // All other remaining paths correspond to removals, but we can ignore + // those that we haven't seen before. This might happen when an editor + // creates a temporary file and removes it in the same iteration, e.g., + // like vim's infamous 4913 file, or in case of paths that are inside + // symbolic links which are not actively monitored, as for reasons of + // security, we don't just always resolve symbolic links. + for path in paths { + if self.paths.contains_key(&path) { + results.append(&mut self.handle_remove(&path)); + } + } + + // After processing all paths, we need to check if a path refers to a + // file or folder that is referenced transitively through a monitored + // symbolic link. This must be done before considering symbolic links + // inside the results themselves, or we'll generate duplicate events. + let mut inserts = Vec::new(); + if !self.links.is_empty() { + // In case the event doesn't contain a path that is a symbolic link + // itself, try to spread it to all symbolic links, if inside any + for (i, result) in results.iter().enumerate() { + if let Ok(event) = result { + if event.kind() != Kind::Link { + inserts.push((i, self.spread(event))); + } + } + } + + // Insert results from spreaded symbolic links at the position of + // the original result, while ensuring that indices remain valid by + // iterating in reverse. Each followed symbolic link includes the + // original result itself. + for (i, insert) in inserts.drain(..).rev() { + results.splice(i..=i, insert); + } + } + + // Follow symbolic links and list all files and folders inside them, if + // and only if they are actively monitored (for security reasons). Like + // mentioned above, this must be done after spreading symbolic links, + // or we'll end up with duplicate events in the result set. + for (i, result) in results.iter().enumerate() { + if let Ok(event) = result { + if event.kind() == Kind::Link { + inserts.push((i, self.follow(event))); + } + } + } + + // Lastly, handle results generated from following symbolic links that + // are part of the original set of results, and insert them accordingly + for (i, insert) in inserts.into_iter().rev() { + results.splice(i..=i, insert); + } + + // Return results, including errors + results + } + + /// Handles a creation event. + fn handle_create(&mut self, root: &PathBuf) -> Vec> { + let iter = walk(root).filter_map(|item| { + item.and_then(|entry| { + let kind = entry.file_type(); + let path = entry.into_path(); + + // In case the path refers to a folder, we're enumerating files + // recursively. However, since some file watcher backends will + // recurse as well, we might have already encountered the path + // in a previous iteration, so we can just skip it here. + if self.paths.contains_key(&path) { + return Ok(None); + } + + // Theoretically, obtaining the file identifier should not fail + // at this point, but operating systems can be unpredictable + let id = get_file_id(&path)?; + + // Here, we know that we're looking at a new file, so we need + // to retrieve the file type and materialize its path + let kind = Kind::from(kind); + let path = Arc::new(path); + + // We record the path and file identifier association in both + // directions, so we can accurately track all events + self.paths.insert(Arc::clone(&path), (id, kind)); + self.ids.insert(id, Arc::clone(&path)); + + // Return event + Ok(Some(Event::Create { kind, path })) + }) + .transpose() + }); + + // Collect results from iterator + iter.collect() + } + + /// Handles a modification event. + fn handle_modify(&mut self, root: &PathBuf) -> Vec> { + let stat = self.paths.get(root); + let iter = stat.into_iter().filter_map(|(id, kind)| { + // Some file watcher backends like `kqueue` emit modifications for + // folders, which we're not interested in, so we filter them out + if *kind == Kind::Folder { + None + } else { + self.ids.get(id).map(|path| { + Ok(Event::Modify { + kind: *kind, + path: Arc::clone(path), + }) + }) + } + }); + + // Collect results from iterator + iter.collect() + } + + /// Handles a rename event. + fn handle_rename(&mut self, root: &PathBuf) -> Vec> { + let iter = walk(root).filter_map(|item| { + item.and_then(|entry| { + let path = entry.path(); + + // Better safe than sorry - although we know that the path has + // just been created, there might be cases where this fails + let id = get_file_id(path)?; + if let Some(prev) = self.ids.get_mut(&id) { + let path = Arc::new(entry.into_path()); + let from = Arc::clone(prev); + + // Rename the path by migrating the file identifier to the + // new path, if the previous path existed. If not, ignore. + if let Some((id, kind)) = self.paths.remove(prev) { + self.paths.insert(Arc::clone(&path), (id, kind)); + + // The `polling` file watcher backend propagates rename + // events to files and folders inside of symbolic links, + // which is different than all other backends. In case + // the file is emitted before the folder in which it is + // contained, this will result in the rename of a file + // that has already been renamed, which we must ignore. + return if path == from { + Ok(None) + } else { + // Update the file identifier map with the new path + // and return the rename from source to target path + prev.clone_from(&path); + Ok(Some(Event::Rename { kind, from, to: path })) + }; + } + } + + // Return nothing, likely due to a file system error + Ok(None) + }) + .transpose() + }); + + // Collect results from iterator + iter.collect() + } + + /// Handles a removal event. + fn handle_remove(&mut self, root: &PathBuf) -> Vec> { + // We need to collect all paths that start with the given path, as we + // can't mutate the file paths map while iterating over it + let mut paths = Vec::new(); + for (path, _) in self.paths.range(root.clone()..) { + if path.starts_with(root) { + paths.push(Arc::clone(path)); + } else { + break; + } + } + + // Next, we remove all collected paths from the file manager, and emit + // a removal event for each path, removing the path and file identifier + // association. Note that we iterate the file path map in reverse, as + // we need to make sure that files are always emitted before folders. + let iter = paths.into_iter().rev().filter_map(|path| { + self.paths.remove(&path).and_then(|(id, kind)| { + self.ids + .remove(&id) + .map(|path| Ok(Event::Remove { kind, path })) + }) + }); + + // Collect results from iterator + iter.collect() + } + + /// Follows a symbolic link after an event. + /// + /// This method is only ever called for symbolic links, keeping track of + /// them, while expanding all paths inside the symbolic link to events. For + /// more information on how symbolic links are handled, see the example in + /// the [`Manager::expand`] method. + #[allow(clippy::bool_comparison)] + fn follow(&mut self, event: &Event) -> Vec> { + debug_assert_eq!(event.kind(), Kind::Link); + + // Update the symbolic links maps, expand all paths inside the symbolic + // link, and return the results. Depending on the event kind, expansion + // must happen before or after the symbolic link has been updated, as + // removal events are handled differently than all other events. + let mut results = Vec::new(); + match event { + // Handle a creation event + Event::Create { path, .. } => { + let res = fs::canonicalize(path.as_path()).map(|to| { + let paths = self.links.entry(Arc::new(to)).or_default(); + if !paths.contains(path) { + paths.push(Arc::clone(path)); + } + event.clone() + }); + + // After updating the symbolic links map, add the original + // event and expand all paths inside the symbolic link + results.push(res.map_err(Into::into)); + results.append(&mut self.expand(event)); + } + + // Handle a modification event + Event::Modify { .. } => { + // Nothing to be done + } + + // Handle a rename event + Event::Rename { from, to: path, .. } => { + // Obtain the path to rename from the symbolic links map, and + // update the previous target path with the next path. Once the + // symbolic link has been updated, the iterator will immediately + // abort and return an empty option to denote success. + let done = self.links.iter_mut().find_map(|(_, paths)| { + paths.iter().position(|check| check == from).map(|index| { + paths[index].clone_from(path); + }) + }); + + // In case no symbolic link was found, we try to canonicalize + // the path, and update the symbolic links map if it exists + let res = match done { + Some(()) => Ok(event.clone()), + None => fs::canonicalize(path.as_path()).map(|to| { + let paths = self.links.entry(Arc::new(to)).or_default(); + if !paths.contains(path) { + paths.push(Arc::clone(path)); + } + event.clone() + }), + }; + + // After updating the symbolic links map, add the original + // event and expand all paths inside the symbolic link + results.push(res.map_err(Into::into)); + results.append(&mut self.expand(event)); + } + + // Handle a removal event + Event::Remove { path, .. } => { + // Expand all paths inside the symbolic link before removal, + // or path enumeration will not be possible anymore. Note that + // we reverse the order of events, so that folder contents are + // always listed before the folder itself. + results.append(&mut self.expand(event)); + results.reverse(); + + // After expanding all paths, we remove the symbolic link from + // the symbolic links map, including empty vectors of paths + self.links.retain(|_, paths| { + paths.retain(|check| check != path); + paths.is_empty() == false + }); + + // Finally, add the original event for the symbolic link at + // the end of the result set, as it's the last event to emit + results.push(Ok(event.clone())); + } + } + + // Return results, including errors + results + } + + /// Expands all paths inside a symbolic link to events. + /// + /// This method is only ever called for symbolic links, and is thus a dual + /// of [`Manager::spread`], which handles files inside of symbolic links. + /// It is used to expand symbolic links to all paths inside them, which + /// implements monitored following of symbolic links. + /// + /// # Examples + /// + /// The following directory structure lists `assets` folders per language, + /// each of which refer to a `shared` top-level folder for sharing assets: + /// + /// ``` text + /// . + /// └─ docs/ + /// ├─ shared/ + /// │ ├─ image-1.png + /// │ ├─ image-2.png + /// │ └─ ... + /// ├─ en/ + /// │ ├─ assets/ -> ../shared/ + /// │ └─ ... + /// └─ fr/ + /// ├─ assets/ -> ../shared/ + /// └─ ... + /// ``` + /// + /// When the symbolic link `docs/en/assets` is created, modified, removed or + /// renamed, also taking into account if the symbolic link is valid before + /// and/or after the event, the following paths will be emitted by this + /// method as part of the corresponding kinds of events: + /// + /// - `docs/en/assets/image-1.png` + /// - `docs/en/assets/image-2.png` + /// + /// For instance, if a relative symbolic link is moved to another location + /// where it becomes invalid, removal events are emitted for those paths. + fn expand(&self, event: &Event) -> Vec> { + debug_assert_eq!(event.kind(), Kind::Link); + + // When the event is not a removal event, for which we'd know for sure + // that the path cannot be canonicalized, as it does not exist anymore, + // we resolve the symbolic link to its target to emit errors for all + // other events, specifically renames + let root = event.path(); + let broken = match &event { + Event::Remove { .. } => None, + _ => fs::canonicalize(root.as_path()).map_err(Into::into).err(), + }; + + // Regardless of whether the target exists, we obtain its path, so we + // can enumerate all files and folders inside the symbolic link. + let target = self.links.iter().find_map(|(path, paths)| { + paths.contains(&root).then_some(Arc::clone(path)) + }); + + // Now, enumerate all paths that start with the path of the given event, + // filtering out the starting path, since it's the symbolic link itself + let iter = target.into_iter().flat_map(|head| { + let iter = self.paths.range(Arc::clone(&head)..).skip(1); + iter.scan((), move |(), (path, (_, kind))| { + path.strip_prefix(head.as_path()) + .ok() + .map(|tail| (*kind, tail)) + }) + }); + + // Check if the next link target is broken, which means that the link + // could not be canonicalized. If the previous link target was broken + // as well, we can just ignore the event. In all other cases, we map + // each path inside the symbolic link to the corresponding event. + let next = broken.is_none(); + let iter = iter.filter_map(move |(kind, tail)| { + let path = Arc::new(root.join(tail)); + + // Map each path to the same kind of event as the symbolic link, + // except for renames where one of the targets is broken + let event = match &event { + Event::Create { .. } => Some(Event::Create { kind, path }), + Event::Modify { .. } => Some(Event::Modify { kind, path }), + Event::Remove { .. } => Some(Event::Remove { kind, path }), + Event::Rename { from, .. } => { + let up = from.parent().expect("invariant"); + let from = Arc::new(from.join(tail)); + + // Check if the previous link target was broken, which we + // can do by canonicalizing it at the previous location + let prev = fs::read_link(root.as_path()) + .and_then(|path| fs::canonicalize(up.join(path))) + .is_ok(); + + // Construct event accordingly, based on the existence of + // the previous and next target of the symbolic link + if prev && next { + Some(Event::Rename { kind, from, to: path }) + } else if prev { + Some(Event::Remove { kind, path: from }) + } else if next { + Some(Event::Create { kind, path }) + } else { + None + } + } + }; + + // Return event + event.map(Ok) + }); + + // Combine error and collect results from iterator + broken.into_iter().map(Err).chain(iter).collect() + } + + /// Spreads an event to all symbolic links. + /// + /// This method is only ever called if we're monitoring one or more symbolic + /// links. If an event happened inside a folder that is targetted by one or + /// more symbolic links, the event is spread across all of them, so it is + /// correctly propagated to all monitored paths. + /// + /// # Examples + /// + /// The following directory structure lists `assets` folders per language, + /// each of which refer to a `shared` top-level folder for sharing assets: + /// + /// ``` text + /// . + /// └─ docs/ + /// ├─ shared/ + /// │ ├─ image-1.png + /// │ ├─ image-2.png + /// │ └─ ... + /// ├─ en/ + /// │ ├─ assets/ -> ../shared/ + /// │ └─ ... + /// └─ fr/ + /// ├─ assets/ -> ../shared/ + /// └─ ... + /// ``` + /// + /// When the `image-1.png` file in the top-level `shared` folder is created, + /// modified, removed or renamed, regardless whether it's moved within the + /// shared folder, or moved in or out of it, the following paths will be + /// emitted by this method with the corresponding kinds of events: + /// + /// - `docs/shared/image-1.png` + /// - `docs/en/assets/image-1.png` + /// - `docs/fr/assets/image-1.png` + /// + /// For instance, if a file is moved out of a folder that has symbolic links + /// pointing to it, the file is removed from all symbolic links. + fn spread(&self, event: &Event) -> Vec> { + debug_assert_ne!(event.kind(), Kind::Link); + + // Select all symbolic links for the given event, if any, so we can map + // the event to all paths inside the symbolic link in the next step. In + // case the file is not within a folder that is targetted by a symbolic + // link, this method returns nothing. + let root = event.path(); + let select = self.links.iter().find_map(|(path, paths)| { + root.strip_prefix(path.as_path()) + .ok() + .map(|tail| (path, paths, tail)) + }); + + // Now, enumerate all selected symbolic links, and combine each of its + // paths with the event, so we can emit the event for each path + let iter = select.into_iter().flat_map(|(head, paths, tail)| { + paths.iter().map(move |root| { + let path = Arc::new(root.join(tail)); + let kind = event.kind(); + + // Map each path to the same kind of event as the symbolic link, + // except for renames where the previous target is broken + let event = match &event { + Event::Create { .. } => Event::Create { kind, path }, + Event::Modify { .. } => Event::Modify { kind, path }, + Event::Remove { .. } => Event::Remove { kind, path }, + Event::Rename { from, .. } => { + if let Ok(tail) = from.strip_prefix(head.as_path()) { + let from = Arc::new(root.join(tail)); + Event::Rename { kind, from, to: path } + } else { + Event::Create { kind, path } + } + } + }; + + // Return event + Ok(event) + }) + }); + + // Create original result, and if the previously constructed iterator + // doesn't yield any results, and the event is a rename event, we know + // for sure that the target does not exist any more. Thus, we need to + // emit a removal event for all paths inside the symbolic link. + let target = Some(Ok(event.clone())); + let mut iter = iter.peekable(); + if let Event::Rename { kind, from, .. } = event { + if iter.peek().is_none() { + let path = Arc::clone(from); + + // Create a temporary removal event, so we can spread it to all + // paths inside the symbolic link, and then return the original. + // It's easier to just reuse the removal business logic, as + // otherwise we'd need more code for an edge case. + let event = Event::Remove { kind: *kind, path }; + return target + .into_iter() + .chain(self.spread(&event).into_iter().skip(1)) + .collect(); + } + } + + // Collect results from iterator + target.into_iter().chain(iter).collect() + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Creates a file system iterator from the given path. +/// +/// When walking directory trees, we explicitly do not follow symbolic links, as +/// we need to track them explicitly. This is particularly necessary in order to +/// normalize the behavior across different file watcher backends, as all of +/// them treat symbolic links differently. +/// +/// Files in a directory are typically not stored sequentially, so they're most +/// likely not returned in lexicographical order. While the hierarchy of files +/// and folders is preserved, the order of files inside of folders is not well +/// defined. Although it's possible to sort the files inside of a folder before +/// yielding, it would be a significant performance hit for a merely cosmetic +/// benefit, as the order of files inside of a folder is not relevant for us. +fn walk

(path: P) -> impl Iterator> +where + P: AsRef, +{ + WalkDir::new(path) + .follow_root_links(false) + .follow_links(false) + .into_iter() + // For now we skip hidden directories to speed up the build, since we + // do not need to watch icons, but in general we need to find a better + // method in the future when we integrate large asset directories and + // libraries that include thousands of icons. + .filter_entry(|item| { + !(item.file_type().is_dir() + && item.file_name().to_str().unwrap_or("").starts_with('.')) + }) + .map(|item| item.map_err(Into::into)) +} + +// ---------------------------------------------------------------------------- + +/// Returns the file identifier for the file or folder at the given path. +#[cfg(target_family = "unix")] +fn get_file_id

(path: P) -> io::Result +where + P: AsRef, +{ + use std::os::unix::fs::MetadataExt; + + // This implementation is taken from the `file-id` crate, but modified to + // not follow symbolic links, as we track those explicitly + fs::symlink_metadata(path) + .map(|metadata| FileId::new_inode(metadata.dev(), metadata.ino())) +} + +/// Returns the file identifier for the file or folder at the given path. +#[cfg(target_family = "windows")] +#[inline] +fn get_file_id

(path: P) -> io::Result +where + P: AsRef, +{ + // We should be fine by just using the low resolution variant, as it's much + // cheaper, and we also don't need to support more than 4b volumes. If it + // turns out that this breaks in some esoteric cases, we might consider + // changing this later on, possibly putting it behind a feature flag. + file_id::get_low_res_file_id(path) +} diff --git a/crates/zensical-watch/src/agent/monitor.rs b/crates/zensical-watch/src/agent/monitor.rs new file mode 100644 index 0000000..5ecd15b --- /dev/null +++ b/crates/zensical-watch/src/agent/monitor.rs @@ -0,0 +1,582 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File monitor. + +use crossbeam::channel::{unbounded, Receiver, TryIter}; +use notify::{ + Config, Event, RecommendedWatcher, RecursiveMode, Result, Watcher, + WatcherKind, +}; +use std::collections::btree_map::Entry; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::{fmt, fs}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// File monitor. +/// +/// This is a small convenient wrapper around the [`notify`] crate, which uses +/// a [`crossbeam`] channel to simplify event handling. It also tracks watched +/// paths, normalizing the behavior of all file watcher backends, especially +/// in terms of automatically watching newly created files. +/// +/// This implementation checks that the paths passed to the file watcher never +/// overlap, because some file watcher backends might not handle addition and +/// removal of overlapping paths correctly. Thus, if a path that is watched is +/// covered by the path that is added, the former will be unwatched before the +/// latter is watched. This is ensured by maintaining a list of watched paths, +/// which is updated whenever a path is added or removed. Additionally, every +/// path is watched recursively, as it doesn't make sense in our case to watch +/// a directory non-recursively. This also has the nice upside of simplifying +/// the API and implementation. +/// +/// In order to better understand how this works, let's assume the monitor is +/// configured to watch three paths: `docs`, `docs/assets`, and `docs/posts`: +/// +/// ``` text +/// . +/// └─ docs/ +/// ├─ assets/ +/// └─ posts/ +/// ``` +/// +/// In this scenario, only `docs` is being actively watched, as the two nested +/// paths are covered by it. Since the monitor has been asked to watch `docs`, +/// it unwatched `docs/assets` and `docs/posts`. Subsequently, when `docs` is +/// unwatched, the two nested paths will be watched. This behavior ensures that +/// each file is only ever watched once, and that the monitor is always in a +/// consistent state, normalizing behavior across file watcher backends. +/// +/// # Examples +/// +/// ``` +/// # use std::error::Error; +/// # fn main() -> Result<(), Box> { +/// use zensical_watch::agent::Monitor; +/// +/// // Create file monitor and start watching +/// let mut monitor = Monitor::default(); +/// monitor.watch(".")?; +/// # Ok(()) +/// # } +/// ``` +pub struct Monitor { + /// File watcher. + watcher: Box, + /// File watcher backend. + kind: Kind, + /// Watched paths. + paths: BTreeMap, + /// Message receiver. + receiver: Receiver>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Monitor { + /// Creates a file monitor. + /// + /// Normally, it's not necessary to use this function, since the [`Default`] + /// implementation will set up the [`RecommendedWatcher`]. However, if you + /// want to use a specific watcher, e.g., the [`PollWatcher`][], you can + /// use this function to create a file monitor with it. + /// + /// [`PollWatcher`]: notify::PollWatcher + /// + /// # Panics + /// + /// Panics if [`notify`] returns an error on [`Watcher`] creation, as the + /// file monitor is required for the file agent. + /// + /// # Examples + /// + /// ``` + /// use notify::{Config, PollWatcher}; + /// use std::time::Duration; + /// use zensical_watch::agent::Monitor; + /// + /// // Define poll interval for polling watcher + /// let config = Config::default() + /// .with_poll_interval(Duration::from_secs(1)); + /// + /// // Create file monitor with polling watcher + /// let mut monitor = Monitor::new::(config); + /// ``` + #[must_use] + pub fn new(config: Config) -> Self + where + W: 'static + Watcher, + { + let (sender, receiver) = unbounded(); + + // Disable following of symbolic links, as the file manager tracks them + // separately to be able to correctly determine the set of events + let config = config.with_follow_symlinks(false); + let h = move |res| { + match res { + Ok(event) => filter::(event).map(Ok), + Err(err) => Some(Err(err)), + } + .map(|res| sender.send(res)); + }; + + // We deliberately use unwrap here, as the capability to spawn threads + // is a fundamental requirement of the file monitor + Self { + watcher: Box::new(W::new(h, config).unwrap()), + kind: W::kind(), + paths: BTreeMap::new(), + receiver, + } + } + + /// Watches the given path, recursively. + /// + /// This method will not return an error if the given path is already part + /// of the list of watched paths, but indicate this with the return value. + /// + /// # Errors + /// + /// Errors returned by [`notify`] are forwarded. Other than that, the path + /// that is passed to this method must exist and be accessible, as we have + /// to canonicalize it before adding it to the list of watched paths. This + /// is essential to uniquely identify paths across the file system. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_watch::agent::Monitor; + /// + /// // Create file monitor and start watching + /// let mut monitor = Monitor::default(); + /// monitor.watch(".")?; + /// # Ok(()) + /// # } + /// ``` + pub fn watch

(&mut self, path: P) -> Result + where + P: AsRef, + { + let path = fs::canonicalize(path)?; + + // Before adding the given path to the list of watched paths, we check + // if we're already watching it in order to skip duplicate work + if let Entry::Vacant(entry) = self.paths.entry(path) { + // Here, we know that we haven't seen this path, so we add it to + // the list of watched paths, and reconfigure the watcher + entry.insert(false); + self.configure() + } else { + Ok(false) + } + } + + /// Unwatches the given path. + /// + /// This method will not return an error if the given path is already part + /// of the list of watched paths, but indicate this with the return value. + /// + /// # Errors + /// + /// Errors returned by [`notify`] are forwarded. Other than that, the path + /// that is passed to this method must exist and be accessible, as we have + /// to canonicalize it before adding it to the list of watched paths. This + /// is essential to uniquely identify paths across the file system. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_watch::agent::Monitor; + /// + /// // Create file monitor and start watching + /// let mut monitor = Monitor::default(); + /// monitor.watch(".")?; + /// + /// // Stop watching + /// monitor.unwatch(".")?; + /// # Ok(()) + /// # } + /// ``` + pub fn unwatch

(&mut self, path: P) -> Result + where + P: AsRef, + { + let path = fs::canonicalize(path)?; + + // After removing the given path from the list of watched paths, we + // need to check whether it was covered by another path, so it wasn't + // actively watched, or it was an actively watched path. In the former + // case, the list of actively watched paths does not change. + if self.paths.remove(&path).unwrap_or(false) { + // Here, we know that the path was an actively watched path, so we + // immediately unwatch it, and reconfigure the watcher. However, we + // must account for when the file watcher backend is `kqueue`, which + // emits mysterious errors. See the `refresh` method for details. + if self.kind == Kind::Kqueue { + let _ = self.watcher.unwatch(&path); + } else { + self.watcher.unwatch(&path)?; + } + + // Reconfigure the watcher + self.configure() + } else { + Ok(false) + } + } + + /// Refreshes the watched path covering the given path. + /// + /// # Errors + /// + /// Errors returned by [`notify`] are forwarded. Other than that, the path + /// that is passed to this method must exist and be accessible, as we have + /// to canonicalize it before adding it to the list of watched paths. This + /// is essential to uniquely identify paths across the file system. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_watch::agent::Monitor; + /// + /// // Create file monitor and start watching + /// let mut monitor = Monitor::default(); + /// monitor.watch(".")?; + /// + /// // Refresh watcher + /// monitor.refresh("Cargo.toml")?; + /// # Ok(()) + /// # } + /// ``` + pub fn refresh

(&mut self, path: P) -> Result + where + P: AsRef, + { + let path = fs::canonicalize(path)?; + + // Short-circuit if the file watcher backend is `fsevents`, as watching + // of subfolders on creation is natively supported by this backend + if self.kind == Kind::Fsevent { + return Ok(true); + } + + // In order to normalize behavior across all file watcher backends, we + // need to refresh the watched path covering the given path, unwatching + // it and then watching it again. Some file watcher backends do not + // gracefully handle overlap, which is why this is necessary. + let mut found = false; + for (prefix, active) in &self.paths { + if *active && path.starts_with(prefix) { + // If the file watcher backend is `kqueue`, we must ignore the + // return value of the unwatch operation, because it might fail + // with a false positive saying that the watched directory does + // not exist, which it does. `kqueue`'s remove filename method + // seems to trigger this error when the events are handed over + // to the operating system, and then things go berzerk. + // + // Related issue on GitHub (which we reported): + // https://github.com/notify-rs/notify/issues/665 + if self.kind == Kind::Kqueue { + let _ = self.watcher.unwatch(prefix); + } else { + self.watcher.unwatch(prefix)?; + } + + // After unwatching, immediately rewatch the actively watched + // path, so the backend re-scans this part of the file system + self.watcher.watch(prefix, RecursiveMode::Recursive)?; + + // Indicate that a covering watched path was found + found = true; + break; + } + } + + // Return refresh result + Ok(found) + } + + /// Clears all messages. + /// + /// This method clears all messages from the receiver, effectively dropping + /// all messages that have not been processed. This can be useful when a new + /// snapshot of the file system is taken, which is the case when the system + /// is reconfigured or restarted. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_watch::agent::Monitor; + /// + /// // Create file monitor and start watching + /// let mut monitor = Monitor::default(); + /// monitor.watch(".")?; + /// + /// // Clear all messages + /// monitor.clear(); + /// # Ok(()) + /// # } + /// ``` + pub fn clear(&mut self) { + while self.receiver.try_recv().is_ok() {} + } + + /// Returns an iterator over all pending messages. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_watch::agent::Monitor; + /// + /// // Create file monitor and start watching + /// let mut monitor = Monitor::default(); + /// monitor.watch(".")?; + /// + /// // Create iterator over file monitor + /// for message in &monitor { + /// println!("Message: {:?}", message); + /// } + /// # Ok(()) + /// # } + /// ``` + #[inline] + #[must_use] + pub fn iter(&self) -> TryIter<'_, Result> { + self.receiver.try_iter() + } + + /// Configures the file watcher backend. + /// + /// This method configures the file watcher by checking all watched paths + /// and updating the set of actively watched paths. This is necessary when + /// a path is added or removed, as the set of watched paths might change, + /// because a path that is added might cover an actively watched path. + #[allow(clippy::bool_comparison)] + fn configure(&mut self) -> Result { + let mut defer = Vec::new(); + + // We need to reconfigure the set of watched paths, as we might have + // added a path that covers an actively watched path + let mut watched: Option<&PathBuf> = None; + for (current, active) in &mut self.paths { + if watched + .filter(|prefix| current.starts_with(prefix)) + .is_some() + { + // The actively watched path is a prefix of the current path, + // so a covering path was added, which means we must remove it + if *active == true { + *active = false; + + // Unwatch immediately, accounting for `kqueue`, which emits + // mysterious errors. See the `refresh` method for details. + if self.kind == Kind::Kqueue { + let _ = self.watcher.unwatch(current); + } else { + self.watcher.unwatch(current)?; + } + } + } else { + // The actively watched path isn't a prefix of the current path, + // so we must watch the current path if it's not already watched + if *active == false { + *active = true; + + // Defer watch for upward propagation + defer.push(current); + } + + // Update actively watched path + watched = Some(current); + } + } + + // Watch paths after iteration + let empty = defer.is_empty(); + for path in defer { + self.watcher.watch(path, RecursiveMode::Recursive)?; + } + + // Return configuration result + Ok(!empty) + } +} + +#[allow(clippy::must_use_candidate)] +impl Monitor { + /// Returns the file watcher backend. + #[inline] + pub fn kind(&self) -> Kind { + self.kind + } + + /// Returns the watched paths. + #[inline] + pub fn paths(&self) -> &BTreeMap { + &self.paths + } + + /// Returns the underlying receiver. + #[inline] + pub fn as_receiver(&self) -> &Receiver> { + &self.receiver + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl<'a> IntoIterator for &'a Monitor { + type Item = Result; + type IntoIter = TryIter<'a, Self::Item>; + + /// Creates an iterator over the file monitor. + /// + /// # Examples + /// + /// ``` + /// # use std::error::Error; + /// # fn main() -> Result<(), Box> { + /// use zensical_watch::agent::Monitor; + /// + /// // Create file monitor and start watching + /// let mut monitor = Monitor::default(); + /// monitor.watch(".")?; + /// + /// // Create iterator over file monitor + /// for message in &monitor { + /// println!("Message: {:?}", message); + /// } + /// # Ok(()) + /// # } + /// ``` + #[inline] + fn into_iter(self) -> TryIter<'a, Self::Item> { + self.iter() + } +} + +// ---------------------------------------------------------------------------- + +impl Default for Monitor { + /// Creates a file monitor with the recommended watcher. + /// + /// This method creates a file monitor by using the [`RecommendedWatcher`], + /// which is platform-dependent. In order to create a file monitor using a + /// specific watcher, e.g., the [`PollWatcher`][], [`Monitor::new`] can + /// be used instead. + /// + /// [`PollWatcher`]: notify::PollWatcher + /// + /// # Panics + /// + /// Panics if [`notify`] returns an error on [`Watcher`] creation, as the + /// file monitor is required for the file agent. + /// + /// # Examples + /// + /// ``` + /// use zensical_watch::agent::Monitor; + /// + /// // Create file monitor + /// let mut monitor = Monitor::default(); + /// ``` + #[inline] + fn default() -> Self { + Self::new::(Config::default()) + } +} + +// ---------------------------------------------------------------------------- + +impl fmt::Debug for Monitor { + /// Formats the file monitor for debugging. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Monitor") + .field("kind", &self.kind) + .field("paths", &self.paths) + .field("receiver", &self.receiver) + .finish_non_exhaustive() + } +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// File watcher backend. +pub type Kind = WatcherKind; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Filters a file event, checking whether it should be forwarded or not. This +/// function is parametrized over the watcher, so the compiler can optimize it. +#[inline] +fn filter(event: Event) -> Option +where + W: 'static + Watcher, +{ + // Unfortunately, the `kqueue` file watcher backend spuriously emits paths + // that were not actually touched if changes are detected inside symbolic + // links, which is why we must check for them and ignore them. Only perform + // this check in case of `kqueue`, as it's not necessary for other backends. + // + // Related issue on GitHub: + // https://github.com/notify-rs/notify/issues/644 + if let Kind::Kqueue = W::kind() { + let mut iter = event.paths.iter(); + iter.all(|path| { + // In case the path is not a symbolic link itself, we check if it's + // a path located inside a symbolic link, as we must ignore those + if path.is_symlink() { + true + } else { + fs::canonicalize(path).map_or(true, |check| check == *path) + } + }) + } else { + true + } + .then_some(event) +} diff --git a/crates/zensical-watch/src/lib.rs b/crates/zensical-watch/src/lib.rs new file mode 100644 index 0000000..94a25ce --- /dev/null +++ b/crates/zensical-watch/src/lib.rs @@ -0,0 +1,35 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File agent and utilities. + +#![allow(clippy::match_same_arms)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::unnecessary_wraps)] + +pub mod agent; + +pub use agent::event; +pub use agent::{Agent, Error, Result}; diff --git a/crates/zensical/Cargo.toml b/crates/zensical/Cargo.toml new file mode 100644 index 0000000..7c8705d --- /dev/null +++ b/crates/zensical/Cargo.toml @@ -0,0 +1,76 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +[package] +name = "zensical" +version = "0.0.0" +description = "Zensical" +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +documentation.workspace = true +repository.workspace = true +authors.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[lib] +name = "zensical" +crate-type = ["cdylib"] + +[dependencies] +zensical-serve.workspace = true +zensical-watch.workspace = true + +ahash.workspace = true +crossbeam.workspace = true +fluent-uri.workspace = true +minijinja = { workspace = true, features = [ + "json", "loader", "builtins", "urlencode" +] } +minijinja-contrib = { workspace = true, features = ["html_entities"] } +mio = { workspace = true, features = ["net", "os-poll"] } +pyo3.workspace = true +serde = { workspace = true, features = ["derive", "rc"] } +serde_json.workspace = true +thiserror.workspace = true +tracing = { workspace = true, optional = true, features = [ + "max_level_trace", + "release_max_level_error" +] } +tracing-chrome = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } +zrx.workspace = true + +[features] +default = ["tracing"] +tracing = [ + "dep:tracing", + "dep:tracing-chrome", + "dep:tracing-subscriber", + "zensical-watch/tracing", + "zrx/tracing", +] diff --git a/crates/zensical/src/config.rs b/crates/zensical/src/config.rs new file mode 100644 index 0000000..0343525 --- /dev/null +++ b/crates/zensical/src/config.rs @@ -0,0 +1,226 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Configuration. + +use fluent_uri::Uri; +use pyo3::types::PyAnyMethods; +use pyo3::{PyErr, Python}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::{fs, iter}; +use zrx::path::PathExt; + +mod error; +pub mod extra; +pub mod mdx; +pub mod plugins; +mod project; +pub mod theme; + +pub use error::Result; +pub use project::Project; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Configuration. +/// +/// Note that this data model exactly matches Material for MkDocs' data model, +/// as it's where we're coming from, and we need to make sure that migration is +/// seamless. This is also why we scope all settings under the `project` key, +/// so we can move them out one by one once we start refactoring configuration. +#[derive(Clone, Debug)] +pub struct Config { + /// Path to configuration file. + pub path: PathBuf, + /// Project settings. + pub project: Arc, + /// Theme directories. + pub theme_dirs: Vec, + /// Configuration hash. + pub hash: u64, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Config { + /// Creates a configuration by loading and parsing the file at given path. + /// + /// This method supports `mkdocs.yml`, as well as `zensical.toml` files. + /// Right now, parsing is done in Python for compatibility with MkDocs. + pub fn new

(path: P) -> Result + where + P: AsRef, + { + let path = path.as_ref(); + Python::attach(|py| { + // Configuration is parsed in Python, since we must support certain + // YAML tags like `!ENV`, and allow to reference Python functions + // in configuration. For TOML, this is technically not necessary, + // but we'll move it through the same pipeline for consistency. + let module = py.import("zensical.config")?; + let config = module + .call_method1("parse_config", (path.to_string_lossy(),))? + .extract::()?; + + // Obtain main theme directory from, which is distributed in a + // subfolder as part of the Python package + let theme_dir = + module.call_method0("get_theme_dir")?.extract::()?; + + // Return configuration and theme directory + Ok::<_, PyErr>((config, theme_dir)) + }) + .map_err(Into::into) + .and_then(|(project, theme_dir)| { + // Merge theme directories, giving precedence to custom directory + // over the main theme directory to allow for overrides + let iter = project.theme.custom_dir.clone().into_iter(); + let theme_dirs = iter + .chain(iter::once(theme_dir)) + .map(|path| path.canonicalize().expect("invariant")) + .collect(); + + // Precompute hash + let hash = { + let mut hasher = DefaultHasher::default(); + project.hash(&mut hasher); + hasher.finish() + }; + + // Return configuration + Ok(Config { + path: path.canonicalize()?, + project: Arc::new(project), + theme_dirs, + hash, + }) + }) + } + + /// Returns the directory the configuration file is located in. + pub fn get_root_dir(&self) -> PathBuf { + let mut path = self.path.clone(); + path.pop(); + path + } + + /// Returns the docs directory, resolved relative to the configuration file. + pub fn get_docs_dir(&self) -> PathBuf { + let mut path = self.path.clone(); + path.pop(); + + // Ensure directory exists + let path = path.join(&self.project.docs_dir); + fs::create_dir_all(&path) + .and_then(|()| path.canonicalize()) + .expect("invariant") + } + + /// Returns the site directory, resolved relative to the configuration file. + pub fn get_site_dir(&self) -> PathBuf { + let mut path = self.path.clone(); + path.pop(); + + // Ensure directory exists + let path = path.join(&self.project.site_dir); + fs::create_dir_all(&path) + .and_then(|()| path.canonicalize()) + .expect("invariant") + } + + /// Returns the cache directory, resolved relative to the configuration file. + pub fn get_cache_dir(&self) -> PathBuf { + let mut path = self.path.clone(); + path.pop(); + + // Ensure directory exists + let path = path.join(".cache"); + fs::create_dir_all(&path) + .and_then(|()| path.canonicalize()) + .inspect(|path| { + let gitignore = path.join(".gitignore"); + if !gitignore.exists() { + fs::write(gitignore, "*").expect("invariant"); + } + }) + .expect("invariant") + } + + /// Returns the base URL, derived from the site URL if available. + #[allow(clippy::unused_self)] + pub fn get_base_url

(&self, path: P) -> String + where + P: AsRef, + { + PathBuf::from(".") + .relative_to(path) + .to_string_lossy() + .into_owned() + } + + /// Returns the base path, derived from the site URL if available. + pub fn get_base_path(&self) -> String { + let site_url = self.project.site_url.clone(); + + // Determine base path from site URL, if available + let mut base = match Uri::parse(site_url.unwrap_or_default()) { + Ok(uri) => uri.path().as_str().to_string(), + Err(_) => String::from("/"), + }; + + // Ensure base path is at least a slash + if base.is_empty() { + base = String::from("/"); + } + + // Ensure base path doesn't end with slash, unless it's just a slash + if base == "/" { + base + } else { + base.trim_end_matches('/').to_string() + } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Hash for Config { + /// Hashes the navigation. + #[inline] + fn hash(&self, state: &mut H) + where + H: Hasher, + { + state.write_u64(self.hash); + } +} diff --git a/crates/zensical/src/config/error.rs b/crates/zensical/src/config/error.rs new file mode 100644 index 0000000..ff90e3b --- /dev/null +++ b/crates/zensical/src/config/error.rs @@ -0,0 +1,69 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Configuration error. + +use pyo3::exceptions::PyIOError; +use pyo3::PyErr; +use std::{io, result}; +use thiserror::Error; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Configuration error. +#[derive(Debug, Error)] +pub enum Error { + /// I/O error. + #[error(transparent)] + Io(#[from] io::Error), + + /// PyO3 error. + #[error(transparent)] + PyO3(#[from] pyo3::PyErr), +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl From for PyErr { + /// Converts a configuration error to a [`PyErr`]. + #[inline] + fn from(err: Error) -> PyErr { + match err { + Error::Io(err) => PyErr::new::(err.to_string()), + Error::PyO3(err) => err, + } + } +} + +// ---------------------------------------------------------------------------- +// Type aliases +// ---------------------------------------------------------------------------- + +/// Configuration result. +pub type Result = result::Result; diff --git a/crates/zensical/src/config/extra.rs b/crates/zensical/src/config/extra.rs new file mode 100644 index 0000000..27578ca --- /dev/null +++ b/crates/zensical/src/config/extra.rs @@ -0,0 +1,47 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Extra settings. + +use pyo3::FromPyObject; +use serde::Serialize; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Extra JavaScript file. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct ExtraScript { + /// Script path. + pub path: String, + /// Script type. + pub r#type: Option, + /// Script async attribute. + pub r#async: bool, + /// Script defer attribute. + pub defer: bool, +} diff --git a/crates/zensical/src/config/mdx.rs b/crates/zensical/src/config/mdx.rs new file mode 100644 index 0000000..50c5891 --- /dev/null +++ b/crates/zensical/src/config/mdx.rs @@ -0,0 +1,55 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Markdown extension settings. + +use pyo3::FromPyObject; +use serde::Serialize; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Markdown extension settings. +/// +/// Note that this is only a tiny subset of values from the `mdx_configs` value +/// that is used inside the templates of Material for MkDocs to obtain the title +/// of the table of contents from the extension configuration. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct MdxConfigs { + /// Table of contents extension. + pub toc: TableOfContents, +} + +// ---------------------------------------------------------------------------- + +/// Table of contents extension. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct TableOfContents { + /// Table of contents title. + pub title: Option, +} diff --git a/crates/zensical/src/config/plugins.rs b/crates/zensical/src/config/plugins.rs new file mode 100644 index 0000000..6718176 --- /dev/null +++ b/crates/zensical/src/config/plugins.rs @@ -0,0 +1,92 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Plugin settings. + +use pyo3::FromPyObject; +use serde::Serialize; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Plugin settings. +/// +/// This data type includes configuration for functionality that is implemented +/// as part of plugins in MkDocs. Right now, this is only a small subset, and +/// only provided for compatibility with our templates. We'll replace this with +/// the module system in the near future. +/// +/// Also note that we require the plugins to be set, which is ensured by the +/// configuration parser that is currently implemented in Python. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct Plugins { + /// Search plugin. + pub search: SearchPlugin, + /// Offline plugin. + pub offline: OfflinePlugin, +} + +// ---------------------------------------------------------------------------- + +/// Search plugin. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct SearchPlugin { + /// Plugin configuration. + pub config: SearchPluginConfig, +} + +/// Search plugin configuration. +/// +/// This second layer is necessary to make our templates compatible with +/// Material for MkDocs, since MkDocs exposes the search plugin instance. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct SearchPluginConfig { + /// Whether the search plugin is enabled. + pub enabled: bool, + /// Tokenizer separator. + pub separator: String, +} + +// ---------------------------------------------------------------------------- + +/// Offline plugin. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct OfflinePlugin { + /// Plugin configuration. + pub config: OfflinePluginConfig, +} + +/// Offline plugin configuration. +#[derive(Clone, Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct OfflinePluginConfig { + /// Whether the offline plugin is enabled. + pub enabled: bool, +} diff --git a/crates/zensical/src/config/project.rs b/crates/zensical/src/config/project.rs new file mode 100644 index 0000000..dda581b --- /dev/null +++ b/crates/zensical/src/config/project.rs @@ -0,0 +1,89 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Project settings. + +use pyo3::FromPyObject; +use serde::Serialize; + +use crate::structure::dynamic::Dynamic; +use crate::structure::nav::NavigationItem; + +use super::extra::ExtraScript; +use super::mdx::MdxConfigs; +use super::plugins::Plugins; +use super::theme::Theme; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Project settings. +#[derive(Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct Project { + /// Site name. + pub site_name: String, + /// Site URL. + pub site_url: Option, + /// Site description. + pub site_description: Option, + /// Site author. + pub site_author: Option, + /// Docs directory (sources). + pub docs_dir: String, + /// Site directory (outputs). + pub site_dir: String, + /// Whether to use directory URLs. + pub use_directory_urls: bool, + /// Development server address. + pub dev_addr: String, + /// Copyright notice. + pub copyright: Option, + /// Repository URL. + pub repo_url: Option, + /// Repository name. + pub repo_name: Option, + /// Edit URI template. + pub edit_uri_template: Option, + /// Edit URI. + pub edit_uri: Option, + /// Theme settings. + pub theme: Theme, + /// Extra settings. + pub extra: Dynamic, + /// Extra CSS files. + pub extra_css: Vec, + /// Extra JavaScript files. + pub extra_javascript: Vec, + /// Extra template files. + pub extra_templates: Vec, + /// Markdown extension configuration. + pub mdx_configs: MdxConfigs, + /// Plugins. + pub plugins: Plugins, + /// Navigation structure. + pub nav: Vec, +} diff --git a/crates/zensical/src/config/theme.rs b/crates/zensical/src/config/theme.rs new file mode 100644 index 0000000..d0d0796 --- /dev/null +++ b/crates/zensical/src/config/theme.rs @@ -0,0 +1,182 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Theme settings. + +use pyo3::FromPyObject; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Theme settings. +#[derive(Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct Theme { + /// Theme custom directory. + pub custom_dir: Option, + /// Theme variant. + pub variant: Option, + /// Language. + pub language: String, + /// Text direction. + pub direction: Option, + /// Feature flags. + pub features: Vec, + /// Font settings. + pub font: Font, + /// Static templates. + pub static_templates: Vec, + /// Favicon. + pub favicon: Option, + /// Logo. + pub logo: Option, + /// Icon settings. + pub icon: Icon, + /// Color palette settings. + pub palette: Vec, +} + +// ---------------------------------------------------------------------------- + +/// Font settings. +#[derive(Debug, Hash, FromPyObject, Serialize)] +#[serde(untagged)] +#[pyo3(from_item_all)] +pub enum Font { + /// Use custom fonts. + Custom(CustomFont), + /// Use system fonts. + System(bool), +} + +/// Custom fonts. +#[derive(Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct CustomFont { + /// Text font. + pub text: String, + /// Code font. + pub code: String, +} + +// ---------------------------------------------------------------------------- + +/// Icon settings. +#[derive(Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct Icon { + /// Edit button icon. + pub edit: Option, + /// View button icon. + pub view: Option, + /// Logo icon. + pub logo: Option, + /// Repository icon. + pub repo: Option, + /// Annotation icon. + pub annotation: Option, + /// Back-to-top icon. + pub top: Option, + /// Search sharing icon. + pub share: Option, + /// Menu icon. + pub menu: Option, + /// Alternate languages icon. + pub alternate: Option, + /// Search icon. + pub search: Option, + /// Close icon. + pub close: Option, + /// Previous page icon. + pub previous: Option, + /// Next page icon. + pub next: Option, + /// Admonition icons. + pub admonition: Option, + /// Tag icons. + pub tag: BTreeMap, +} + +/// Admonition icons. +#[derive(Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct AdmonitionIcon { + /// Admonition `note` icon. + pub note: Option, + /// Admonition `abstract` icon. + pub r#abstract: Option, + /// Admonition `info` icon. + pub info: Option, + /// Admonition `tip` icon. + pub tip: Option, + /// Admonition `success` icon. + pub success: Option, + /// Admonition `question` icon. + pub question: Option, + /// Admonition `warning` icon. + pub warning: Option, + /// Admonition `failure` icon. + pub failure: Option, + /// Admonition `danger` icon. + pub danger: Option, + /// Admonition `bug` icon. + pub bug: Option, + /// Admonition `example` icon. + pub example: Option, + /// Admonition `quote` icon. + pub quote: Option, +} + +// ---------------------------------------------------------------------------- + +/// Color palette settings. +#[derive(Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct Palette { + /// Palette media query. + pub media: Option, + /// Palette scheme. + pub scheme: Option, + /// Palette primary color. + pub primary: Option, + /// Palette accent color. + pub accent: Option, + /// Palette toggle. + pub toggle: Option, +} + +/// Color palette toggle. +#[derive(Debug, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct PaletteToggle { + /// Palette toggle icon. + pub icon: Option, + /// Palette toggle name. + pub name: Option, +} diff --git a/crates/zensical/src/lib.rs b/crates/zensical/src/lib.rs new file mode 100644 index 0000000..6625d5e --- /dev/null +++ b/crates/zensical/src/lib.rs @@ -0,0 +1,230 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Zensical Python bindings. + +#![allow(clippy::default_constructed_unit_structs)] +#![allow(clippy::doc_markdown)] +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::needless_pass_by_value)] + +use crossbeam::channel::unbounded; +use pyo3::prelude::*; +use std::path::PathBuf; +use std::time::{Duration, Instant}; +use zrx::scheduler::action::Report; +use zrx::scheduler::Scheduler; + +mod config; +mod server; +mod structure; +mod template; +mod watcher; +mod workflow; + +use config::Config; +use server::create_server; +use watcher::Watcher; +use workflow::create_workspace; + +// ---------------------------------------------------------------------------- +// Enums +// ---------------------------------------------------------------------------- + +/// Build mode. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Mode { + /// Build the project once. + Build(bool), + /// Build the project continuously. + Serve(String, u64), +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Setup tracing if enabled. +#[cfg(feature = "tracing")] +fn setup_tracing() -> tracing_chrome::FlushGuard { + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::Registry; + let (chrome_layer, guard) = tracing_chrome::ChromeLayerBuilder::new() + .file("trace.json") + .include_args(true) + .include_locations(true) + .build(); + + // Create and subscribe tracing subscriber + let subscriber = Registry::default().with(chrome_layer); + let _ = tracing::subscriber::set_global_default(subscriber); + guard +} + +/// Handle report from the scheduler. +fn handle(report: Report) { + for diagnostic in &report { + println!("[{:?}] {}", diagnostic.severity, diagnostic.message); + } +} + +/// Run the build process. +fn run(config_file: &PathBuf, mode: Mode) -> PyResult { + #[cfg(feature = "tracing")] + let _guard = setup_tracing(); + + // In case the configuration changes, we recreate the entire workspace and + // scheduler. Once we have the module system set up, this will be tightly + // integrated and not necessary anymore, since partial rebuilds of the + // network of tasks will be supported. + let config = Config::new(config_file)?; + + // Clean cache directory if requested + if let Mode::Build(true) = mode { + let cache_dir = config.get_cache_dir(); + if cache_dir.exists() { + std::fs::remove_dir_all(&cache_dir) + .expect("cache directory could not be removed"); + } + } + + // Always clean site directory before building for now - we're working on + // true differential builds, which will also include cleaning up old files + // that are not needed anymore but for now, we just remove everything, like + // MkDocs does it. + let site_dir = config.get_site_dir(); + if site_dir.exists() { + std::fs::remove_dir_all(&site_dir) + .expect("site directory could not be removed"); + } + + // Create workspace and scheduler + let workspace = create_workspace(&config); + let mut scheduler = Scheduler::new(workspace.into_builder().build()); + + // Create channel for reload notifications + let (sender, receiver) = unbounded(); + + // Create session to connect file agent and scheduler - note that we must + // assign the agent to a variable right now, or it is dropped, and will + // automatically terminate. This is a temporary workaround until we could + // better integrate the scheduler with the agent. + let session = scheduler.session().expect("invariant"); + + // If site should be served, create HTTP server - note that we must assign + // the agent to a variable right now or it's dropped and will automatically + // terminate. This is a temporary workaround until we could better integrate + // the scheduler with the agent. + let waker = match &mode { + Mode::Build(_) => None, + Mode::Serve(addr, seq) => { + if *seq == 0 { + println!("Serving {} on {addr}", site_dir.display()); + } else { + println!("Reloading..."); + } + Some(create_server(&config, receiver, Some(addr.clone()))) + } + }; + let watcher = Watcher::new(&config, session, sender, waker.clone())?; + + // Start event loop after a short delay - once we tightly integrated the + // file agent with the scheduler, the sleep can be removed + println!("Build started"); + let time = Instant::now(); + loop { + match mode { + // Build mode - just exit when we're done + Mode::Build(_) => { + handle(scheduler.tick()); + // @todo this is a hack to ensure we don't exit too early, as + // we need to improve the interop between scheduler and agent + if scheduler.is_empty() && scheduler.total() > 100 { + let elapsed = time.elapsed().as_secs_f32(); + println!("Build finished in {elapsed:.2}s"); + break; + } + } + // Serve mode - keep watching, until the watcher terminates, which + // happens if the configuration file changed. After we've integrated + // the scheduler with the agent, we can remove this temporary hack + // and have immediate reloading. + Mode::Serve(_, _) => { + handle(scheduler.tick_timeout(Duration::from_millis(100))); + if watcher.is_terminated() { + // Wake the server + if let Some(waker) = &waker { + waker.wake()?; + } + return Ok(true); + } + } + } + + // Allow Python to handle signals (e.g., Ctrl+C) + if Python::attach(|py| py.check_signals().is_err()) { + println!("Received interrupt, exiting"); + break; + } + } + + // All good + Ok(false) +} + +// ---------------------------------------------------------------------------- + +/// Builds the project. +#[pyfunction] +fn build(py: Python, config_file: PathBuf, clean: bool) -> PyResult<()> { + py.detach(|| { + run(&config_file, Mode::Build(clean))?; + Ok(()) + }) +} + +/// Builds and serves the project. +#[pyfunction] +fn serve(py: Python, config_file: PathBuf, dev_addr: String) -> PyResult<()> { + let mut seq = 0; + py.detach(|| loop { + match run(&config_file, Mode::Serve(dev_addr.clone(), seq)) { + Ok(true) => { + seq += 1; + } + other => return other.map(|_| ()), + } + }) +} + +// ---------------------------------------------------------------------------- + +/// Expose Rust runtime to Python. +#[pymodule] +fn zensical(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_function(wrap_pyfunction!(build, m)?)?; + m.add_function(wrap_pyfunction!(serve, m)?)?; + Ok(()) +} diff --git a/crates/zensical/src/server.rs b/crates/zensical/src/server.rs new file mode 100644 index 0000000..42caee1 --- /dev/null +++ b/crates/zensical/src/server.rs @@ -0,0 +1,99 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Preview server. + +use crossbeam::channel::{unbounded, Receiver}; +use mio::Waker; +use std::sync::Arc; +use std::{fs, thread}; +use zensical_serve::handler::Stack; +use zensical_serve::middleware; +use zensical_serve::server::{Result, Server}; + +use super::config::Config; + +mod client; + +use client::Client; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Creates an HTTP server to serve the site. +pub fn create_server( + config: &Config, receiver: Receiver, addr: Option, +) -> Arc { + let site_dir = config.get_site_dir(); + fs::create_dir_all(&site_dir).expect("site directory could not be created"); + + // Create a one shot channel to extract waker - this is currently necessary, + // so that the server wakes up when the file watcher emits new events + let (tx, rx) = unbounded(); + + // Create new thread to run the server + let base = config.get_base_path(); + let addr = addr.unwrap_or_else(|| config.project.dev_addr.clone()); + thread::spawn({ + let tx = tx.clone(); + move || -> Result { + // Ensure site directory exists + fs::create_dir_all(&site_dir).unwrap(); + let stack = Stack::new() + .with(Client::default()) + .with(middleware::WebSocketHandshake::default()) + .with(middleware::NormalizePath::default()) + .with(middleware::BasePath::new(base).expect("invariant")) + .with( + middleware::StaticFiles::new(&site_dir).expect("invariant"), + ); + + // Start server and extract waker for interaction with event loop + let mut server = match Server::new(stack, addr) { + Ok(server) => server, + Err(err) => { + let _ = tx.send(Err(err)); + return Ok(()); + } + }; + let _ = tx.send(Ok(server.waker())); + loop { + server.poll(Some(&receiver))?; + } + } + }); + + // Return waker, or fail if server thread could not be started - we need to + // restructure this logic, but for now, it's quite safe to assume that when + // the server thread could not be started, the address is already in use. + match rx.recv().expect("invariant") { + Ok(waker) => waker, + Err(err) => { + eprintln!("Error: {err}"); + std::process::exit(1); + } + } +} diff --git a/crates/zensical/src/server/client.rs b/crates/zensical/src/server/client.rs new file mode 100644 index 0000000..591a8e0 --- /dev/null +++ b/crates/zensical/src/server/client.rs @@ -0,0 +1,127 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Middleware for livereload client. + +use zensical_serve::handler::Handler; +use zensical_serve::http::{Header, Request, Response}; +use zensical_serve::middleware::Middleware; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Livereload client script. +/// +/// This script connects to the WebSocket server and listens for messages. When +/// a message is received, it will either reload the page or update a CSS file +/// dynamically to reflect changes without a full page reload, allowing for +/// very fast feedback loops when editing CSS files. +static CLIENT: &str = concat!( + "(() => {\n", + " const title = document.title;\n", + " let closed = false;\n", + " function pending(state) {\n", + " document.title = state ? \"Waiting for connection\" : title;\n", + " }\n", + " function connect() {\n", + " const socket = new WebSocket(`ws://${window.location.host}`);\n", + " pending(true);\n", + " socket.addEventListener(\"message\", ev => {\n", + " if (ev.data.endsWith(\".css\")) {\n", + " const file = ev.data.split(\"/\").pop();\n", + " document.querySelectorAll(`link[rel=\"stylesheet\"]`)", + " .forEach(link => {\n", + " if (link.href.includes(file)) {\n", + " const reload = link.cloneNode(true);\n", + " reload.addEventListener(\"load\", () => {\n", + " link.parentNode.removeChild(link)\n", + " })\n", + " }\n", + " });\n", + " return\n", + " }\n", + " if (ev.data.endsWith(\".js\")) {\n", + " window.location.reload()\n", + " }\n", + " if (ev.data == window.location.pathname) {\n", + " window.location.reload()\n", + " }\n", + " });\n", + " socket.addEventListener(\"open\", () => {\n", + " setTimeout(() => pending(false), 100);\n", + " console.info(`Connected to ${socket.url}`)\n", + " if (closed) {\n", + " window.location.reload()\n", + " }\n", + " });\n", + " socket.addEventListener(\"close\", () => {\n", + " closed = true\n", + " setTimeout(() => connect(), 1000)\n", + " })\n", + " }\n", + " connect()\n", + "})()\n" +); + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Middleware for livereload client. +#[derive(Default)] +pub struct Client; + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Middleware for Client { + /// Processes the given request. + fn process(&self, req: Request, next: &dyn Handler) -> Response { + let uri = req.uri.path.clone(); + let mut res = next.handle(req); + + // In case an HTML file is served, inject the client script + if let Some(value) = res.headers.get(Header::ContentType) { + if value.contains("text/html") { + res.body.extend(b""); + + // Update content length + res.headers.insert(Header::ContentLength, res.body.len()); + } + } + + // Never cache JavaScript or CSS files, so reloading works smoothly + if uri.ends_with(".js") || uri.ends_with(".css") { + res.headers.insert(Header::CacheControl, "no-cache"); + } + + // Return response + res + } +} diff --git a/crates/zensical/src/structure.rs b/crates/zensical/src/structure.rs new file mode 100644 index 0000000..ed2276b --- /dev/null +++ b/crates/zensical/src/structure.rs @@ -0,0 +1,34 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Site structure. + +pub mod dynamic; +pub mod markdown; +pub mod nav; +pub mod page; +pub mod search; +pub mod tag; +pub mod toc; diff --git a/crates/zensical/src/structure/dynamic.rs b/crates/zensical/src/structure/dynamic.rs new file mode 100644 index 0000000..356a156 --- /dev/null +++ b/crates/zensical/src/structure/dynamic.rs @@ -0,0 +1,96 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Dynamic value. + +use pyo3::FromPyObject; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::fmt; + +mod float; + +use float::Float; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Dynamic value. +/// +/// This data type represents any valid value that can be used as part of the +/// metadata of a page and the extra data of configuration, supporting strings, +/// booleans, integers, floating point numbers, lists, and maps, so basically +/// everything supported in YAML and TOML. +/// +/// Null value are not supported, and currently represented as empty strings. +/// We're aiming to provide a type safe way to define custom namespaces in the +/// configuration, so we'll definitely revisit this as part of our efforts to +/// make configuration much more flexible. +#[derive( + Clone, Debug, FromPyObject, Hash, PartialEq, Eq, Serialize, Deserialize, +)] +#[serde(untagged)] +#[pyo3(from_item_all)] +pub enum Dynamic { + /// String value. + String(String), + /// Boolean value. + Bool(bool), + /// Integer value. + Integer(i64), + /// Floating point value. + Float(Float), + /// List value. + List(Vec), + /// Map value. + Map(BTreeMap), +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl fmt::Display for Dynamic { + /// Formats the dynamic value for display. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Dynamic::String(value) => write!(f, "{value}"), + Dynamic::Bool(value) => write!(f, "{value}"), + Dynamic::Integer(value) => write!(f, "{value}"), + Dynamic::Float(value) => write!(f, "{value}"), + Dynamic::List(values) => { + let iter = values.iter().map(|v| format!("{v}")); + let values: Vec = iter.collect(); + write!(f, "[{}]", values.join(", ")) + } + Dynamic::Map(values) => { + let iter = values.iter().map(|(k, v)| format!("{k}: {v}")); + let values: Vec = iter.collect(); + write!(f, "{{{}}}", values.join(", ")) + } + } + } +} diff --git a/crates/zensical/src/structure/dynamic/float.rs b/crates/zensical/src/structure/dynamic/float.rs new file mode 100644 index 0000000..92f5711 --- /dev/null +++ b/crates/zensical/src/structure/dynamic/float.rs @@ -0,0 +1,71 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Floating point number with equality and hashing. + +use pyo3::FromPyObject; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::hash::{Hash, Hasher}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Floating point number. +#[derive(Clone, Debug, FromPyObject, Serialize, Deserialize)] +pub struct Float(pub f64); + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl PartialEq for Float { + /// Compares two floating point numbers for equality. + fn eq(&self, other: &Self) -> bool { + (self.0 - other.0).abs() < f64::EPSILON + } +} + +impl Eq for Float {} + +// ---------------------------------------------------------------------------- + +impl Hash for Float { + /// Hashes the number. + fn hash(&self, state: &mut H) + where + H: Hasher, + { + state.write(&self.0.to_ne_bytes()); + } +} + +impl fmt::Display for Float { + /// Formats the floating point number. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/zensical/src/structure/markdown.rs b/crates/zensical/src/structure/markdown.rs new file mode 100644 index 0000000..67289cc --- /dev/null +++ b/crates/zensical/src/structure/markdown.rs @@ -0,0 +1,139 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Markdown rendering. + +use pyo3::types::PyAnyMethods; +use pyo3::{FromPyObject, PyErr, Python}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use zrx::id::Id; +use zrx::scheduler::action::report::IntoReport; +use zrx::scheduler::action::Error; +use zrx::scheduler::Value; + +use crate::structure::dynamic::Dynamic; +use crate::structure::nav::to_title; +use crate::structure::search::SearchItem; +use crate::structure::toc::Section; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Markdown. +#[derive(Clone, Debug, FromPyObject, Serialize, Deserialize)] +#[pyo3(from_item_all)] +pub struct Markdown { + /// Markdown metadata. + pub meta: BTreeMap, + /// Markdown content. + pub content: String, + /// Search index. + pub search: Vec, + /// Page title extracted from Markdown. + pub title: String, + /// Table of contents. + pub toc: Vec

, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Markdown { + /// Renders Markdown using Python Markdown. + #[cfg_attr(feature = "tracing", tracing::instrument(skip_all))] + pub fn new(id: &Id, content: String) -> impl IntoReport { + let id = id.clone(); + Python::attach(|py| { + let module = py.import("zensical.markdown")?; + module + .call_method1("render", (content, id.location()))? + .extract::() + }) + .map_err(|err: PyErr| Error::from(Box::new(err) as Box<_>)) + .map(|markdown| Markdown { + title: extract_title(&id, &markdown), + meta: markdown.meta, + content: markdown.content, + search: markdown.search, + toc: markdown.toc, + }) + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Markdown {} + +// ---------------------------------------------------------------------------- + +impl PartialEq for Markdown { + fn eq(&self, other: &Self) -> bool { + self.content == other.content + } +} + +impl Eq for Markdown {} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Extract the title from the metadata or table of contents. +/// +/// MkDocs prioritizes the "title" metadata field over the actual title in the +/// page. This has been a huge source of confusion, as can be read here: +/// https://github.com/mkdocs/mkdocs/issues/3532 +/// +/// We'll fix this in our modular navigation proposal that will make title +/// handling much more flexible in the near future. +fn extract_title(id: &Id, markdown: &Markdown) -> String { + if let Some(value) = markdown.meta.get("title") { + return value.to_string(); + } + + // Otherwise, fall back to the first top-level heading, if existent + let mut iter = markdown.toc.iter(); + if let Some(item) = iter.find(|item| item.level == 1) { + return item.title.clone(); + } + + // As a last resort, use the file name + let location = id.location(); + + // Split location into components at slashes + let mut components = location + .split('/') + .map(ToString::to_string) + .collect::>(); + + // Extract file, and return title + let file = components.pop().expect("invariant"); + to_title(&file) +} diff --git a/crates/zensical/src/structure/nav.rs b/crates/zensical/src/structure/nav.rs new file mode 100644 index 0000000..cc52e71 --- /dev/null +++ b/crates/zensical/src/structure/nav.rs @@ -0,0 +1,416 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Navigation. + +use std::hash::{DefaultHasher, Hash, Hasher}; + +use ahash::HashMap; +use pyo3::FromPyObject; +use serde::Serialize; +use zrx::id::Id; +use zrx::scheduler::Value; +use zrx::stream::value::Chunk; + +use super::page::Page; + +mod item; +mod iter; +mod meta; + +pub use item::NavigationItem; +use iter::Iter; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Navigation. +/// +/// Besides the list of navigation items, this also provides methods to create +/// a navigation from a list of pages, and to set the active item based on the +/// current page, as well as to retrieve ancestors, previous and next pages. +/// This mirrors MkDocs' behavior, which is important for compatibility. +#[derive(Clone, Debug, PartialEq, Eq, FromPyObject, Serialize)] +pub struct Navigation { + /// Navigation items. + pub items: Vec, + /// Homepage, if defined. + pub homepage: Option, + /// Precomputed hash. + pub hash: u64, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Navigation { + /// Creates a navigation from the given items. + pub fn new(mut items: Vec, pages: Chunk) -> Self { + if items.is_empty() { + return Self::from(pages); + } + + // Create a map of pages for easy lookup, so we can resolve titles and + // icons from the file location of the respective page. + let mut pages = pages + .into_iter() + .map(|item| { + let id = item.id.location().to_string(); + (id, item.data) + }) + .collect::>(); + + // Since a navigation structure is given, we just need to add titles and + // icons where necessary and defined in page metadata + let mut stack = vec![&mut items]; + while let Some(children) = stack.pop() { + for item in children.iter_mut() { + // Here, we differ from MkDocs, in that navigation items can or + // cannot have URLs, since we model sections and pages with the + // same data type. This is definitely not the final design that + // we want, and we'll switch to a much more flexible approach + // once we work on modular navigation. The component system + // will also make things much easier here. + if let Some(url) = &item.url { + // Try to obtain a page for the given url. Users might also + // refer to non-existing pages, which we just ignore for now + if let Some(page) = pages.remove(url) { + // Set URLs from page - we currently resolve the final + // URL during rendering, so we just need to set it here. + // Once we start working on the component and module + // system, all of this is going to change anyway + item.url = Some(page.url); + item.canonical_url = page.canonical_url; + + // Set item title from page if not set + if item.title.is_none() { + item.title = Some(page.title); + } + + // Extract page metadata for selected keys + item.meta = Some(page.meta.into()); + } + } + + // Push children onto the stack for further processing + if !item.children.is_empty() { + stack.push(&mut item.children); + } + } + } + + // Determine homepage - sometimes, the index page isn't linked, which + // is why we try to obtain it from the remaining pages + let mut homepage = items.iter().find(|item| item.is_index).cloned(); + if homepage.is_none() { + if let Some(page) = pages.remove("index.md") { + homepage = Some(NavigationItem { + title: Some(page.title), + url: Some(page.url), + canonical_url: page.canonical_url, + meta: Some(page.meta.into()), + children: Vec::new(), + is_index: true, + active: false, + }); + } + } + + // Precompute hash + let hash = { + let mut hasher = DefaultHasher::default(); + items.hash(&mut hasher); + hasher.finish() + }; + + // Return navigation + Self { items, homepage, hash } + } + + /// Returns a copy of the navigation with the active item set based on the + /// current URL. This mirrors MkDocs' behavior of setting the "active" + /// state on navigation items, which is then used for styling. + /// + /// Note that this does not modify the navigation in place, but returns a + /// new instance with the active state set. This is important, as we need + /// to keep the original navigation structure intact for other pages. + pub fn with_active(&self, page: &Page) -> Self { + /// Recursively set active state on navigation items. + fn recurse(items: &mut [NavigationItem], url: &str) -> bool { + for item in items.iter_mut() { + if item.url.as_deref() == Some(url) { + item.active = true; + return true; + } + + // If we haven't found the item yet, recurse into children + if recurse(&mut item.children, url) { + item.active = true; + return true; + } + } + false + } + + // Set active state starting from the root + let mut items = self.items.clone(); + recurse(&mut items, &page.url); + Self { + items, + homepage: self.homepage.clone(), + hash: self.hash, + } + } + + /// Returns ancestors of the page with the given URL. + /// + /// Note that only the ancestors, not the page itself is returned, which + /// again, mirrors MkDocs' behavior, and is necessary for breadcrumbs. + pub fn ancestors(&self, page: &Page) -> Vec { + // Recursively find ancestors of the page with the given URL. + fn recurse<'a>( + items: &'a [NavigationItem], url: &str, + ancestors: &mut Vec<&'a NavigationItem>, + ) -> bool { + for item in items { + // If this item's URL matches, we've found the page. + if item.url.as_deref() == Some(url) { + return true; + } + + // Recurse into children, then treat this item as a potential + // ancestor, and push it before recursing and pop if the branch + // does not contain the page. + if !item.children.is_empty() { + ancestors.push(item); + if recurse(&item.children, url, ancestors) { + return true; + } + ancestors.pop(); + } + } + false + } + + // Clone the ancestors into owned items and reverse them, so we start + // at the ancestor closest to the page, not the root itself + let mut items: Vec<&NavigationItem> = Vec::new(); + let _ = recurse(&self.items, &page.url, &mut items); + items.into_iter().rev().cloned().collect() + } + + /// Returns an iterator over all navigation items in pre-order. + pub fn iter(&self) -> Iter<'_> { + Iter::new(&self.items) + } + + /// Return the next page for the given page in pre-order, if any. + pub fn next_page(&self, page: &Page) -> Option { + let mut found = false; + for item in self { + if found { + if item.url.is_some() { + return Some(item.clone()); + } + continue; + } + if item.url.as_deref() == Some(&page.url) { + found = true; + } + } + None + } + + /// Return the previous page for the given page in pre-order, if any. + pub fn previous_page(&self, page: &Page) -> Option { + let mut prev: Option = None; + for item in self { + if item.url.as_deref() == Some(&page.url) { + return prev; + } + if item.url.is_some() { + prev = Some(item.clone()); + } + } + None + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Navigation {} + +// ---------------------------------------------------------------------------- + +impl From> for Navigation { + /// Creates a navigation from pages. + /// + /// This mirrors the functionality of auto-populated navigation that MkDocs + /// provides. In the future, we intend to refactor this into a more flexible + /// system that allows for custom and modular navigation structures, but for + /// now, compatibility is key. + fn from(pages: Chunk) -> Self { + let mut items: Vec = Vec::new(); + + // Convert chunk into a vector for easier processing, and sort pages by + // the exact same method that MkDocs uses + let mut pages = Vec::from_iter(pages); + pages.sort_by_key(|item| file_sort_key(&item.id)); + + // There can only be pages, no URLs, since we're auto-populating the + // navigation from the files in the docs directory + for page in pages { + let location = page.id.location(); + + // Split location into components at slashes + let mut components = location + .split('/') + .map(ToString::to_string) + .collect::>(); + + // Extract file, and check, whether it's an index file + let file = components.pop().expect("invariant"); + + // Now, first obtain the subsection in which we need to insert the + // page. If there are no parents, we insert it at the top level. + let mut section = &mut items; + for component in components { + let title = to_title(&component); + + // Next, we try to find an existing section with the same title. + // If we find one, we descend into it, otherwise, we create. + let mut iter = section.iter(); + if let Some(index) = + iter.position(|item| item.title.as_ref() == Some(&title)) + { + section = &mut section[index].children; + } else { + section.push(NavigationItem { + title: Some(title), + url: None, + canonical_url: None, + meta: None, + children: Vec::new(), + is_index: false, + active: false, + }); + + // We just inserted an item, so it's safe to unwrap + let item = section.last_mut().expect("invariant"); + section = &mut item.children; + } + } + + // Insert page into the section + section.push(NavigationItem { + title: Some(page.data.title), + url: Some(page.data.url), + canonical_url: page.data.canonical_url, + meta: Some(page.data.meta.into()), + children: Vec::new(), + is_index: is_index(&file), + active: false, + }); + } + + // Precompute hash + let hash = { + let mut hasher = DefaultHasher::default(); + items.hash(&mut hasher); + hasher.finish() + }; + + // Determine homepage and return navigation + Self { + homepage: items.iter().find(|item| item.is_index).cloned(), + items, + hash, + } + } +} + +// ---------------------------------------------------------------------------- + +impl Hash for Navigation { + /// Hashes the navigation. + #[inline] + fn hash(&self, state: &mut H) + where + H: Hasher, + { + state.write_u64(self.hash); + } +} + +// ---------------------------------------------------------------------------- + +impl<'a> IntoIterator for &'a Navigation { + type Item = &'a NavigationItem; + type IntoIter = Iter<'a>; + + /// Returns an iterator over all navigation items in pre-order. + fn into_iter(self) -> Self::IntoIter { + Iter::new(&self.items) + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +// Returns a key that replicates MkDocs' navigation sorting behavior, ordering +// by parents, then putting the index page first, then sorting by name +pub(crate) fn file_sort_key(id: &Id) -> (Vec, bool, String) { + let location = id.location(); + + // Split location into components at slashes + let mut components = location + .split('/') + .map(ToString::to_string) + .collect::>(); + + // Extract file, and check, whether it's an index file + let file = components.pop().expect("invariant"); + (components, !is_index(&file), file) +} + +/// Returns whether the given file name is an index file. +fn is_index(component: &str) -> bool { + component == "index.md" || component == "README.md" +} + +/// Computes a page title from a file name, replicating MkDocs' behavior. +pub(crate) fn to_title(component: &str) -> String { + let mut title = component.trim_end_matches(".md").replace(['-', '_'], " "); + if title.to_lowercase() == title { + let first = title.chars().next().unwrap_or_default(); + title = first.to_uppercase().to_string() + &title[1..]; + } + title +} diff --git a/crates/zensical/src/structure/nav/item.rs b/crates/zensical/src/structure/nav/item.rs new file mode 100644 index 0000000..3699c5f --- /dev/null +++ b/crates/zensical/src/structure/nav/item.rs @@ -0,0 +1,55 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Navigation item. + +use pyo3::FromPyObject; +use serde::Serialize; + +use super::meta::NavigationMeta; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Navigation item. +#[derive(Clone, Debug, PartialEq, Eq, Hash, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct NavigationItem { + /// Item title. + pub title: Option, + /// Item URL. + pub url: Option, + /// Item canonical URL. + pub canonical_url: Option, + /// Item metadata. + pub meta: Option, + /// Item children. + pub children: Vec, + /// Whether this item is an index page. + pub is_index: bool, + /// Whether this item is currently active. + pub active: bool, +} diff --git a/crates/zensical/src/structure/nav/iter.rs b/crates/zensical/src/structure/nav/iter.rs new file mode 100644 index 0000000..2804967 --- /dev/null +++ b/crates/zensical/src/structure/nav/iter.rs @@ -0,0 +1,86 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Navigation iterator. + +use super::item::NavigationItem; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Navigation iterator. +pub struct Iter<'a> { + /// Iteration stack. + stack: Vec<(&'a [NavigationItem], usize)>, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl<'a> Iter<'a> { + /// Creates a navigation iterator. + pub fn new(items: &'a [NavigationItem]) -> Self { + let mut stack = Vec::new(); + if !items.is_empty() { + stack.push((items, 0)); + } + Self { stack } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl<'a> Iterator for Iter<'a> { + type Item = &'a NavigationItem; + + /// Advances the iterator and returns the next item. + fn next(&mut self) -> Option { + while let Some((slice, index)) = self.stack.last_mut() { + if *index >= slice.len() { + self.stack.pop(); + continue; + } + + // Advance index + let item = &slice[*index]; + *index += 1; + + // Push children slice so they are visited next (pre-order) + if !item.children.is_empty() { + self.stack.push((item.children.as_slice(), 0)); + } + + // Return current item + return Some(item); + } + + // No more items + None + } +} diff --git a/crates/zensical/src/structure/nav/meta.rs b/crates/zensical/src/structure/nav/meta.rs new file mode 100644 index 0000000..96c7875 --- /dev/null +++ b/crates/zensical/src/structure/nav/meta.rs @@ -0,0 +1,61 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Navigation item. + +use pyo3::FromPyObject; +use serde::Serialize; + +use crate::structure::page::PageMeta; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Navigation item metadata. +#[derive(Clone, Debug, PartialEq, Hash, Eq, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct NavigationMeta { + /// Page icon. + pub icon: Option, + /// Page status. + pub status: Option, +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl From for NavigationMeta { + /// Extract navigation metadata from a page. + fn from(meta: PageMeta) -> Self { + let icon = meta.get("icon").cloned(); + let status = meta.get("status").cloned(); + NavigationMeta { + icon: icon.map(|meta| meta.to_string()), + status: status.map(|meta| meta.to_string()), + } + } +} diff --git a/crates/zensical/src/structure/page.rs b/crates/zensical/src/structure/page.rs new file mode 100644 index 0000000..6b8e339 --- /dev/null +++ b/crates/zensical/src/structure/page.rs @@ -0,0 +1,247 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Page. + +use minijinja::{context, Error}; +use pyo3::FromPyObject; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; +use zensical_serve::http::Uri; +use zrx::id::Id; +use zrx::scheduler::Value; + +use crate::config::Config; +use crate::template::{Template, GENERATOR}; + +use super::dynamic::Dynamic; +use super::markdown::Markdown; +use super::nav::{Navigation, NavigationItem}; +use super::search::SearchItem; +use super::tag::Tag; +use super::toc::Section; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Page. +/// +/// This data type contains all data necessary for rendering a page, including +/// its content, metadata, table of contents, and relations to other pages. In +/// the future, we're going to split this up into smaller components, to make +/// rendering more modular, but right now, we just replicate what MkDocs does. +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, PartialEq, Eq, FromPyObject, Serialize)] +#[pyo3(from_item_all)] +pub struct Page { + /// Page target URL. + pub url: String, + /// Page canonical URL. + pub canonical_url: Option, + /// Page edit URL. + pub edit_url: Option, + /// Page title. + pub title: String, + /// Page metadata. + pub meta: PageMeta, + /// Page file system path. + pub path: String, + /// Page content. + pub content: String, + /// Table of contents. + pub toc: Vec
, + /// Search index. + pub search: Vec, + /// Ancestor pages. + pub ancestors: Vec, + /// Previous page. + pub previous_page: Option, + /// Next page. + pub next_page: Option, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Page { + /// Creates a page. + #[allow(clippy::similar_names)] + pub fn new(config: &Config, id: &Id, markdown: Markdown) -> Page { + let root_dir = config.get_root_dir(); + + // Retrieve site directory and URL + let site_dir = config.project.site_dir.clone(); + let site_url = config.project.site_url.clone(); + + // Retrieve repository URL and edit URI + let repo_url = config.project.repo_url.clone(); + let edit_uri = config.project.edit_uri.clone(); + + // Determine whether to use directory URLs + let use_directory_urls = config.project.use_directory_urls; + + // Create identifier builder, as we need to change the context in order + // to copy the file over to the site directory + let builder = id.to_builder().with_context(&site_dir); + let id = builder.clone().build().expect("invariant"); + + // Next, obtain the path, and check whether it is an index file, which + // is true for index.md, as well as README.md, as MkDocs handles both + let mut path: PathBuf = id.location().to_string().into(); + let is_index = + path.ends_with("index.md") || path.ends_with("README.md"); + + // Ensure that README.md files are treated as index files + if path.ends_with("README.md") { + path.pop(); + path = path.join("index.md"); + } + + // If directory URLs should not be used, and the page is an index page, + // we need to adjust the path accordingly + if !use_directory_urls || is_index { + path.set_extension("html"); + } else { + path.set_extension(""); + path.push("index.html"); + } + + // Set computed path in id, and compute final target path - once we add + // more convenience function to the id crate, we can make this shorter + let path = path.to_string_lossy().into_owned(); + let id = builder.with_location(&path).build().expect("invariant"); + + // Compute URL of page, and strip the index.html suffix in case + // directory URLs should be used. The URL is relative. + let url = id.as_uri().to_string(); + let url = if use_directory_urls { + url.trim_end_matches("index.html").to_string() + } else { + url + }; + + // Ensure path encoding, and compute canonical URL. Note that we should + // definitely rethink this interface, it's a little inconvenient + let url = Uri::from(url.as_ref()).to_string(); + let canonical_url = site_url.as_ref().map(|base| { + let base = base.trim_end_matches('/'); + format!("{base}/{url}") + }); + + // Compute edit URL - edit URIs can be relative or absolute, as both + // variants are supported by MkDocs, so we mirror behavior for now + let edit_url = repo_url.clone().and_then(|repo_url| { + edit_uri.clone().map(|uri| { + let file_uri = id.location().replace(".html", ".md"); + if uri.starts_with("https://") { + format!("{uri}/{file_uri}") + } else { + format!("{repo_url}/{uri}/{file_uri}") + } + }) + }); + + // Return page - note that ancestors, as well as previous and next + // pages are populated when the navigation is created. This is also a + // hint that it's not a good idea to centralize all propeties in a + // single struct, but to split up the page as necessary later on. + let path = root_dir.join(id.to_path()); + Page { + url, + title: markdown.title, + meta: markdown.meta, + canonical_url, + edit_url, + content: markdown.content, + toc: markdown.toc, + search: markdown.search, + path: path.to_string_lossy().into_owned(), + ancestors: Vec::new(), + previous_page: None, + next_page: None, + } + } + + /// Renders the page. + #[cfg_attr( + feature = "tracing", + tracing::instrument(skip_all, fields(url = %self.url)) + )] + pub fn render( + &mut self, config: &Config, nav: &Navigation, + ) -> Result { + let name = self.meta.get("template").map(ToString::to_string); + let template = Template::new( + name.unwrap_or(String::from("main.html")), + config.theme_dirs.clone(), + ); + + // Set active page in navigation and compute ancestors, as well as next + // and previous page, all of which we need for rendering navigation + let nav = nav.with_active(self); + self.ancestors = nav.ancestors(self); + self.previous_page = nav.previous_page(self); + self.next_page = nav.next_page(self); + + // Create context and render template + template.render_with_context(context! { + generator => GENERATOR, + nav => nav, + base_url => config.get_base_url(&self.url), + extra_css => config.project.extra_css.clone(), + extra_javascript => config.project.extra_javascript.clone(), + config => config.project.clone(), + tags => self.tags(), + page => self, + }) + } + + /// Returns the tags of the page. + pub fn tags(&self) -> Vec { + let mut tags = Vec::new(); + if let Some(Dynamic::List(values)) = self.meta.get("tags") { + for name in values { + tags.push(Tag { name: name.to_string() }); + } + } + tags + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for Page {} + +// ---------------------------------------------------------------------------- +// Type alises +// ---------------------------------------------------------------------------- + +/// Page metadata. +pub type PageMeta = BTreeMap; diff --git a/crates/zensical/src/structure/search.rs b/crates/zensical/src/structure/search.rs new file mode 100644 index 0000000..1b3f3d9 --- /dev/null +++ b/crates/zensical/src/structure/search.rs @@ -0,0 +1,140 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Search index. + +use pyo3::FromPyObject; +use serde::Serialize; +use zrx::id::Id; +use zrx::scheduler::Value; +use zrx::stream::value::Chunk; + +use crate::config::plugins::SearchPluginConfig; + +use super::nav::{file_sort_key, Navigation}; +use super::page::Page; + +mod item; + +pub use item::SearchItem; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Search configuration. +#[derive(Clone, Debug, PartialEq, Eq, FromPyObject, Serialize)] +pub struct SearchConfig { + /// Separator for tokenizer. + pub separator: String, +} + +/// Search index. +/// +/// Later, when the module system is available, we'll move search into a module +/// of its own, but for now, we'll just keep it here for simplicity. +#[derive(Clone, Debug, PartialEq, Eq, FromPyObject, Serialize)] +pub struct SearchIndex { + /// Search configuration. + pub config: SearchConfig, + /// Search items. + pub items: Vec, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl SearchIndex { + /// Creates a search index from pages. + #[allow(clippy::assigning_clones)] + pub fn new( + pages: Chunk, nav: &Navigation, config: SearchPluginConfig, + ) -> Self { + let mut items: Vec = Vec::new(); + + // Convert chunk into a vector for easier processing, and sort pages by + // the exact same method that MkDocs uses + let mut pages = Vec::from_iter(pages); + pages.sort_by_key(|item| file_sort_key(&item.id)); + + // Assemble search index, combining all items from all pages into a + // single, flat list, adjusting the location to include the page URL + for page in pages { + let iter = nav.ancestors(&page.data).into_iter().rev(); + let mut path = iter + .map(|item| item.title.expect("invariant")) + .collect::>(); + + // Add page title to path if not already present - this might be + // the true in case of index pages + if path.last() != Some(&page.data.title) { + path.push(page.data.title.clone()); + } + + // Extract page tags, if any + let tags: Vec = + page.data.tags().into_iter().map(|tag| tag.name).collect(); + + // For each page, adjust the location of each item and add it to + // the overall list + for mut item in page.data.search { + let location = match item.location { + Some(id) => format!("{}#{}", page.data.url, id), + _ => page.data.url.clone(), + }; + + // Fall back to page title, if item title is empty + if item.title.is_empty() { + item.title = page.data.title.clone(); + } + + // Update location and path and add item + item.location = Some(location); + item.path = path.clone(); + item.tags = tags.clone(); + items.push(item); + } + } + + // Return search + Self { config: config.into(), items } + } +} + +// ---------------------------------------------------------------------------- +// Trait implementations +// ---------------------------------------------------------------------------- + +impl Value for SearchIndex {} + +// ---------------------------------------------------------------------------- + +impl From for SearchConfig { + /// Converts plugin configuration into search configuration. + fn from(config: SearchPluginConfig) -> Self { + Self { separator: config.separator } + } +} diff --git a/crates/zensical/src/structure/search/item.rs b/crates/zensical/src/structure/search/item.rs new file mode 100644 index 0000000..123dbd0 --- /dev/null +++ b/crates/zensical/src/structure/search/item.rs @@ -0,0 +1,51 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Search item. + +use pyo3::FromPyObject; +use serde::{Deserialize, Serialize}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Search item. +#[derive(Clone, Debug, PartialEq, Eq, FromPyObject, Serialize, Deserialize)] +#[pyo3(from_item_all)] +pub struct SearchItem { + /// Search location. + pub location: Option, + /// Section level + pub level: u32, + /// Section title. + pub title: String, + /// Section text. + pub text: String, + /// Section path. + pub path: Vec, + /// Section tags. + pub tags: Vec, +} diff --git a/crates/zensical/src/structure/tag.rs b/crates/zensical/src/structure/tag.rs new file mode 100644 index 0000000..3ef3d1b --- /dev/null +++ b/crates/zensical/src/structure/tag.rs @@ -0,0 +1,40 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Tag. + +use pyo3::FromPyObject; +use serde::Serialize; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Tag. +#[derive(Clone, Debug, PartialEq, Eq, FromPyObject, Serialize)] +pub struct Tag { + /// Tag name. + pub name: String, +} diff --git a/crates/zensical/src/structure/toc.rs b/crates/zensical/src/structure/toc.rs new file mode 100644 index 0000000..6d20bb3 --- /dev/null +++ b/crates/zensical/src/structure/toc.rs @@ -0,0 +1,49 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Table of contents. + +use pyo3::FromPyObject; +use serde::{Deserialize, Serialize}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Table of contents section. +#[derive(Clone, Debug, PartialEq, Eq, FromPyObject, Serialize, Deserialize)] +#[pyo3(from_item_all)] +pub struct Section { + /// Section title. + pub title: String, + /// Section ID. + pub id: String, + /// Section URL. + pub url: String, + /// Section children. + pub children: Vec
, + /// Section level. + pub level: u8, +} diff --git a/crates/zensical/src/template.rs b/crates/zensical/src/template.rs new file mode 100644 index 0000000..9ab66c4 --- /dev/null +++ b/crates/zensical/src/template.rs @@ -0,0 +1,123 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! MiniJinja template engine. + +use minijinja::{context, AutoEscape, Environment, Error}; +use minijinja_contrib::filters::striptags; +use serde::Serialize; +use std::path::PathBuf; + +use super::config::Config; +use super::structure::nav::Navigation; + +mod filter; +mod loader; + +use filter::{script_tag_filter, url_filter}; +use loader::Loader; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// MiniJinja template. +pub struct Template<'a> { + /// Template environment + env: Environment<'a>, + /// Template name. + name: String, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Template<'_> { + /// Creates a template. + pub fn new(name: S, dirs: D) -> Self + where + S: Into, + D: IntoIterator, + { + let mut env = Environment::new(); + + // Create template loader with support for theme overrides + let loader = Loader::new(dirs); + env.set_loader(move |name| loader.load(name)); + + // Register the striptags filter, which isn't part of MiniJinja's common + // filters, and add our custom filters to replicate MkDocs' behavior + env.add_filter("striptags", striptags); + env.add_filter("url", url_filter); + env.add_filter("script_tag", script_tag_filter); + + // Reset auto-escaping, as we don't want to escape HTML in templates + env.set_auto_escape_callback(|_| AutoEscape::None); + Self { env, name: name.into() } + } + + /// Renders the template with the given context. + pub fn render_with_context(&self, context: C) -> Result + where + C: Serialize, + { + let template = self.env.get_template(&self.name)?; + template.render(context) + } + + /// Renders the template. + pub fn render( + &self, config: &Config, nav: &Navigation, + ) -> Result { + let template = self.env.get_template(&self.name)?; + let pages = nav.iter().collect::>(); + + // Create context and render template + template.render(context! { + generator => GENERATOR, + nav => nav, + pages => pages, + base_url => config.get_base_path(), + extra_css => config.project.extra_css.clone(), + extra_javascript => config.project.extra_javascript.clone(), + config => config.project.clone(), + // MiniJinja does not allow to pass empty objects, so we create a + // dummy page here - these won't be used in static templates + page => context! { + ancestors => Vec::<()>::new(), + toc => Vec::<()>::new() + }, + }) + } +} + +// ---------------------------------------------------------------------------- +// Constants +// ---------------------------------------------------------------------------- + +/// Generator string. +pub const GENERATOR: &str = + concat!(env!("CARGO_PKG_NAME"), "-", env!("CARGO_PKG_VERSION")); diff --git a/crates/zensical/src/template/filter.rs b/crates/zensical/src/template/filter.rs new file mode 100644 index 0000000..e1ecbe7 --- /dev/null +++ b/crates/zensical/src/template/filter.rs @@ -0,0 +1,116 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! MiniJinja template filters. + +use minijinja::{State, Value}; +use std::fmt::Write; +use std::path::Path; +use zrx::path::PathExt; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// MiniJinja `url` filter. +/// +/// This filter replicates the filter of the same name in MkDocs, resolving URLs +/// relative to the current page. If no page object is given, a static template +/// is rendered, which means that URLs must be resolved relative to base URL. +pub fn url_filter(state: &State, url: String) -> String { + if url.starts_with('#') || url.starts_with('/') { + return url; + } + + // Leave absolute links unchanged + if url.starts_with("http://") || url.starts_with("https://") { + return url; + } + + // Create target URL + let target = Path::new(&url); + + // Render URLs in pages + if let Some(source) = state + .lookup("page") + .and_then(|page| page.get_attr("url").ok()) + .filter(|value| !value.is_undefined()) + .map(|value| value.to_string()) + { + // Make target URL relative to page + target + .relative_to(&source) // fmt + .to_string_lossy() + .into_owned() + + // Render URLs in static templates + } else { + let source = state.lookup("base_url").expect("invariant"); + Path::new(&source.to_string()) + .join(target.normalize()) + .to_string_lossy() + .into_owned() + } +} + +/// MiniJinja `script_tag` filter. +/// +/// This filter replicates the filter of the same name in MkDocs, generating a +/// script tag from a `extra_javascript` entry, which was introduced in MkDocs +/// 1.5.0 to allow the use of JavaScript files that are ESM modules. We always +/// convert to a structured format when during configuration parsing. +/// +/// Note that MkDocs will set a missing path value to an empty string, which +/// is non-sensical, but we mirror behavior to stay compatible. +pub fn script_tag_filter(state: &State, value: Value) -> String { + let path = value.get_attr("path").unwrap_or(Value::from("")); + let mut html = + format!(""); + html +} diff --git a/crates/zensical/src/template/loader.rs b/crates/zensical/src/template/loader.rs new file mode 100644 index 0000000..d7938b8 --- /dev/null +++ b/crates/zensical/src/template/loader.rs @@ -0,0 +1,81 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! MiniJinja template engine. + +use minijinja::{Error, ErrorKind}; +use std::path::PathBuf; +use std::{fs, io}; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// MiniJinja template loader with override support. +pub struct Loader { + /// Template search directories. + dirs: Vec, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Loader { + /// Creates a template loader. + pub fn new(dirs: I) -> Self + where + I: IntoIterator, + { + Self { + dirs: dirs.into_iter().collect(), + } + } + + /// Loads a template by name, searching all configured directories. + pub fn load(&self, name: S) -> Result, Error> + where + S: AsRef, + { + for dir in &self.dirs { + match fs::read_to_string(dir.join(name.as_ref())) { + Ok(res) => return Ok(Some(res)), + Err(err) if err.kind() == io::ErrorKind::NotFound => { + // Try next directory + } + Err(err) => { + let inner = Error::new( + ErrorKind::InvalidOperation, + "could not read template", + ); + return Err(inner.with_source(err)); + } + } + } + + // No template found + Ok(None) + } +} diff --git a/crates/zensical/src/watcher.rs b/crates/zensical/src/watcher.rs new file mode 100644 index 0000000..d5af980 --- /dev/null +++ b/crates/zensical/src/watcher.rs @@ -0,0 +1,218 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! File watcher. + +use crossbeam::channel::Sender; +use mio::Waker; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use zensical_watch::event::{Event, Kind}; +use zensical_watch::{Agent, Error, Result}; +use zrx::id::Id; +use zrx::scheduler::Session; + +use super::config::Config; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// File watcher. +/// +/// This is a thin wrapper around the file agent. We're going to refactor this +/// logic into a provider architecture that will make things more flexible. +pub struct Watcher { + /// File agent. + agent: Agent, +} + +// ---------------------------------------------------------------------------- +// Implementations +// ---------------------------------------------------------------------------- + +impl Watcher { + /// Creates a file watcher. + pub fn new( + config: &Config, session: Session, reload: Sender, + waker: Option>, + ) -> Result { + let mut sources = Vec::default(); + + // Add docs directory and theme directories + sources.push((config.get_docs_dir(), config.project.docs_dir.clone())); + for (i, theme_dir) in config.theme_dirs.iter().enumerate() { + sources.push((theme_dir.clone(), format!("templates/{i}"))); + } + + // Add configuration file last, or we might run into overlapping paths. + // Note that right now, we need to monitor the whole directory. We'll + // integrate identification generation deeper into the file agent, + // so we can make sure that there won't be any ambiguities. + let mut path = config.path.clone(); + path.pop(); + sources.push((config.get_site_dir(), config.project.site_dir.clone())); + sources.push((path, String::from("."))); + + // Initialize file agent - we use a debounce interval of 20ms, which + // should be sufficient to correctly determine rename events + let mut initial = false; + let agent = Agent::new(Duration::from_millis(20), { + let config = config.clone(); + move |res| { + // For now, we just swallow the event, as the file agent should + // to take care of it, and skip anything other than files + if let Ok(event) = res { + if event.kind() != Kind::File { + return Ok(()); + } + + // Check if the config file reloaded, and terminate agent, + // as we need to kick off the entire pipeline again + if *event.path() == config.path { + if initial { + return Err(Error::Disconnected); + } + initial = true; + } + + // Ignore events in the site directory, since they are files + // that were generated and should not trigger a rebuild. We + // forward them to the reload channel in the server instead, + // so the browser can refresh the site. + let site_dir = config.get_site_dir(); + if event.path().starts_with(&site_dir) { + // Compute identifier, since we need the relative URL + // so we only reload the page the client is on. + let id = to_id(event.path().clone(), &sources); + + // Compute path, and if directory URLs are enabled, + // strip the `index.html` suffix, if present. + let path = id.as_uri().to_string(); + let path = if config.project.use_directory_urls { + path.trim_end_matches("index.html") + } else { + path.as_str() + }; + + // Prepend base path + let base = config.get_base_path(); + let path = if base == "/" { + format!("{base}{path}") + } else { + format!("{base}/{path}") + }; + + // Send path to reload channel and wake server polling + // loop, if available (i.e., serve mode is enabled) + let _ = reload.send(path); + if let Some(waker) = &waker { + waker.wake()?; + } + + // We don't trigger rebuilds for the site directory + return Ok(()); + } + + // Compute an identifier from the path and known contexts - + // in case the session is disconnected, the agent terminates + match event { + // File was created or modified + Event::Create { path, .. } + | Event::Modify { path, .. } => { + let data = path.to_string_lossy().into_owned(); + session.insert(to_id(path, &sources), data)?; + } + + // File was renamed + Event::Rename { from, to, .. } => { + let data = to.to_string_lossy().into_owned(); + session.remove(to_id(from, &sources))?; + session.insert(to_id(to, &sources), data)?; + } + + // File was removed + Event::Remove { path, .. } => { + session.remove(to_id(path, &sources))?; + } + } + } + Ok(()) + } + }); + + // Watch docs and template directories + agent.watch(config.get_docs_dir())?; + agent.watch(&config.path)?; + for theme_dir in &config.theme_dirs { + agent.watch(theme_dir)?; + } + + // Watch site directory, ensuring it exists + let site_dir = config.get_site_dir(); + fs::create_dir_all(&site_dir).unwrap(); + agent.watch(&site_dir)?; + + // Return file watcher + Ok(Self { agent }) + } + + /// Returns whether the watcher is terminated. + pub fn is_terminated(&self) -> bool { + self.agent.is_terminated() + } +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Create identifier for the given path and sources. +/// +/// This will also be hoisted into the file provider, which will make sure that +/// identifiers are platform independent by always ensuring forward slashes. +fn to_id(path: Arc, sources: &[(PathBuf, String)]) -> Id { + let option = sources.iter().find_map(|(prefix, context)| { + if let Ok(suffix) = path.strip_prefix(prefix) { + let location = suffix.to_str().unwrap_or(""); + Some( + Id::builder() + .with_provider("file") + .with_context(context.replace('\\', "/")) + .with_location(location.replace('\\', "/")) + .build() + .expect("invariant"), + ) + } else { + None + } + }); + + // Note that this cannot fail, since there must be a path in the source + // mapping that matches the given path, at least the project root + option.expect("invariant") +} diff --git a/crates/zensical/src/workflow.rs b/crates/zensical/src/workflow.rs new file mode 100644 index 0000000..477fdc4 --- /dev/null +++ b/crates/zensical/src/workflow.rs @@ -0,0 +1,299 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Workflow definitions + +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::path::Path; +use std::str::FromStr; +use std::{fs, io}; +use zrx::id::{Id, Matcher}; +use zrx::scheduler::action::report::IntoReport; +use zrx::stream::barrier::Condition; +use zrx::stream::function::{with_id, with_splat}; +use zrx::stream::value::{Chunk, Delta}; +use zrx::stream::workspace::Workspace; +use zrx::stream::Stream; + +use super::config::Config; +use super::structure::markdown::Markdown; +use super::structure::nav::Navigation; +use super::structure::page::Page; +use super::structure::search::SearchIndex; +use super::template::Template; + +mod cached; + +use cached::cached; + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Create a stream to process static assets. +pub fn process_assets(config: &Config, files: &Stream) { + let matcher = Matcher::from_str("zrs:::::**/*.{html,md,toml,xml,yml}:") + .expect("invariant"); + + // Create pipeline to copy static assets + let site_dir = config.project.site_dir.clone(); + let root_dir = config.get_root_dir(); + files.map(with_id(move |id: &Id, from: String| { + if matcher.is_match(id).expect("invariant") { + return Ok(()); + } + + // Create identifier builder, as we need to change the context in order + // to copy the file over to the site directory + let builder = id.to_builder().with_context(&site_dir); + let id = builder.build().expect("invariant"); + + // Compute parent path, create intermediate directories and copy files + let to = root_dir.join(id.to_path()); + fs::create_dir_all(to.parent().expect("invariant"))?; + fs::copy(from, to).map(|_| ()) + })); +} + +/// Create a stream to process Markdown files. +pub fn process_markdown( + config: &Config, files: &Stream, +) -> Stream { + let matcher = Matcher::from_str("zrs:::::**/*.md:").expect("invariant"); + + // Create pipeline to render Markdown files + let config = config.clone(); + files + .filter(with_id(move |id: &Id, _: &_| { + matcher.is_match(id).expect("invariant") + })) + // Render Markdown if we don't have a recent cached version at our own + // disposal. Otherwise, just return that if the content did not change. + // Note that we need to limit concurrency here, or we'll overwhelm the + // Python interpreter with all tasks competing for the GIL. + .map_concurrency( + with_id(move |id: &Id, path: String| { + let data = fs::read_to_string(path)?; + cached(&config, id, data, |data| Markdown::new(id, data)) + .into_report() + }), + 1, + ) +} + +/// Create a stream to wait for all Markdown files to be rendered. +pub fn wait_for_markdown( + config: &Config, files: &Stream, +) -> Stream> { + let name = config.path.file_name().expect("invariant"); + let matcher = + Matcher::from_str(&format!("zrs:::::{}:", name.to_string_lossy())) + .expect("invariant"); + + // Set up matcher to filter for the configuration file, and return a new + // stream that emits a condition in order to implement barriers + files.filter_map(with_id(move |id: &Id, _: _| { + matcher.is_match(id).expect("invariant").then(|| { + let matcher = + Matcher::from_str("zrs:::::**/*.md:").expect("invariant"); + + // Return condition waiting for all Markdown files + Condition::new(matcher) + }) + })) +} + +/// Generate pages from Markdown files. +pub fn generate_page( + config: &Config, markdown: &Stream, +) -> Stream { + let config = config.clone(); + markdown.map(with_id(move |id: &Id, markdown| { + Page::new(&config, id, markdown) + })) +} + +/// Generate navigation from all pages. +pub fn generate_nav( + config: &Config, pages: &Stream>, +) -> Stream { + let config = config.clone(); + pages.map(move |pages: Chunk| { + Navigation::new(config.project.nav.clone(), pages) + }) +} + +/// Generte search index +pub fn generate_search_index( + config: &Config, nav: &Stream, + pages: &Stream>, +) { + let config = config.clone(); + pages.product(nav).delta_map(with_splat(move |pages, nav| { + let plugin = config.project.plugins.search.config.clone(); + let search = SearchIndex::new(pages, &nav, plugin); + + // Serialize search index to json, and obtain site directory + let data = serde_json::to_string(&search).expect("invariant"); + let site_dir = config.get_site_dir(); + + // Write search index to disk + let path = site_dir.join("search.json"); + fs::create_dir_all(path.parent().expect("invariant"))?; + fs::write(path, &data)?; + + // If offline plugin is enabled, create search.js as well + if config.project.plugins.offline.config.enabled { + let path = site_dir.join("search.js"); + fs::create_dir_all(path.parent().expect("invariant"))?; + fs::write(path, format!("var __index = {data};").as_str())?; + } + + // All files were written successfully + Ok::<_, io::Error>(()) + })); +} + +/// Render static and extra templates. +pub fn render_templates( + config: &Config, files: &Stream, nav: &Stream, +) -> Stream> { + let docs_dir = config.project.docs_dir.clone(); + + // Retrieve template names + let static_templates = &config.project.theme.static_templates.join(","); + let extra_templates = &config.project.extra_templates.join(","); + + // Build matcher for static and extra templates - we just handle them the + // same. In MkDocs, extra templates can do even less than static templates, + // not having access to the `url_filter`, but there's no need for us to + // differentiate here. + let mut builder = Matcher::builder(); + builder + .add(format!("zrs::::templates/*:{{{static_templates}}}:")) + .expect("invariant"); + builder + .add(format!("zrs::::{docs_dir}:{{{extra_templates}}}:")) + .expect("invariant"); + + // Create matcher from builder, and filter templates + let matcher = builder.build().expect("invariant"); + let templates = files.filter(with_id(move |id: &Id, _: &String| { + matcher.is_match(id).expect("invariant") + })); + + // Create pipeline to render templates + let config = config.clone(); + templates.product(nav).delta_map(with_splat( + move |template: String, nav: Navigation| { + let name = Path::new(&template).file_name().expect("invariant"); + let site_dir = config.get_site_dir(); + + // Obtain template + let template = Template::new( + name.to_string_lossy(), + config.theme_dirs.clone(), + ); + + // Render template and write to disk + template + .render(&config, &nav) + .into_report() + .and_then(|report| { + let path = site_dir.join(name); + fs::create_dir_all(path.parent().expect("invariant"))?; + fs::write(path, &report.data).map_err(Into::into) + }) + }, + )) +} + +/// Render pages. +pub fn render_pages( + config: &Config, page: &Stream, nav: &Stream, +) -> Stream> { + let config = config.clone(); + page.product(nav).delta_map(with_splat( + move |mut page: Page, nav: Navigation| { + let id = page.url.clone(); + + // Compute hash of page content + let hash = { + let mut hasher = DefaultHasher::new(); + page.content.hash(&mut hasher); + page.meta.hash(&mut hasher); + hasher.finish() + }; + + // Render page if we don't have a recent cached version at our own + // disposal. Otherwise, just return if the content did not change. + let args = (config.hash, nav.hash, hash); + cached(&config, id, args, |(_, _, _)| page.render(&config, &nav)) + .into_report() + .and_then(|report| { + let path = Path::new(&page.path); + fs::create_dir_all(path.parent().expect("invariant"))?; + fs::write(path, &report.data) + .map_err(Into::into) + .inspect(|()| println!("+ /{}", page.url)) + }) + }, + )) +} + +/// Creates a new workspace for the given config. +pub fn create_workspace(config: &Config) -> Workspace { + let workspace = Workspace::new(); + let config = config.clone(); + + // Right now, we use a single workflow for the entirety of the build. Later, + // when we work on the module system, modules will have their own workflows. + // Create a source for files, so the file agent can submit file creation, + // change and delete events to the workflow + let workflow = workspace.add_workflow(); + let files = workflow.add_source::(); + + // Set up workflow to process static assets, as well as Markdown files, and + // create a barrier to wait for the completion of all Markdown files + process_assets(&config, &files); + let markdown = process_markdown(&config, &files); + let wait = wait_for_markdown(&config, &files); + + // Generate pages, and use the barrier to ensure that all pages have been + // processed, in order to create the navigation and search index + let page = generate_page(&config, &markdown); + let pages = page.select(&wait).chunks(); + + // Generate navigation and search index + let nav = generate_nav(&config, &pages); + generate_search_index(&config, &nav, &pages); + + // Render static and extra templates, as well as pages + render_templates(&config, &files, &nav); + render_pages(&config, &page, &nav); + + // Return workspace + workspace +} diff --git a/crates/zensical/src/workflow/cached.rs b/crates/zensical/src/workflow/cached.rs new file mode 100644 index 0000000..dc821e5 --- /dev/null +++ b/crates/zensical/src/workflow/cached.rs @@ -0,0 +1,103 @@ +// Copyright (c) Zensical LLC + +// SPDX-License-Identifier: MIT +// Third-party contributions licensed under CLA + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +// IN THE SOFTWARE. + +// ---------------------------------------------------------------------------- + +//! Workflow cache. + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::hash::{DefaultHasher, Hash, Hasher}; +use zrx::scheduler::action::report::IntoReport; +use zrx::scheduler::Value; + +use crate::config::Config; + +// ---------------------------------------------------------------------------- +// Structs +// ---------------------------------------------------------------------------- + +/// Workflow cache. +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Cached { + /// Cached data. + pub data: T, + /// Computed hash. + pub hash: u64, +} + +// ---------------------------------------------------------------------------- +// Functions +// ---------------------------------------------------------------------------- + +/// Caches the result of an expensive computation based on an identifier and +/// input arguments. Note that this is only a preliminary implementation, and +/// will be replaced with a more generic caching mechanism integrated into +/// the runtime. +pub fn cached( + config: &Config, id: I, args: T, mut f: F, +) -> impl IntoReport +where + I: Hash, + T: Hash, + F: FnMut(T) -> R, + R: IntoReport, + U: Value + Serialize + for<'de> Deserialize<'de>, +{ + // Compute hash of identifier + let id_hash = { + let mut hasher = DefaultHasher::default(); + id.hash(&mut hasher); + hasher.finish() + }; + + // Compute hash of content + let hash = { + let mut hasher = DefaultHasher::default(); + args.hash(&mut hasher); + hasher.finish() + }; + + // Compute path to cache file from cache directory and identifier hash, and + // check if we already have a cached version of the artifact. If so, compare + // the content hash and return cached version if it matches. Otherwise, we + // continue and compute the artifact. + let path = config.get_cache_dir().join(id_hash.to_string()); + if let Ok(data) = fs::read(&path) { + let cached: Cached = + serde_json::from_slice(&data).expect("invariant"); + + // In case content hashes match, return cached data + if cached.hash == hash { + return cached.data.into_report(); + } + } + + // Compute artifact and convert into report - note that we need to properly + // handle encoding and file I/O errors here as well + f(args).into_report().inspect(|report| { + serde_json::to_string_pretty(&Cached { data: &report.data, hash }) + .map(|content| fs::write(path, content).expect("invariant")) + .expect("invariant"); + }) +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8acd587 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,85 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +[build-system] +requires = ["maturin>=1.8,<2.0"] +build-backend = "maturin" + +[project] +name = "zensical" +description = "A modern static site generator built by the creators of Material for MkDocs" +authors = [ + { name = "Zensical LLC", email = "contributors@zensical.org" } +] +license = { text = "MIT" } +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "License :: OSI Approved :: MIT License", + "Programming Language :: Rust", + "Programming Language :: Python", + "Topic :: Documentation", + "Topic :: Software Development :: Documentation", + "Topic :: Text Processing :: Markup :: HTML", + "Topic :: Text Processing :: Markup :: Markdown", +] +dynamic = ["version"] +dependencies = [ + "click>=8.1.8", + "deepmerge>=2.0", + "markdown>=3.7", + "pygments>=2.16", + "pymdown-extensions>=10.15", + "pyyaml>=6.0.2", +] + +[project.urls] +Homepage = "https://zensical.org/" +Documentation = "https://zensical.org/docs/" +Source = "https://github.com/zensical/zensical/" +Changelog = "https://zensical.org/docs/changelog/" +Issues = "https://github.com/zensical/zensical/issues" + +[project.scripts] +zensical = "zensical.main:cli" + +[dependency-groups] +dev = [ + "maturin>=1.9.3", + "ruff>=0.12.8", +] + +[tool.maturin] +python-source = "python" +features = ["pyo3/extension-module"] +manifest-path = "crates/zensical/Cargo.toml" +include = [ + "python/zensical/templates/**/*" +] + +[tool.ruff] +line-length = 80 +fix = true + diff --git a/python/zensical/.gitignore b/python/zensical/.gitignore new file mode 100644 index 0000000..f8a2687 --- /dev/null +++ b/python/zensical/.gitignore @@ -0,0 +1 @@ +templates diff --git a/python/zensical/__init__.py b/python/zensical/__init__.py new file mode 100644 index 0000000..4296f84 --- /dev/null +++ b/python/zensical/__init__.py @@ -0,0 +1,28 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from .zensical import * + +__doc__ = zensical.__doc__ +if hasattr(zensical, "__all__"): + __all__ = zensical.__all__ diff --git a/python/zensical/bootstrap/.github/workflows/docs.yml b/python/zensical/bootstrap/.github/workflows/docs.yml new file mode 100644 index 0000000..f34858b --- /dev/null +++ b/python/zensical/bootstrap/.github/workflows/docs.yml @@ -0,0 +1,22 @@ +name: docs +on: + push: + branches: + - master + - main +permissions: + contents: write +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - run: pip install zensical + - run: zensical build + - uses: actions/upload-pages-artifact@v4 + with: + path: site + - uses: actions/deploy-pages@v4 diff --git a/python/zensical/bootstrap/docs/index.md b/python/zensical/bootstrap/docs/index.md new file mode 100644 index 0000000..e61fc8e --- /dev/null +++ b/python/zensical/bootstrap/docs/index.md @@ -0,0 +1,172 @@ +--- +icon: lucide/hand +--- + +# Welcome to Zensical! + +With Zensical, you can author professional documentation with minimal +fuss thanks to the *batteries included* approach. + +These generated pages serve to showcase some of what you can do with Zensical +and to show you examples of using the various elements available to you as an +author. Links to the relevant documentation let you explore further. + +The configuration file generated by `zensical new` can serve as a basis for your +own configuration. It contains plenty comments and links to the documentation. + +## Admonitions + +!!! note + This is a **note** admonition. Use it to provide helpful information. + +!!! warning + This is a **warning** admonition. Be careful! + +[Documentation ->](https://zensical.org/docs/authoring/admonitions) + +### Collapsible Sections (Details) + +??? info "Click to expand for more info" + This content is hidden until you click to expand it. + Great for FAQs or long explanations. + +[Documentation ->](https://zensical.org/docs/authoring/admonitions/#collapsible-blocks) + +## Buttons + +[Subscribe to our newsletter](#buttons){ .md-button .md-button--primary } + +[Documentation ->](https://zensical.org/docs/authoring/buttons) + + +## Code Blocks + +Zensical uses [Pygments] to highlight code blocks. + +[Pygments]: https://pygments.org/ + +```python linenums="1" hl_lines="2" title="Code blocks" +def greet(name): + print(f"Hello, {name}!") # (1)! + +greet("Zensical") +``` + +1. :man_raising_hand: I'm a code annotation! I can contain `code`, __formatted + text__, images, ... basically anything that can be written in Markdown. + +Code can also be highlighted inline: `#!python print("Hello from Python!")`. + +[Documentation ->](http://localhost:8000/docs/authoring/code-blocks) + +## Content tabs + +=== "Python" + + ```python + print("Hello from Python!") + ``` + +=== "JavaScript" + + ```javascript + console.log("Hello from JavaScript!"); + ``` + +[Documentation ->](https:///docs/authoring/content-tabs/) + +## Diagrams + +``` mermaid +graph LR + A[Start] --> B{Error?}; + B -->|Yes| C[Hmm...]; + C --> D[Debug]; + D --> B; + B ---->|No| E[Yay!]; +``` + +[Read the documentation](https://zensical.org/docs/authoring/diagrams) + +## Footnotes + +Here's a sentence with a footnote.[^1] Footnote tooltips are turned on, +so you can just hover over the footnote to see it. + +[^1]: This is the footnote. + +[Documentation ->](https://zensical.org/docs/authoring/footnotes) + +## Formatting + +- ==This was marked (highlight)== +- ^^This was inserted (underline)^^ +- ~~This was deleted (strikethrough)~~ +- H~2~O +- A^T^A +- ++ctrl+alt+del++ + +[Documentation ->](https://zensical.org/docs/authoring/formatting) + +## Icons, Emojis + +Zenscial supports GitHub-style emoji shortcodes: + +* :sparkles: `:sparkles:` +* :rocket: `:rocket:` +* :tada: `:tada:` +* :memo: `:memo:` +* :eyes: `:eyes:` + +[Documentation ->](https://zensical.org/docs/authoring/icons-emojis/) + +## Maths + +Mathematical formulae can be written using LaTeX syntax and rendered using +MathJax. + +$$ +\cos x=\sum_{k=0}^{\infty}\frac{(-1)^k}{(2k)!}x^{2k} +$$ + +!!! warning "Needs configuration" + Note that MathJax is included via a `script` tag on this page and is not + configured in the generated default configuration to avoid including it + in a pages that do not need it. See the documentation for details on how + to configure it on all your pages if they are more Maths-heavy than these + simple starter pages. + +[Read the documentation](https://zensical.org/docs/authoring/math) + + + + +## Task Lists + +* [x] Install Zensical +* [x] Configure `zensical.toml` +* [x] Write amazing documentation +* [ ] Deploy anywhere + +[Documentation ->](https://zensical.org/docs/authoring/lists/#using-task-lists) + +## Tooltips + +[Hover me][example] + + [example]: https://example.com "I'm a tooltip!" + +[Documentation ->](https://zensical.org/docs/authoring/tooltips) diff --git a/python/zensical/bootstrap/docs/markdown.md b/python/zensical/bootstrap/docs/markdown.md new file mode 100644 index 0000000..9d27ae0 --- /dev/null +++ b/python/zensical/bootstrap/docs/markdown.md @@ -0,0 +1,49 @@ +--- +icon: simple/markdown +--- + +# Intro to Markdown + +!!! warning "" + Zensical uses [Python Markdown] to be compatible with [Material for MkDocs]. + In the medium term, we are considering adding support or CommonMark and + providing tools for existing projects to migrate. Check out our [roadmap]. + +[Python Markdown]: https://python-markdown.github.io/ +[Material for MkDocs]: https://squidfunk.github.io/mkdocs-material/ +[CommonMark]: https://commonmark.org/ +[roadmap]: https://zensical.org/about/roadmap/ + +Text in Markdown can be _italicized_, __bold face__. + +Markdown allows you to produce bullet point lists: + +* bullet +* point +* list + * nested + * list + +as well as numbered lists: + +1. numbered +2. list + 1. nested + 2. list + +> If you can't explain it to a six year old, you don't understand it +> yourself.
(Albert Einstein) + + +| Feature | Supported | Notes | +| -------------- | --------- | -------------------------- | +| Admonitions | ✅ | Native support | +| Code Highlight | ✅ | Pygments & Superfences | +| Task Lists | ✅ | Pymdown extensions | +| Emojis | ✅ | GitHub-style emoji | + + +
+ ![Image title](https://dummyimage.com/600x400/){ width="300" } +
Image caption
+
diff --git a/python/zensical/bootstrap/zensical.toml b/python/zensical/bootstrap/zensical.toml new file mode 100644 index 0000000..ae24204 --- /dev/null +++ b/python/zensical/bootstrap/zensical.toml @@ -0,0 +1,312 @@ +# ============================================================================ +# +# The configuration produced by default is meant to highlight the features +# that Zensical provides and to serve as a starting point for your own +# projects. +# +# ============================================================================ + +[project] + +# The site_name is shown in the page header and the browser window title +# +# Read more: https://zensical.org/docs/setup/basics#site_name +site_name = "My Zensical project" + +# The site_description is included in the HTML head and should contain a +# meaningful description of the site content for use by search engines. +# +# Read more: https://zensical.org/docs/setup/basics#site_description +site_description = "A new project generated from the default template project." + +# The site_author attribute. This is used in the HTML head element. +# +# Read more: https://zensical.org/docs/setup/basics#site_author +site_author = "" + +# The site_url is the canonical URL for your site. When building online +# documentation you should set this. +# Read more: https://zensical.org/docs/setup/basics#site_url +#site_url = "https://www.example.com/" + +# The copyright notice appears in the page footer and can contain an HTML +# fragment. +# +# Read more: https://zensical.org/docs/setup/basics#copyright +copyright = """ +(c) 2025 your name here +""" + +# Zensical supports both implicit navigation and explicitly defined navigation. +# If you decide not to define a navigation here then Zensical will simply +# derive the navigation structure from the directory structure of your +# "docs_dir". The definition below demonstrates how a navigation structure +# can be defined using TOML syntax. +# +# Read more: https://zensical.org/docs/setup/setting-up-navigation +nav = [ + {"Welcome" = "index.md"}, + {"Intro to Markdown" = "markdown.md"}, +] + +# ---------------------------------------------------------------------------- +# Section for configuring theme options +# ---------------------------------------------------------------------------- +[project.theme] + +# change this to "classic" to use the traditional Material for MkDocs look. +# +# TODO: add link to docs +variant = "modern" + +# Zensical allows you to override specific blocks, partials, or whole +# templates as well as to define your own templates. To do this, uncomment +# the custom_dir setting below and set it to a directory in which you +# keep your template overrides. +# +# Read more: +# - https://zensical.org/docs/customization/#extending-the-theme +# +#custom_dir = "overrides" + +# With the "favicon" option you can set your own image to use as the icon +# browsers will use in the browser title bar or tab bar. The path provided +# must be relative to the "docs_dir". +# +# Read more: +# - https://zensical.org/docs/setup/changing-the-logo-and-icons/#favicon +# - https://developer.mozilla.org/en-US/docs/Glossary/Favicon +# +#favicon = "assets/images/favicon.png" + +# Zensical supports more than 60 different languages. This means that the +# labels and tooltips that Zensical's templates produce are translated. +# The "language" option allows you to set the language used. This language +# is also indicated in the HTML head element to help with accessibility +# and guide search engines and translation tools. +# +# The default language is "en" (English). It is possible to create +# sites with multiple languages and configure a language selector. See +# the documentation for details. +# +# Read more: +# - https://zensical.org/docs/docs/setup/changing-the-language/ +# +language = "en" + +# Zensical provides a number of feature toggles that change the behavior +# of the documentation site. +features = [ + # Zensical includes an announcement bar. This feature allows users to + # dismiss it then they have read the announcement. + # https://zensical.org/docs/setup/setting-up-the-header/#announcement-bar + "announce.dismiss", + + # If you have a repository configured and turn feature this on, Zensical + # will generate an edit button for the page. This works for common + # repository hosting services. + # https://zensical.org/docs/setup/adding-a-git-repository/#code-actions + #"content.action.edit", + + # If you have a repository configured and turn feature this on, Zensical + # will generate a button that allows the user to view the Markdown + # code for the current page. + # https://zensical.org/docs/setup/adding-a-git-repository/#code-actions + #"content.action.view", + + # Code annotations allow you to add an icon with a tooltip to your + # code blocks to provide explanations at crucial points. + # https://zensical.org/docs/authoring/code-blocks/#code-annotations + "content.code.annotate", + + # This feature turns on a button in code blocks that allow users to + # copy the content to their clipboard without first selecting it. + # https://zensical.org/docs/authoring/code-blocks/#code-copy-button + "content.code.copy", + + # Code blocks can include a button to allow for the selection of line + # ranges by the user. + # https://zensical.org/docs/authoring/code-blocks/#code-selection-button + "content.code.select", + + # Zensical can render footnotes as inline tooltips, so the user can read + # the footnote without leaving the context of the document. + # https://zensical.org/docs/authoring/footnotes/#footnote-tooltips + "content.footnote.tooltips", + + # If you have many content tabs that have the same titles (e.g., "Python", + # "JavaScript", "Cobol"), this feature causes all of them to switch to + # at the same time when the user chooses their language in one. + # https://zensical.org/docs/authoring/content-tabs/#linked-content-tabs + "content.tabs.link", + + # TODO: not sure I understand this one? Is there a demo of this in the docs? + # https://zensical.org/docs/authoring/tooltips/#improved-tooltips + "content.tooltips", + + # With this feature enabled, Zensical will automatically hide parts + # of the header when the user scrolls past a certain point. + # https://zensical.org/docs/setup/setting-up-the-header/#automatic-hiding + "header.autohide", + + # Turn on this feature to expand all collapsible sections in the + # navigation sidebar by default. + # https://zensical.org/docs/setup/setting-up-navigation/#navigation-expansion + # "navigation.expand", + + # This feature turns on navigation elements in the footer that allow the + # user to navigate to a next or previous page. + # http://zensical.org/docs/setup/setting-up-the-footer/#navigation + "navigation.footer", + + # When section index pages are enabled, documents can be directly attached + # to sections, which is particularly useful for providing overview pages. + # http://zensical.org/docs/setup/setting-up-navigation/#section-index-pages + "navigation.indexes", + + # When instant loading is enabled, clicks on all internal links will be + # intercepted and dispatched via XHR without fully reloading the page. + # http://zensical.org/docs/setup/setting-up-navigation/#instant-loading + "navigation.instant", + + # With instant prefetching, your site will start to fetch a page once the + # user hovers over a link. This will reduce the perceived loading time + # for the user. + # http://zensical.org/docs/setup/setting-up-navigation/#instant-prefetching + "navigation.instant.prefetch", + + # In order to provide a better user experience on slow connections when + # using instant navigation, a progress indicator can be enabled. + # http://zensical.org/docs/setup/setting-up-navigation/#progress-indicator + #"navigation.instant.progress" + + # When navigation paths are activated, a breadcrumb navigation is rendered + # above the title of each page + # http://zensical.org/docs/setup/setting-up-navigation/#navigation-path + "navigation.path", + + # When pruning is enabled, only the visible navigation items are included + # in the rendered HTML, reducing the size of the built site by 33% or more. + # http://zensical.org/docs/setup/setting-up-navigation/#navigation-pruning + #"navigation.prune", + + # When sections are enabled, top-level sections are rendered as groups in + # the sidebar for viewports above 1220px, but remain as-is on mobile. + # http://zensical.org/docs/setup/setting-up-navigation/#navigation-sections + "navigation.sections", + + # When tabs are enabled, top-level sections are rendered in a menu layer + # below the header for viewports above 1220px, but remain as-is on mobile. + # http://zensical.org/docs/setup/setting-up-navigation/#navigation-tabs + #"navigation.tabs", + + # When sticky tabs are enabled, navigation tabs will lock below the header + # and always remain visible when scrolling down. + # http://zensical.org/docs/setup/setting-up-navigation/#sticky-navigation-tabs + #"navigation.tabs.sticky", + + # A back-to-top button can be shown when the user, after scrolling down, + # starts to scroll up again. + # http://zensical.org/docs/setup/setting-up-navigation/#back-to-top-button + "navigation.top", + + # When anchor tracking is enabled, the URL in the address bar is + # automatically updated with the active anchor as highlighted in the table + # of contents. + # http://zensical.org/docs/setup/setting-up-navigation/#anchor-tracking + "navigation.tracking", + + # When search highlighting is enabled and a user clicks on a search result, + # Zensical will highlight all occurrences after following the link. + # https://zensical.org/docs/setup/setting-up-site-search/#search-highlighting + "search.highlight", + + # When anchor following for the table of contents is enabled, the sidebar + # is automatically scrolled so that the active anchor is always visible. + # https://zensical.org/docs/setup/setting-up-navigation/#anchor-following + # "toc.follow" + + # When navigation integration for the table of contents is enabled, it is + # always rendered as part of the navigation sidebar on the left. + # https://zensical.org/docs/setup/setting-up-navigation/#navigation-integration + #"toc.integrate" +] + +# With the "extra_css" option you can add your own CSS styling to customize +# your Zensical project according to your needs. You can add any number of +# CSS files. +# +# The path provided should be relative to the "docs_dir". +# +# Read more: http://zensical.org/docs/customization/#additional-css +# +#extra_css = ["assets/stylesheets/extra.css"] + +# With the `extra_javascript` option you can add your own JavaScript to your +# project to customize the behavior according to your needs. +# +# The path provided should be relative to the "docs_dir". +# +# Read more: http://zensical.org/docs/customization/#additional-javascript +#extra_javascript = ["assets/javascript/extra.js"] + +# ---------------------------------------------------------------------------- +# In the "palette" subsection you can configure options for the color scheme. +# You can configure different color # schemes, e.g., to turn on dark mode, +# that the user can switch between. Each color scheme can be further +# customized. +# +# Read more: +# - https://zensical.org/docs/setup/changing-the-colors +# ---------------------------------------------------------------------------- + +# TODO: use the version from "web" +[[project.theme.palette]] +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to light mode" + +# ---------------------------------------------------------------------------- +# In the "font" subsection you can configure the fonts used. By default, fonts +# are loaded from Google Fonts, giving you a wide range of choices from a set +# of suitably licensed fonts. There are options for a normal text font and for +# a monospaced font used in code blocks. +# ---------------------------------------------------------------------------- +# These are the defaults +#[project.theme.font] +#text = "Inter" +#code = "Jetbrains Mono" + +# ---------------------------------------------------------------------------- +# You can configure your own logo to be shown in the header using the "logo" +# option in the "icons" subsection. The logo can be a path to a file in your +# "docs_dir" or it can be a path to an icon. +# +# Likewise, you can customize the logo used for the repository section of the +# header. Zensical derives the default logo for this from the repository URL. +# See below... +# +# There are other icons you can customize. See the documentation for details. +# +# Read more: +# - https://zensical.org/docs/setup/changing-the-logo-and-icons +# - http://zensical.org/docs/authoring/icons-emojis/#search +# ---------------------------------------------------------------------------- +#[project.theme.icon] +#logo = "lucide/smile" +#repo = "lucide/smile" + + +# ---------------------------------------------------------------------------- +# The "extra" section contains miscellaneous settings. +# ---------------------------------------------------------------------------- +#[[project.extra.social]] +#icon = "fontawesome/brands/github" +#link = "https://github.com/user/repo" + + diff --git a/python/zensical/config.py b/python/zensical/config.py new file mode 100644 index 0000000..e87bf05 --- /dev/null +++ b/python/zensical/config.py @@ -0,0 +1,770 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import importlib +import os +import tomllib +import yaml + +from click import ClickException +from deepmerge import always_merger +from functools import partial +from typing import Any, IO +from yaml import BaseLoader, Loader, YAMLError +from yaml.constructor import ConstructorError +from urllib.parse import urlparse + +from .extensions.emoji import to_svg, twemoji + +# ---------------------------------------------------------------------------- +# Globals +# ---------------------------------------------------------------------------- + + +_CONFIG = None +""" +Global configuration to pick up later for parsing Markdown. + +Since MkDocs uses YAML as a configuration format, the configuration can contain +references to functions or other Python objects, for which we don't have any +representation in Rust. Thus, we just keep the configuration on the Python +side, and use it directly when needed. It's a hack but will do for now. +""" + +# ---------------------------------------------------------------------------- +# Classes +# ---------------------------------------------------------------------------- + + +class ConfigurationError(ClickException): + """ + Configuration resolution or validation failed. + """ + + +# ---------------------------------------------------------------------------- +# Functions +# ---------------------------------------------------------------------------- + + +def parse_config(path: str) -> dict: + """ + Parse configuration file. + """ + if path.endswith("zensical.toml"): + return parse_zensical_config(path) + else: + return parse_mkdocs_config(path) + + +def parse_zensical_config(path: str) -> dict: + """ + Parse zensical.toml configuration file. + """ + global _CONFIG + with open(path, "rb") as f: + config = tomllib.load(f) + if "project" in config: + config = config["project"] + + # Apply defaults and return parsed configuration + _CONFIG = _apply_defaults(config, path) + return _CONFIG + + +def parse_mkdocs_config(path: str) -> dict: + """ + Parse mkdocs.yml configuration file. + """ + global _CONFIG + with open(path, "r") as f: + config = _yaml_load(f) + + # Apply defaults and return parsed configuration + _CONFIG = _apply_defaults(config, path) + return _CONFIG + + +def get_theme_dir() -> str: + """ + Return the theme directory. + """ + path = os.path.dirname(os.path.abspath(__file__)) + return os.path.join(path, "templates") + + +def _apply_defaults(config: dict, path: str) -> dict: + """ + Apply default settings in configuration. + + Note that this is loosely based on the defaults that MkDocs sets in its own + configuration system, which we won't port for compatibility right now, as + well as the defaults that are set in Material for MkDocs for theme- and + extra-level settings. + + We must set all properties, as well as nested properties to `None`, or PyO3 + will refuse to convert them, as the key must definitely exist. + """ + if "site_name" not in config: + raise ConfigurationError("Missing required setting: site_name") + + # Set site directory + config.setdefault("site_dir", "site") + if ".." in config.get("site_dir"): + raise ConfigurationError("site_dir must not contain '..'") + + # Set docs directory + config.setdefault("docs_dir", "docs") + if ".." in config.get("docs_dir"): + raise ConfigurationError("docs_dir must not contain '..'") + + # Set defaults for core settings + set_default(config, "site_url", None, str) + set_default(config, "site_description", None, str) + set_default(config, "site_author", None, str) + set_default(config, "use_directory_urls", True, bool) + set_default(config, "dev_addr", "localhost:8000", str) + set_default(config, "copyright", None, str) + + # Set defaults for repository settings + set_default(config, "repo_url", None, str) + set_default(config, "repo_name", None, str) + set_default(config, "edit_uri_template", None, str) + set_default(config, "edit_uri", None, str) + + # Set defaults for repository name settings + repo_url = config.get("repo_url") + if repo_url and not config.get("repo_name"): + host = urlparse(repo_url).hostname or "" + if host == "github.com": + config["repo_name"] = "GitHub" + config["edit_uri"] = "edit/master/docs" + elif host == "gitlab.com": + config["repo_name"] = "GitLab" + config["edit_uri"] = "edit/master/docs" + elif host == "bitbucket.org": + config["repo_name"] = "Bitbucket" + config["edit_uri"] = "src/default/docs" + elif host: + config["repo_name"] = host.split(".")[0].title() + + # Remove trailing slash from edit_uri if present + edit_uri = config.get("edit_uri") + if isinstance(edit_uri, str) and edit_uri.endswith("/"): + config["edit_uri"] = edit_uri.rstrip("/") + + # Set defaults for theme font settings + theme = config.setdefault("theme", {}) + if isinstance(theme, str): + theme = {"name": theme} + config["theme"] = theme + + # Set variant and fonts for variant + set_default(theme, "variant", "modern", str) + if theme.get("variant") == "modern": + font = {"text": "Inter", "code": "JetBrains Mono"} + else: + font = {"text": "Roboto", "code": "Roboto Mono"} + + # Resolve custom theme directory + set_default(theme, "custom_dir", None, str) + if theme.get("custom_dir"): + theme["custom_dir"] = os.path.join( + os.path.dirname(path), theme["custom_dir"] + ) + + # Ensure presence of static templates + theme["static_templates"] = ["404.html", "sitemap.xml"] + + # Set defaults for theme settings + set_default(theme, "language", "en", str) + set_default(theme, "direction", None, str) + set_default(theme, "features", [], list) + set_default(theme, "favicon", "assets/images/favicon.png", str) + set_default(theme, "logo", None, str) + + # Set defaults for theme font settings + theme.setdefault("font", {}) + if isinstance(theme["font"], dict): + set_default(theme["font"], "text", font["text"], str) + set_default(theme["font"], "code", font["code"], str) + + # Set defaults for theme icons + icon = theme.setdefault("icon", {}) + set_default(icon, "repo", None, str) + set_default(icon, "annotation", None, str) + set_default(icon, "tag", {}, dict) + if theme.get("variant") == "modern": + set_default(icon, "logo", "lucide/book-open", str) + set_default(icon, "edit", "lucide/file-pen", str) + set_default(icon, "view", "lucide/file-code-2", str) + set_default(icon, "top", "lucide/circle-arrow-up", str) + set_default(icon, "share", "lucide/share-2", str) + set_default(icon, "menu", "lucide/menu", str) + set_default(icon, "alternate", "lucide/languages", str) + set_default(icon, "search", "lucide/search", str) + set_default(icon, "close", "lucide/x", str) + set_default(icon, "previous", "lucide/arrow-left", str) + set_default(icon, "next", "lucide/arrow-right", str) + else: + set_default(icon, "logo", None, str) + set_default(icon, "edit", None, str) + set_default(icon, "view", None, str) + set_default(icon, "top", None, str) + set_default(icon, "share", None, str) + set_default(icon, "menu", None, str) + set_default(icon, "alternate", None, str) + set_default(icon, "search", None, str) + set_default(icon, "close", None, str) + set_default(icon, "previous", None, str) + set_default(icon, "next", None, str) + + # Set defaults for theme admonition icons + admonition = icon.setdefault("admonition", {}) + set_default(admonition, "note", None, str) + set_default(admonition, "abstract", None, str) + set_default(admonition, "info", None, str) + set_default(admonition, "tip", None, str) + set_default(admonition, "success", None, str) + set_default(admonition, "question", None, str) + set_default(admonition, "warning", None, str) + set_default(admonition, "failure", None, str) + set_default(admonition, "danger", None, str) + set_default(admonition, "bug", None, str) + set_default(admonition, "example", None, str) + set_default(admonition, "quote", None, str) + + # Set defaults for theme palette settings and normalize to list + palette = theme.setdefault("palette", []) + if isinstance(palette, dict): + palette = [palette] + theme["palette"] = palette + + # Set defaults for each palette entry + for entry in palette: + set_default(entry, "media", None, str) + set_default(entry, "scheme", None, str) + set_default(entry, "primary", None, str) + set_default(entry, "accent", None, str) + set_default(entry, "toggle", None, dict) + + # Set defaults for palette toggle + toggle = entry.get("toggle") + if toggle: + set_default(toggle, "icon", None, str) + set_default(toggle, "name", None, str) + + # Set defaults for extra settings + extra = config.setdefault("extra", {}) + set_default(extra, "homepage", None, str) + set_default(extra, "scope", None, str) + set_default(extra, "annotate", {}, dict) + set_default(extra, "tags", {}, dict) + set_default(extra, "generator", True, bool) + set_default(extra, "polyfills", [], list) + set_default(extra, "analytics", None, dict) + + # Set defaults for extra analytics settings + analytics = extra.get("analytics") + if analytics: + set_default(analytics, "provider", None, str) + set_default(analytics, "property", None, str) + set_default(analytics, "feedback", None, dict) + + # Set defaults for extra analytics feedback settings + feedback = analytics.get("feedback") + if feedback: + set_default(feedback, "title", None, str) + set_default(feedback, "ratings", [], list) + + # Set defaults for each rating entry + ratings = feedback.setdefault("ratings", []) + for entry in ratings: + set_default(entry, "icon", None, str) + set_default(entry, "name", None, str) + set_default(entry, "data", None, str) + set_default(entry, "note", None, str) + + # Set defaults for extra consent settings + consent = extra.setdefault("consent", None) + if consent: + set_default(consent, "title", None, str) + set_default(consent, "description", None, str) + set_default(consent, "actions", [], list) + + # Set defaults for extra consent cookie settings + cookies = consent.setdefault("cookies", {}) + for key, value in cookies.items(): + if isinstance(value, str): + cookies[key] = {"name": value, "checked": False} + + # Set defaults for each cookie entry + set_default(cookies[key], "name", None, str) + set_default(cookies[key], "checked", False, bool) + + # Set defaults for extra social settings + social = extra.setdefault("social", []) + for entry in social: + set_default(entry, "icon", None, str) + set_default(entry, "name", None, str) + set_default(entry, "link", None, str) + + # Set defaults for extra alternate settings + alternate = extra.setdefault("alternate", []) + for entry in alternate: + set_default(entry, "name", None, str) + set_default(entry, "link", None, str) + set_default(entry, "lang", None, str) + + # Set defaults for extra version settings + version = extra.setdefault("version", None) + if version: + set_default(version, "provider", None, str) + set_default(version, "default", None, str) + set_default(version, "alias", False, bool) + + # Ensure all non-existent values are all empty strings (for now) + config["extra"] = _convert_extra(extra) + + # Set defaults for extra files + set_default(config, "extra_css", [], list) + set_default(config, "extra_templates", [], list) + + # Generate navigation if not defined, and convert + config["nav"] = _convert_nav(config.setdefault("nav", [])) + config["extra_javascript"] = _convert_extra_javascript( + config.setdefault("extra_javascript", []) + ) + + # MkDocs will also set fenced_code, which is incompatible with SuperFences, + # the extension that Material for MkDocs generally recommends. Note that we + # decided to set defaults that make it easy to get started with sensible + # Markdown support, but users can override this as needed. + markdown_extensions, mdx_configs = _convert_markdown_extensions( + config.get( + "markdown_extensions", + { + "abbr": {}, + "admonition": {}, + "attr_list": {}, + "def_list": {}, + "footnotes": {}, + "md_in_html": {}, + "toc": {"permalink": True}, + "pymdownx.arithmatex": {"generic": True}, + "pymdownx.betterem": {"smart_enable": "all"}, + "pymdownx.caret": {}, + "pymdownx.details": {}, + "pymdownx.emoji": { + "emoji_generator": to_svg, + "emoji_index": twemoji, + }, + "pymdownx.highlight": { + "anchor_linenums": True, + "line_spans": "__span", + "pygments_lang_class": True, + }, + "pymdownx.inlinehilite": {}, + "pymdownx.keys": {}, + "pymdownx.magiclink": {}, + "pymdownx.mark": {}, + "pymdownx.smartsymbols": {}, + "pymdownx.superfences": { + "custom_fences": [{"name": "mermaid", "class": "mermaid"}] + }, + "pymdownx.tabbed": { + "alternate_style": True, + "combine_header_slug": True, + }, + "pymdownx.tasklist": {"custom_checkbox": True}, + "pymdownx.tilde": {}, + }, + ) + ) + config["markdown_extensions"] = markdown_extensions + config["mdx_configs"] = mdx_configs + + # Now, since YAML supports using Python tags to resolve functions, we need + # to support the same for when we load TOML. This is a bandaid, and we will + # find a better solution, once we work on configuration management, but for + # now this should be sufficient. + emoji = config["mdx_configs"].get("pymdownx.emoji", {}) + if isinstance(emoji.get("emoji_generator"), str): + emoji["emoji_generator"] = _resolve(emoji.get("emoji_generator")) + if isinstance(emoji.get("emoji_index"), str): + emoji["emoji_index"] = _resolve(emoji.get("emoji_index")) + + # Tabbed extension configuration - we need to resolve the slugification + # function. + tabbed = config["mdx_configs"].get("pymdownx.tabbed", {}) + if isinstance(tabbed.get("slugify"), dict): + object = tabbed["slugify"].get("object", "pymdownx.slugs.slugify") + tabbed["slugify"] = partial( + _resolve(object), tabbed["slugify"].get("kwds") + ) + + # Ensure the table of contents title is initialized, as it's used inside + # the template, and the table of contents extension is always defined + config["mdx_configs"]["toc"].setdefault("title", None) + + # Convert plugins configuration + config["plugins"] = _convert_plugins(config.get("plugins", []), config) + return config + + +def set_default( + entry: dict, key: str, default: Any, data_type: type = None +) -> None: + """ + Set a key to a default value if it isn't set, and optionally cast it to the specified data type. + """ + # Set the default value if the key is not present + entry.setdefault(key, default) + + # Optionally cast the value to the specified data type + if data_type is not None and entry[key] is not None: + try: + entry[key] = data_type(entry[key]) + except (ValueError, TypeError) as e: + raise ValueError(f"Failed to cast key '{key}' to {data_type}: {e}") + + +def _convert_extra(data: dict | list) -> dict | list: + """ + Recursively convert all None values in a dictionary or list to empty strings. + """ + if isinstance(data, dict): + # Process each key-value pair in the dictionary + return { + key: _convert_extra(value) + if isinstance(value, (dict, list)) + else ("" if value is None else value) + for key, value in data.items() + } + elif isinstance(data, list): + # Process each item in the list + return [ + _convert_extra(item) + if isinstance(item, (dict, list)) + else ("" if item is None else item) + for item in data + ] + else: + return data + + +def _resolve(symbol: str): + """ + Resolve a symbol to its corresponding Python object. + """ + module_path, func_name = symbol.rsplit(".", 1) + module = importlib.import_module(module_path) + return getattr(module, func_name) + + +# ----------------------------------------------------------------------------- + + +def _convert_nav(nav: dict) -> dict: + """ + Convert MkDocs navigation + """ + return [_convert_nav_item(entry) for entry in nav] + + +def _convert_nav_item(item: str | dict | list) -> dict: + """ + Convert MkDocs shorthand navigation structure into something more manageable + as we need to annotate each item with a title, URL, icon, and children. + """ + if isinstance(item, str): + return { + "title": None, + "url": item, + "canonical_url": None, + "meta": None, + "children": [], + "is_index": _is_index(item), + "active": False, + } + + # Handle Title: URL + elif isinstance(item, dict): + for title, value in item.items(): + if isinstance(value, str): + return { + "title": str(title), + "url": value, + "canonical_url": None, + "meta": None, + "children": [], + "is_index": _is_index(value), + "active": False, + } + elif isinstance(value, list): + return { + "title": str(title), + "url": None, + "canonical_url": None, + "meta": None, + "children": [_convert_nav_item(child) for child in value], + "is_index": False, + "active": False, + } + + # Handle a list of items + elif isinstance(item, list): + return [_convert_nav_item(child) for child in item] + else: + raise ValueError(f"Unknown nav item type: {type(item)}") + + +def _is_index(path: str) -> bool: + """ + Returns, whether the given path points to a section index. + """ + return path.endswith(("index.md", "README.md")) + + +# ----------------------------------------------------------------------------- + + +def _convert_extra_javascript(value: list[any]) -> list: + """ + Ensure extra_javascript uses a structured format. + """ + for i, item in enumerate(value): + if isinstance(item, str): + value[i] = { + "path": item, + "type": None, + "async": False, + "defer": False, + } + elif isinstance(item, dict): + item.setdefault("path", "") + item.setdefault("type", None) + item.setdefault("async", False) + item.setdefault("defer", False) + else: + raise ValueError( + f"Unknown extra_javascript item type: {type(item)}" + ) + + # Return resulting value + return value + + +# ----------------------------------------------------------------------------- + + +def _convert_markdown_extensions(value: any): + """ + Convert Markdown extensions configuration to what Python Markdown expects. + """ + markdown_extensions = ["toc", "tables"] + mdx_configs = {"toc": {}, "tables": {}} + + # In case of Python Markdown Extensions, we allow to omit the necessary + # quotes around the extension names, so we need to hoist the extensions + # configuration one level up. This is a pre-processing step before we + # actually parse the configuration. + if "pymdownx" in value: + pymdownx = value.pop("pymdownx") + for ext, config in pymdownx.items(): + value[f"pymdownx.{ext}"] = config + + # Extensions can be defined as a dict + if isinstance(value, dict): + for ext, config in value.items(): + markdown_extensions.append(ext) + mdx_configs[ext] = config or {} + + # Extensions can also be defined as a list + else: + for item in value: + if isinstance(item, dict): + ext, config = item.popitem() + markdown_extensions.append(ext) + mdx_configs[ext] = config or {} + elif isinstance(item, str): + markdown_extensions.append(item) + + # Return extension list and configuration, after ensuring they're unique + return list(set(markdown_extensions)), mdx_configs + + +# ---------------------------------------------------------------------------- + + +def _convert_plugins(value: any, config: dict) -> list: + """ + Convert plugins configuration to something we can work with. + """ + plugins = {} + + # Plugins can be defined as a dict + if isinstance(value, dict): + for name, data in value.items(): + plugins[name] = data + + # Plugins can also be defined as a list + else: + for item in value: + if isinstance(item, dict): + name, data = item.popitem() + plugins[name] = data + elif isinstance(item, str): + plugins[item] = {} + + # Define defaults for search plugin + search = plugins.setdefault("search", {}) + search.setdefault("enabled", True) + search.setdefault("separator", '[\\s\\-_,:!=\\[\\]()\\\\"`/]+|\\.(?!\\d)') + + # Define defaults for offline plugin + offline = plugins.setdefault("offline", {"enabled": False}) + offline.setdefault("enabled", True) + + # Ensure correct resolution of links when viewing the site from the + # file system by disabling directory URLs + if offline.get("enabled"): + config["use_directory_urls"] = False + + # Append iframe-worker to polyfills/shims + if not any( + "iframe-worker" in url for url in config["extra"]["polyfills"] + ): + script = "https://unpkg.com/iframe-worker/shim" + config["extra"]["polyfills"].append(script) + + # Ensure extra polyfills/shims use structured format + config["extra"]["polyfills"] = _convert_extra_javascript( + config["extra"]["polyfills"] + ) + + # Now, add another level of indirection, by moving all plugin configuration + # into a `config` property, making it compatible with Material for MkDocs. + for name, config in plugins.items(): + if not isinstance(config, dict) or "config" not in config: + plugins[name] = {"config": config} + + # Return plugins + return plugins + + +# ---------------------------------------------------------------------------- + + +def _yaml_load( + source: IO, loader: type[BaseLoader] | None = None +) -> dict[str, Any]: + """ + Load configuration file and resolve environment variables and parent files. + + Note that INHERIT is only a bandaid that was introduced to allow for some + degree of modularity, but with serious shortcomings. Zensical will use a + different approach in the future, which will allow for composable and + environment-specific configuration. + """ + loader = loader or Loader.add_constructor("!ENV", _construct_env_tag) + try: + config = yaml.load( + # Compatibility shim: we remap Material's extension namespace to + # Zensical's, and the now deprecated materialx namespace as well + source.read() + .replace("material.extensions", "zensical.extensions") + .replace("materialx", "zensical.extensions"), + Loader=Loader, + ) + except YAMLError as e: + raise ConfigurationError( + f"Encountered an error parsing the configuration file: {e}" + ) + if config is None: + return {} + + # Try to resolve inherited configuration file + if "INHERIT" in config and not isinstance(source, str): + relpath = config.pop("INHERIT") + abspath = os.path.normpath( + os.path.join(os.path.dirname(source.name), relpath) + ) + if not os.path.exists(abspath): + raise ConfigurationError( + f"Inherited config file '{relpath}' doesn't exist at '{abspath}'." + ) + with open(abspath, "r") as fd: + parent = _yaml_load(fd, loader) + config = always_merger.merge(parent, config) + + # Return resulting configuration + return config + + +def _construct_env_tag(loader: yaml.Loader, node: yaml.Node): + """ + Assign value of ENV variable referenced at node. + + MkDocs supports the use of !ENV to reference environment variables in YAML + configuration files. We won't likely support this in Zensical, but for now + we need it to build MkDocs projects. Zensical will use a different approach + to create environment-specific configuration in the future. + + Licensed under MIT + Copyright (c) 2020 Waylan Limberg + Taken and adapted from + https://github.com/waylan/pyyaml-env-tag/blob/master/yaml_env_tag.py + """ + default = None + + # Handle !ENV + if isinstance(node, yaml.nodes.ScalarNode): + vars = [loader.construct_scalar(node)] + + # Handle !ENV [, ] + elif isinstance(node, yaml.nodes.SequenceNode): + child_nodes = node.value + if len(child_nodes) > 1: + default = loader.construct_object(child_nodes[-1]) + child_nodes = child_nodes[:-1] + # Env Vars are resolved as string values, ignoring (implicit) types. + vars = [loader.construct_scalar(child) for child in child_nodes] + else: + raise ConstructorError( + context=f"expected a scalar or sequence node, but found {node.id}", + start_mark=node.start_mark, + ) + + # Resolve environment variable + for var in vars: + if var in os.environ: + value = os.environ[var] + # Resolve value to Python type using YAML's implicit resolvers + tag = loader.resolve(yaml.nodes.ScalarNode, value, (True, False)) + return loader.construct_object(yaml.nodes.ScalarNode(tag, value)) + + # Otherwise return default + return default diff --git a/python/zensical/extensions/__init__.py b/python/zensical/extensions/__init__.py new file mode 100644 index 0000000..b522b11 --- /dev/null +++ b/python/zensical/extensions/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/python/zensical/extensions/emoji.py b/python/zensical/extensions/emoji.py new file mode 100644 index 0000000..31a618b --- /dev/null +++ b/python/zensical/extensions/emoji.py @@ -0,0 +1,119 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import codecs +import functools +import os + +from glob import iglob +from markdown import Markdown +from pymdownx import emoji, twemoji_db +from xml.etree.ElementTree import Element + +# ----------------------------------------------------------------------------- +# Functions +# ----------------------------------------------------------------------------- + + +def twemoji(options: object, md: Markdown): + """ + Create twemoji index. + """ + paths = options.get("custom_icons", [])[:] + return _load_twemoji_index(tuple(paths)) + + +def to_svg( + index: str, + shortname: str, + alias: str, + uc: str | None, + alt: str, + title: str, + category: str, + options: object, + md: Markdown, +): + """ + Load icon. + """ + if not uc: + icons = md.inlinePatterns["emoji"].emoji_index["emoji"] + + # Create and return element to host icon + el = Element("span", {"class": options.get("classes", index)}) + el.text = md.htmlStash.store(_load(icons[shortname]["path"])) + return el + + # Delegate to `pymdownx.emoji` extension + return emoji.to_svg( + index, shortname, alias, uc, alt, title, category, options, md + ) + + +# ----------------------------------------------------------------------------- +# Helper functions +# ----------------------------------------------------------------------------- + + +@functools.lru_cache(maxsize=None) +def _load(file: str): + """ + Load icon from file. + """ + with codecs.open(file, encoding="utf-8") as f: + return f.read() + + +@functools.lru_cache(maxsize=None) +def _load_twemoji_index(paths): + """ + Load twemoji index and add icons. + """ + index = { + "name": "twemoji", + "emoji": twemoji_db.emoji, + "aliases": twemoji_db.aliases, + } + + # Compute path to theme root and traverse all icon directories + root = os.path.dirname(os.path.dirname(__file__)) + root = os.path.join(root, "templates", ".icons") + for path in [*paths, root]: + base = os.path.normpath(path) + + # Index icons provided by the theme and via custom icons + glob = os.path.join(base, "**", "*.svg") + glob = iglob(os.path.normpath(glob), recursive=True) + for file in glob: + icon = file[len(base) + 1 : -4].replace(os.path.sep, "-") + + # Add icon to index + name = f":{icon}:" + if not any(name in index[key] for key in ["emoji", "aliases"]): + index["emoji"][name] = {"name": name, "path": file} + + # Return index + return index diff --git a/python/zensical/extensions/links.py b/python/zensical/extensions/links.py new file mode 100644 index 0000000..a99410d --- /dev/null +++ b/python/zensical/extensions/links.py @@ -0,0 +1,126 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from markdown import Extension, Markdown +from markdown.treeprocessors import Treeprocessor +from markdown.util import AMP_SUBSTITUTE +from xml.etree.ElementTree import Element +from urllib.parse import urlparse + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + + +class LinksProcessor(Treeprocessor): + """ + Tree processor to replace links in Markdown with URLs. + + Note that we view this as a bandaid until we can do processing on proper + HTML ASTs in Rust. In the meantime, we just replace them as we find them. + This processor will replace links to other Markdown files, as well as + adjust asset links if directory URLs are used. + """ + + def __init__(self, md: Markdown, path: str, use_directory_urls: bool): + super().__init__(md) + self.path = path # Current page + self.use_directory_urls = use_directory_urls + + def run(self, root: Element): + # Now, we determine whether the current page is an index page, as we + # must apply slightly different handling in case of directory URLs + current_is_index = self.path.endswith(("index.md", "README.md")) + for el in root.iter(): + # In case the element has a `href` or `src` attribute, we parse it + # as an URL, so we can analyze and alter its path + key = next((k for k in ("href", "src") if el.get(k)), None) + if not key: + continue + + # Extract value - Python Markdown does some weird stuff where it + # replaces mailto: links with double encoded entities. MkDocs just + # skips if it detects that, so we do the same. + value = el.get(key) + if AMP_SUBSTITUTE in value: + continue + + # Parse URL and skip everything that is not a relative link + url = urlparse(value) + if url.scheme or url.netloc: + continue + + # Leave anchors that go to the same page as they are + if not url.path and url.fragment: + continue + + # Now, adjust relative links to Markdown files + path = url.path + if path.endswith(".md"): + path = path.removesuffix(".md") + ".html" + if self.use_directory_urls: + if path.endswith("index.html"): + path = path[: -len("index.html")] + elif path.endswith("README.html"): + path = path[: -len("README.html")] + elif path.endswith(".html"): + path = path[: -len(".html")] + "/" + + # If the current page is not an index page, and we should render + # directory URLs, we need to prepend a "../" to all links + if not current_is_index and self.use_directory_urls: + path = f"../{path}" + + # Reassemble URL and update link + el.set(key, url._replace(path=path).geturl()) + + +# ----------------------------------------------------------------------------- + + +class LinksExtension(Extension): + """ + A Markdown extension to resolve links to other Markdown files. + """ + + def __init__(self, path: str, use_directory_urls: bool): + """ + Initialize the extension. + """ + self.path = path # Current page + self.use_directory_urls = use_directory_urls + + def extendMarkdown(self, md: Markdown): + """ + Register Markdown extension. + """ + md.registerExtension(self) + + # Create and register treeprocessor - we use the same priority as the + # `relpath` treeprocessor, the latter of which is guaranteed to run + # after our treeprocessor, so we can check the original Markdown URIs + # before they are resolved to URLs. + processor = LinksProcessor(md, self.path, self.use_directory_urls) + md.treeprocessors.register(processor, "relpath", 0) diff --git a/python/zensical/extensions/preview.py b/python/zensical/extensions/preview.py new file mode 100644 index 0000000..f3ff16a --- /dev/null +++ b/python/zensical/extensions/preview.py @@ -0,0 +1,201 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import posixpath + +from markdown import Extension, Markdown +from markdown.treeprocessors import Treeprocessor +from urllib.parse import urlparse +from xml.etree.ElementTree import Element + +from .links import LinksProcessor +from .utilities.filter import Filter + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + + +class PreviewProcessor(Treeprocessor): + """ + A Markdown treeprocessor to enable instant previews on links. + + Note that this treeprocessor is dependent on the `links` treeprocessor + registered programmatically before rendering a page. + """ + + def __init__(self, md: Markdown, config: dict): + """ + Initialize the treeprocessor. + """ + super().__init__(md) + self.config = config + + def run(self, root: Element): + """ + Run the treeprocessor. + """ + at = self.md.treeprocessors.get_index_for_name("relpath") + + # Hack: Python Markdown has no notion of where it is, i.e., which file + # is being processed. This seems to be a deliberate design decision, as + # it is not possible to access the file path of the current page, but + # it might also be an oversight that is now impossible to fix. However, + # since this extension is only useful in the context of Material for + # MkDocs, we can assume that the _RelativePathTreeprocessor is always + # present, telling us the file path of the current page. If that ever + # changes, we would need to wrap this extension in a plugin, but for + # the time being we are sneaky and will probably get away with it. + processor = self.md.treeprocessors[at] + if not isinstance(processor, LinksProcessor): + raise TypeError("Links processor not registered") + + # Normalize configurations + configurations = self.config["configurations"] + configurations.append( + { + "sources": self.config.get("sources"), + "targets": self.config.get("targets"), + } + ) + + # Walk through all configurations - @todo refactor so that we don't + # iterate multiple times over the same elements + for configuration in configurations: + if not configuration.get("sources"): + if not configuration.get("targets"): + continue + + # Skip if page should not be considered + filter = get_filter(configuration, "sources") + if not filter(processor.path): + continue + + # Walk through all links and add preview attributes + filter = get_filter(configuration, "targets") + for el in root.iter("a"): + href = el.get("href") + if not href: + continue + + # Skip footnotes + if "footnote-ref" in el.get("class", ""): + continue + + # Skip headerlinks + if "headerlink" in el.get("class", ""): + continue + + # Skip external links + url = urlparse(href) + if url.scheme or url.netloc: + continue + + # Include, if filter matches + path = resolve(processor.path, url.path) + if path and filter(path): + el.set("data-preview", "") + + +# ----------------------------------------------------------------------------- + + +class PreviewExtension(Extension): + """ + A Markdown extension to enable instant previews on links. + + This extensions allows to automatically add the `data-preview` attribute to + internal links matching specific criteria, so Material for MkDocs renders a + nice preview on hover as part of a tooltip. It is the recommended way to + add previews to links in a programmatic way. + """ + + def __init__(self, *args, **kwargs): + """ + Initialize the extension. + """ + self.config = { + "configurations": [[], "Filter configurations"], + "sources": [{}, "Link sources"], + "targets": [{}, "Link targets"], + } + super().__init__(*args, **kwargs) + + def extendMarkdown(self, md: Markdown): + """ + Register Markdown extension. + """ + md.registerExtension(self) + + # Create and register treeprocessor - we use the same priority as the + # `relpath` treeprocessor, the latter of which is guaranteed to run + # after our treeprocessor, so we can check the original Markdown URIs + # before they are resolved to URLs. + processor = PreviewProcessor(md, self.getConfigs()) + md.treeprocessors.register(processor, "preview", 0) + + +# ----------------------------------------------------------------------------- +# Functions +# ----------------------------------------------------------------------------- + + +def get_filter(settings: dict, key: str): + """ + Get file filter from settings. + """ + return Filter(config=settings.get(key)) # type: ignore + + +def resolve(processor_path: str, url_path: str) -> str: + """ + Resolve a relative URL path against the processor path. + """ + # Remove the file name from the processor path to get the directory + base_path = posixpath.dirname(processor_path) + + # Split the base path and URL path into segments + base_segments = base_path.split("/") + url_segments = url_path.split("/") + + # Process each segment in the URL path + for segment in url_segments: + if segment == "..": + # Remove the last segment from the base path if possible + if base_segments: + base_segments.pop() + elif segment and segment != ".": + # Add non-empty, non-current directory segments + base_segments.append(segment) + + # Join the base segments into the resolved path + return posixpath.join(*base_segments) + + +def makeExtension(**kwargs): + """ + Register Markdown extension. + """ + return PreviewExtension(**kwargs) diff --git a/python/zensical/extensions/search.py b/python/zensical/extensions/search.py new file mode 100644 index 0000000..7fd53f5 --- /dev/null +++ b/python/zensical/extensions/search.py @@ -0,0 +1,373 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from html import escape +from html.parser import HTMLParser + +from markdown import Extension +from markdown.postprocessors import Postprocessor + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + + +class SearchProcessor(Postprocessor): + """ + PostProcessor that extracts searchable content from the rendered HTML. + """ + + def __init__(self, md): + super().__init__(md) + self.data = [] + + def run(self, html): + """Process the rendered HTML and extract text length.""" + + # Divide page content into sections + parser = Parser() + parser.feed(html) + parser.close() + + # Extract data from sections that are not excluded + self.data = [] + for section in parser.data: + if not section.is_excluded(): + # Compute title and text + title = "".join(section.title).strip() + text = "".join(section.text).strip() + + # Store data for external access + self.data.append( + { + "location": section.id, + "level": section.level, + "title": title, + "text": text, + "path": [], + "tags": [], + } + ) + + # Return the original HTML unchanged + return html + + +class SearchExtension(Extension): + """Markdown extension for search indexing.""" + + def __init__(self, **kwargs): + self.config = {"keep": [set(), "Set of HTML tags to keep in output"]} + super().__init__(**kwargs) + + def extendMarkdown(self, md): + """Register the PostProcessor with Markdown.""" + processor = SearchProcessor(md) + md.postprocessors.register(processor, "search", 0) + + +def makeExtension(**kwargs): + """Factory function for creating the extension.""" + return SearchExtension(**kwargs) + + +# ----------------------------------------------------------------------------- + + +# HTML element +class Element: + """ + An element with attributes, essentially a small wrapper object for the + parser to access attributes in other callbacks than handle_starttag. + """ + + # Initialize HTML element + def __init__(self, tag, attrs=None): + self.tag = tag + self.attrs = attrs or {} + + # String representation + def __repr__(self): + return self.tag + + # Support comparison (compare by tag only) + def __eq__(self, other): + if other is Element: + return self.tag == other.tag + else: + return self.tag == other + + # Support set operations + def __hash__(self): + return hash(self.tag) + + # Check whether the element should be excluded + def is_excluded(self): + return "data-search-exclude" in self.attrs + + +# ----------------------------------------------------------------------------- + + +# HTML section +class Section: + """ + A block of text with markup, preceded by a title (with markup), i.e., a + headline with a certain level (h1-h6). Internally used by the parser. + """ + + # Initialize HTML section + def __init__(self, el, level, depth=0): + self.el = el + self.depth = depth + self.level = level + + # Initialize section data + self.text = [] + self.title = [] + self.id = None + + # String representation + def __repr__(self): + if self.id: + return "#".join([self.el.tag, self.id]) + else: + return self.el.tag + + # Check whether the section should be excluded + def is_excluded(self): + return self.el.is_excluded() + + +# ----------------------------------------------------------------------------- + + +# HTML parser +class Parser(HTMLParser): + """ + This parser divides the given string of HTML into a list of sections, each + of which are preceded by a h1-h6 level heading. A white- and blacklist of + tags dictates which tags should be preserved as part of the index, and + which should be ignored in their entirety. + """ + + # Initialize HTML parser + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Tags to skip + self.skip = set( + [ + "object", # Objects + "script", # Scripts + "style", # Styles + ] + ) + + # Current context and section + self.context = [] + self.section = None + + # All parsed sections + self.data = [] + + # Called at the start of every HTML tag + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + + # Ignore self-closing tags + el = Element(tag, attrs) + if tag not in void: + self.context.append(el) + else: + return + + # Handle heading + if tag in ([f"h{x}" for x in range(1, 7)]): + depth = len(self.context) + if "id" in attrs: + # Ensure top-level section + if tag != "h1" and not self.data: + self.section = Section(Element("hx"), 1, depth) + self.data.append(self.section) + + # Set identifier, if not first section + self.section = Section(el, int(tag[1:2]), depth) + if self.data: + self.section.id = attrs["id"] + + # Append section to list + self.data.append(self.section) + + # Handle preface - ensure top-level section + if not self.section: + self.section = Section(Element("hx"), 1) + self.data.append(self.section) + + # Handle special cases to skip + for key, value in attrs.items(): + # Skip block if explicitly excluded from search + if key == "data-search-exclude": + self.skip.add(el) + return + + # Skip line numbers - see https://bit.ly/3GvubZx + if key == "class" and value == "linenodiv": + self.skip.add(el) + return + + # Render opening tag if kept + if not self.skip.intersection(self.context) and tag in keep: + # Check whether we're inside the section title + data = self.section.text + if self.section.el in self.context: + data = self.section.title + + # Append to section title or text + data.append(f"<{tag}>") + + # Called at the end of every HTML tag + def handle_endtag(self, tag): + if not self.context or self.context[-1] != tag: + return + + # Check whether we're exiting the current context, which happens when + # a headline is nested in another element. In that case, we close the + # current section, continuing to append data to the previous section, + # which could also be a nested section – see https://bit.ly/3IxxIJZ + if self.section.depth > len(self.context): + for section in reversed(self.data): + if section.depth <= len(self.context): + # Set depth to infinity in order to denote that the current + # section is exited and must never be considered again. + self.section.depth = float("inf") + self.section = section + break + + # Remove element from skip list + el = self.context.pop() + if el in self.skip: + if el.tag not in ["script", "style", "object"]: + self.skip.remove(el) + return + + # Render closing tag if kept + if not self.skip.intersection(self.context) and tag in keep: + # Check whether we're inside the section title + data = self.section.text + if self.section.el in self.context: + data = self.section.title + + # Search for corresponding opening tag + index = data.index(f"<{tag}>") + for i in range(index + 1, len(data)): + if not data[i].isspace(): + index = len(data) + break + + # Remove element if empty (or only whitespace) + if len(data) > index: + while len(data) > index: + data.pop() + + # Append to section title or text + else: + data.append(f"") + + # Called for the text contents of each tag + def handle_data(self, data): + if self.skip.intersection(self.context): + return + + # Collapse whitespace in non-pre contexts + if "pre" not in self.context: + if not data.isspace(): + data = data.replace("\n", " ") + else: + data = " " + + # Handle preface - ensure top-level section + if not self.section: + self.section = Section(Element("hx"), 1) + self.data.append(self.section) + + # Handle section headline + if self.section.el in self.context: + permalink = False + for el in self.context: + if el.tag == "a" and el.attrs.get("class") == "headerlink": + permalink = True + + # Ignore permalinks + if not permalink: + self.section.title.append(escape(data, quote=False)) + + # Collapse adjacent whitespace + elif data.isspace(): + if not self.section.text or not self.section.text[-1].isspace(): + self.section.text.append(data) + elif "pre" in self.context: + self.section.text.append(data) + + # Handle everything else + else: + self.section.text.append(escape(data, quote=False)) + + +# ----------------------------------------------------------------------------- +# Data +# ----------------------------------------------------------------------------- + +# Tags to keep +keep = set( + [ + "p", + "code", + "pre", + "li", + "ol", + "ul", + "sub", + "sup", + ] +) + +# Tags that are self-closing +void = set( + [ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", + ] +) diff --git a/python/zensical/extensions/utilities/__init__.py b/python/zensical/extensions/utilities/__init__.py new file mode 100644 index 0000000..b522b11 --- /dev/null +++ b/python/zensical/extensions/utilities/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. diff --git a/python/zensical/extensions/utilities/filter.py b/python/zensical/extensions/utilities/filter.py new file mode 100644 index 0000000..9303c39 --- /dev/null +++ b/python/zensical/extensions/utilities/filter.py @@ -0,0 +1,87 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +from fnmatch import fnmatch + +# ----------------------------------------------------------------------------- +# Classes +# ----------------------------------------------------------------------------- + + +class Filter: + """ + A filter. + """ + + def __init__(self, config: dict): + """ + Initialize the filter. + + Arguments: + config: The filter configuration. + """ + self.config = config + + def __call__(self, value: str) -> bool: + """ + Filter a value. + + First, the inclusion patterns are checked. Regardless of whether they + are present, the exclusion patterns are checked afterwards. This allows + to exclude values that are included by the inclusion patterns, so that + exclusion patterns can be used to refine inclusion patterns. + + Arguments: + value: The value to filter. + + Returns: + Whether the value should be included. + """ + + # Check if value matches one of the inclusion patterns + if "include" in self.config: + for pattern in self.config["include"]: + if fnmatch(value, pattern): + break + + # Value is not included + else: + return False + + # Check if value matches one of the exclusion patterns + if "exclude" in self.config: + for pattern in self.config["exclude"]: + if fnmatch(value, pattern): + return False + + # Value is not excluded + return True + + # ------------------------------------------------------------------------- + + config: dict + """ + The filter configuration. + """ diff --git a/python/zensical/main.py b/python/zensical/main.py new file mode 100644 index 0000000..54fa8d8 --- /dev/null +++ b/python/zensical/main.py @@ -0,0 +1,172 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import click +import os +import shutil +import webbrowser + +from click import ClickException +from zensical import build, serve + +# ---------------------------------------------------------------------------- +# Commands +# ---------------------------------------------------------------------------- + + +@click.group() +def cli(): + """Zensical""" + + +@cli.command(name="build") +@click.option( + "-f", + "--config-file", + type=click.Path(exists=True), + default=None, + help="Path to config file.", +) +@click.option( + "-c", + "--clean", + default=False, + is_flag=True, + help="Clean cache.", +) +@click.option( + "-s", + "--strict", + default=False, + is_flag=True, + help="Strict mode (currently unsupported).", +) +def execute_build(config_file: str | None, **kwargs): + """ + Build a project. + """ + if config_file is None: + for file in ["zensical.toml", "mkdocs.yml", "mkdocs.yaml"]: + if os.path.exists(file): + config_file = file + break + else: + raise ClickException("No config file found in the current folder.") + if kwargs.get("strict", False): + print("Warning: Strict mode is currently unsupported.") + + # Build project in Rust runtime, calling back into Python when necessary, + # e.g., to parse MkDocs configuration format or render Markdown + build(os.path.abspath(config_file), kwargs.get("clean")) + + +@cli.command(name="serve") +@click.option( + "-f", + "--config-file", + type=click.Path(exists=True), + default=None, + help="Path to config file.", +) +@click.option( + "-a", + "--dev-addr", + metavar="", + help="IP address and port (default: localhost:8000).", +) +@click.option( + "-o", + "--open", + default=False, + is_flag=True, + help="Open preview in default browser.", +) +@click.option( + "-s", + "--strict", + default=False, + is_flag=True, + help="Strict mode (currently unsupported).", +) +def execute_serve(config_file: str | None, **kwargs): + """ + Build and serve a project. + """ + if config_file is None: + for file in ["zensical.toml", "mkdocs.yml", "mkdocs.yaml"]: + if os.path.exists(file): + config_file = file + break + else: + raise ClickException("No config file found in the current folder.") + + # Obtain development server address and open in browser, if desired + dev_addr = kwargs.get("dev_addr") or "localhost:8000" + if kwargs.get("open", False): + webbrowser.open(f"http://{dev_addr}") + if kwargs.get("strict", False): + print("Warning: Strict mode is currently unsupported.") + + # Build project in Rust runtime, calling back into Python when necessary, + # e.g., to parse MkDocs configuration format or render Markdown + serve(os.path.abspath(config_file), dev_addr) + + +@cli.command(name="new") +@click.argument( + "directory", + type=click.Path(file_okay=False, dir_okay=True, writable=True), + required=False, +) +def new_project(directory: str | None, **kwargs): + """ + Create a new template project in the current directory or in the given + directory. + """ + + if directory is None: + directory = "." + if os.path.exists(directory): + if not os.path.isdir(directory): + raise (ClickException("Path provided is not a directory.")) + if any(os.listdir(directory)): + raise (ClickException("Directory is not empty. Aborting.")) + else: + os.makedirs(directory) + # ok, directory exists and is empty + package_dir = os.path.dirname(os.path.abspath(__file__)) + shutil.copy(os.path.join(package_dir, "bootstrap/zensical.toml"), directory) + shutil.copytree( + os.path.join(package_dir, "bootstrap/docs"), + os.path.join(directory, "docs"), + ) + + +# ---------------------------------------------------------------------------- +# Program +# ---------------------------------------------------------------------------- + +if __name__ == "__main__": # pragma: no cover + cli() diff --git a/python/zensical/markdown.py b/python/zensical/markdown.py new file mode 100644 index 0000000..5f96798 --- /dev/null +++ b/python/zensical/markdown.py @@ -0,0 +1,139 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +from __future__ import annotations + +import re +import yaml + +from datetime import date, datetime +from markdown import Markdown +from yaml import SafeLoader + +from .config import _CONFIG +from .extensions.links import LinksExtension +from .extensions.search import SearchExtension + +# ---------------------------------------------------------------------------- +# Constants +# ---------------------------------------------------------------------------- + + +FRONT_MATTER_RE = re.compile( + r"^-{3}[ \t]*\n(.*?\n)(?:\.{3}|-{3})[ \t]*\n", re.UNICODE | re.DOTALL +) +""" +Regex pattern to extract front matter. +""" + +# ---------------------------------------------------------------------------- +# Functions +# ---------------------------------------------------------------------------- + + +def render(content: str, path: str) -> dict: + """ + Render Markdown and return HTML. + + This function returns rendered HTML as well as the table of contents and + metadata. Now, this is the part where Zensical needs to call into Python, + in order to support the specific syntax of Python Markdown. We're working + on moving the entire rendering chain to Rust. + """ + config = _CONFIG + + # Initialize Markdown parser + md = Markdown( + extensions=config["markdown_extensions"], + extension_configs=config["mdx_configs"], + ) + + # Register links extension, which is equivalent to MkDocs' path resolution + # Markdown extension. This is a bandaid, until we move this to Rust + links = LinksExtension( + use_directory_urls=config["use_directory_urls"], path=path + ) + links.extendMarkdown(md) + + # Register search extension, which extracts text for search indexing + search = SearchExtension() + search.extendMarkdown(md) + + # First, extract metadata - the Python Markdown parser brings a metadata + # extension, but the implementation is broken, as it does not support full + # YAML syntax, e.g. lists. Thus, we just parse the metadata with YAML. + meta = {} + if match := FRONT_MATTER_RE.match(content): + try: + meta = yaml.load(match.group(1), SafeLoader) + if isinstance(meta, dict): + content = content[match.end() :].lstrip("\n") + else: + meta = {} + except Exception: + pass + + # Convert Markdown and set nullish metadata to empty string, since we + # currently don't have a null value for metadata in the Rust runtime + content = md.convert(content) + for key, value in meta.items(): + if value is None: + meta[key] = "" + + # Convert datetime back to ISO format (for now) + if isinstance(value, (date, datetime)): + meta[key] = value.isoformat() + + # Obtain search index data, unless page is excluded + search = md.postprocessors["search"] + if meta.get("search", {}).get("exclude", False): + search.data = [] + + # Return Markdown with metadata + return { + "meta": meta, + "content": content, + "search": search.data, + "title": "", + "toc": [_convert_toc(item) for item in getattr(md, "toc_tokens", [])], + } + + +def _convert_toc(item: any): + """ + Convert a table of contents item to navigation item format. + """ + toc_item = { + "title": item["data-toc-label"] or item["name"], + "id": item["id"], + "url": f"#{item['id']}", + "children": [], + "level": item["level"], + } + + # Recursively convert items + for child in item["children"]: + toc_item["children"].append(_convert_toc(child)) + + # Return table of contents item + return toc_item diff --git a/python/zensical/zensical.pyi b/python/zensical/zensical.pyi new file mode 100644 index 0000000..45cec0e --- /dev/null +++ b/python/zensical/zensical.pyi @@ -0,0 +1,40 @@ +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +# ---------------------------------------------------------------------------- +# Functions +# ---------------------------------------------------------------------------- + +def build(config_file: str, clean: bool): + """ + Builds the project. + """ + +def serve(config_file: str, dev_addr: str): + """ + Builds and serves the project. + """ + +# ---------------------------------------------------------------------------- + +__all__ = ["build", "serve"] diff --git a/scripts/commit.py b/scripts/commit.py new file mode 100755 index 0000000..ccfba7a --- /dev/null +++ b/scripts/commit.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python + +# ----------------------------------------------------------------------------- + +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import os, re, sys, tomllib # noqa: E401 + +from dataclasses import dataclass +from glob import glob + +# ---------------------------------------------------------------------------- +# Classes +# ---------------------------------------------------------------------------- + + +class ScopeError(ValueError): + """Invalid commit scope error.""" + + +class TypeError(ValueError): + """Invalid commit type error.""" + + +# ---------------------------------------------------------------------------- + + +@dataclass +class Message: + """ + Commit message. + + This class represents a commit message with a scope, type, and description. + It provides methods to parse and validate commit messages according to our + format, which is slightly different from the Conventional Commits standard, + improving readability and consistency. + """ + + @classmethod + def parse(cls, message: str) -> "Message": + """ + Parse a commit message string into an object. + """ + match = re.match(r"^([^:]+):([^\s]+) - (.+)$", message) + if not match: + raise ValueError("Required format: : - ") + + # Extract components and return commit message + scope, type, description = match.groups() + return cls(scope=scope, type=type, description=description) + + def validate(self, scopes: dict[str, str]) -> None: + """ + Validate the commit message against the given scopes and types. + """ + if self.scope not in scopes: + raise ScopeError(f"Invalid scope: {self.scope}") + + # Validate type + if self.type not in TYPES: + raise TypeError(f"Invalid type: {self.type}") + + # Validate description + if self.description != self.description.lower(): + raise ValueError("Commit message must be lowercased.") + + # Retrieve staged files + with os.popen("git diff --cached --name-only") as p: + output = p.read() + + # Validate if files are within scope + for file in output.strip().split("\n"): + if not f"./{file}".startswith(scopes[self.scope]): + raise ValueError( + f"Invalid scope for file: " + f"{file} not in {scopes[self.scope]}" + ) + + scope: str + """ + Commit scope. + """ + + type: str + """ + Commit type. + """ + + description: str + """ + Commit description. + """ + + +# ---------------------------------------------------------------------------- +# Functions +# ---------------------------------------------------------------------------- + + +def resolve(directory: str) -> dict[str, str] | None: + """ + Return commit scopes for a cargo project. + + This function checks, if the given directory contains a `Cargo.toml` file, + and if so, parses it to extract the workspace members. It then resolves the + valid scopes, which are the names of the crates defined in the respective + `Cargo.toml` files. + """ + path = os.path.join(directory, "Cargo.toml") + if not os.path.isfile(path): + return + + # Open and parse the Cargo.toml file + with open(path, "rb") as f: + content = tomllib.load(f) + + # Return workspace members + if "workspace" in content: + scopes: dict[str, str] = {} + + # Get the list of member crates + for member in content["workspace"].get("members", []): + path = os.path.join(directory, member) + for match in glob(path): + nested = resolve(match) + if nested: + scopes.update(nested) + + # Return commit scopes + return scopes + + # Return crate + package = content.get("package") + if package and "name" in package: + return {package["name"]: directory} + + +# ---------------------------------------------------------------------------- +# Constants +# ---------------------------------------------------------------------------- + + +TYPES = { + "feature", + "fix", + "refactor", + "docs", + "perf", + "test", + "build", + "style", + "chore", + "release", +} +""" +Commit types. +""" + +# ---------------------------------------------------------------------------- + +BG_RED = "\033[41m" +""" +ANSI escape code for red background. +""" + +FG_RED = "\033[31m" +""" +ANSI escape code for red foreground. +""" + +RESET = "\033[0m" +""" +ANSI escape code to reset formatting. +""" + +# ---------------------------------------------------------------------------- +# Program +# ---------------------------------------------------------------------------- + + +def main(): + """ + Commit message linter. + """ + if len(sys.argv) < 2: + print("No commit message provided.") + sys.exit(1) + + # Commit message might be passed as string, or in a file + commit = sys.argv[1] + if os.path.isfile(commit): + with open(sys.argv[1], "r") as f: + message = f.read().strip() + else: + message = commit.strip() + + # Skip merge commits + if message.startswith("Merge branch"): + return sys.exit(0) + + # Resolve cargo workspace members and parse commit message + scopes = resolve(os.path.curdir) + scopes["workspace"] = "." + try: + msg = Message.parse(message) + msg.validate(scopes) + + # If an error happened, print it + except ValueError as e: + print(f"{FG_RED}✘{RESET} {BG_RED} Error {RESET} {e}") + print("") + print(" Commit rejected.") + print("") + + # Exit with error + return sys.exit(1) + + +# ---------------------------------------------------------------------------- + +if __name__ == "__main__": + main() diff --git a/scripts/dev.py b/scripts/dev.py new file mode 100755 index 0000000..2066f44 --- /dev/null +++ b/scripts/dev.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python + +# ----------------------------------------------------------------------------- + +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import os, shutil, subprocess # noqa: E401 + +# ---------------------------------------------------------------------------- +# Program +# ---------------------------------------------------------------------------- + + +def main(): + """ + Set up development environment. + + This script clones the Zensical UI repository, and symbolically links the + build artifacts into the Python package directory for development use. + """ + os.makedirs("tmp", exist_ok=True) + + # Clone UI repository into tmp directory + repo_url = "https://github.com/zensical/ui.git" + dest_dir = os.path.join("tmp", "ui") + if not os.path.exists(dest_dir): + subprocess.run(["git", "clone", repo_url, dest_dir], check=True) + + # Remove existing template directory if it exists + path = os.path.join("python", "zensical", "templates") + if os.path.exists(path) and not os.path.islink(path): + shutil.rmtree(path) + + # Determine base and dist directories + base_dir = os.path.join("python", "zensical") + dist_dir = os.path.join(dest_dir, "dist") + + # Create a symbolic link to the UI source directory + path = os.path.join(base_dir, "templates") + if os.path.exists(dist_dir) and not os.path.exists(path): + os.symlink( + os.path.relpath(dist_dir, base_dir), + path, + target_is_directory=True, + ) + + # Create a .gitignore file to ignore templates directory + path = os.path.join(base_dir, ".gitignore") + if not os.path.exists(path): + with open(path, "w") as f: + f.write("templates\n") + + +# ---------------------------------------------------------------------------- + +if __name__ == "__main__": + main() diff --git a/scripts/prepare.py b/scripts/prepare.py new file mode 100755 index 0000000..3d5aa32 --- /dev/null +++ b/scripts/prepare.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python + +# ----------------------------------------------------------------------------- + +# Copyright (c) Zensical LLC + +# SPDX-License-Identifier: MIT +# Third-party contributions licensed under CLA + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +import os, subprocess # noqa: E401 + +# ---------------------------------------------------------------------------- +# Program +# ---------------------------------------------------------------------------- + + +def main(): + """ + Prepare production build. + """ + os.makedirs("tmp", exist_ok=True) + + # Clone UI repository into tmp directory + repo_url = "https://github.com/zensical/ui.git" + dest_dir = os.path.join("tmp", "ui") + if not os.path.exists(dest_dir): + subprocess.run(["git", "clone", repo_url, dest_dir], check=True) + + # Determine base and dist directories + base_dir = os.path.join("python", "zensical") + dist_dir = os.path.join(dest_dir, "dist") + + # Check, if there are symbolic links and remove them + if os.path.islink(os.path.join(base_dir, "templates")): + os.unlink(os.path.join(base_dir, "templates")) + + # Remove .gitignore file from development setup + path = os.path.join(base_dir, ".gitignore") + if os.path.exists(path): + os.remove(path) + + # Copy UI build artifacts + path = os.path.join(base_dir, "templates") + if os.path.exists(dist_dir): + if os.path.exists(path): + subprocess.run(["rm", "-rf", path], check=True) + subprocess.run(["cp", "-r", dist_dir, path], check=True) + + +# ---------------------------------------------------------------------------- + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c25976b --- /dev/null +++ b/uv.lock @@ -0,0 +1,261 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "deepmerge" +version = "2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, +] + +[[package]] +name = "markdown" +version = "3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8d/37/02347f6d6d8279247a5837082ebc26fc0d5aaeaf75aa013fcbb433c777ab/markdown-3.9.tar.gz", hash = "sha256:d2900fe1782bd33bdbbd56859defef70c2e78fc46668f8eb9df3128138f2cb6a", size = 364585, upload-time = "2025-09-04T20:25:22.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ae/44c4a6a4cbb496d93c6257954260fe3a6e91b7bed2240e5dad2a717f5111/markdown-3.9-py3-none-any.whl", hash = "sha256:9f4d91ed810864ea88a6f32c07ba8bee1346c0cc1f6b1f9f6c822f2a9667d280", size = 107441, upload-time = "2025-09-04T20:25:21.784Z" }, +] + +[[package]] +name = "maturin" +version = "1.9.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/35/c3370188492f4c139c7a318f438d01b8185c216303c49c4bc885c98b6afb/maturin-1.9.6.tar.gz", hash = "sha256:2c2ae37144811d365509889ed7220b0598487f1278c2441829c3abf56cc6324a", size = 214846, upload-time = "2025-10-07T12:45:08.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/5c/b435418ba4ba2647a1f7a95d53314991b1e556e656ae276dea993c3bce1d/maturin-1.9.6-py3-none-linux_armv6l.whl", hash = "sha256:26e3ab1a42a7145824210e9d763f6958f2c46afb1245ddd0bab7d78b1f59bb3f", size = 8134483, upload-time = "2025-10-07T12:44:44.274Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1c/8e58eda6601f328b412cdeeaa88a9b6a10e591e2a73f313e8c0154d68385/maturin-1.9.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5263dda3f71feef2e4122baf5c4620e4b3710dbb7f2121f85a337182de214369", size = 15776470, upload-time = "2025-10-07T12:44:47.476Z" }, + { url = "https://files.pythonhosted.org/packages/6c/33/8c967cce6848cdd87a2e442c86120ac644b80c5ed4c32e3291bde6a17df8/maturin-1.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe78262c2800c92f67d1ce3c0f6463f958a692cc67bfb572e5dbf5b4b696a8ba", size = 8226557, upload-time = "2025-10-07T12:44:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/58/bd/3e2675cdc8b7270700ba30c663c852a35694441732a107ac30ebd6878bd8/maturin-1.9.6-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:7ab827c6e8c022eb2e1e7fb6deede54549c8460b20ccc2e9268cc6e8cde957a8", size = 8166544, upload-time = "2025-10-07T12:44:51.396Z" }, + { url = "https://files.pythonhosted.org/packages/58/1f/a2047ddf2230e700d5f8a13dd4b9af5ce806ad380c32e58105888205926e/maturin-1.9.6-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:0246202377c49449315305209f45c8ecef6e2d6bd27a04b5b6f1ab3e4ea47238", size = 8641010, upload-time = "2025-10-07T12:44:53.658Z" }, + { url = "https://files.pythonhosted.org/packages/be/1f/265d63c7aa6faf363d4a3f23396f51bc6b4d5c7680a4190ae68dba25dea2/maturin-1.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:f5bac167700fbb6f8c8ed1a97b494522554b4432d7578e11403b894b6a91d99f", size = 7965945, upload-time = "2025-10-07T12:44:55.248Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ca/a8e61979ccfe080948bcc1bddd79356157aee687134df7fb013050cec783/maturin-1.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:7f53d3b1d8396d3fea3e1ee5fd37558bca5719090f3d194ba1c02b0b56327ae3", size = 7978820, upload-time = "2025-10-07T12:44:56.919Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4a/81b412f8ad02a99801ef19ec059fba0822d1d28fb44cb6a92e722f05f278/maturin-1.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:7f506eb358386d94d6ec3208c003130cf4b69cab26034fc0cbbf8bf83afa4c2e", size = 10452064, upload-time = "2025-10-07T12:44:58.232Z" }, + { url = "https://files.pythonhosted.org/packages/5b/12/cc96c7a8cb51d8dcc9badd886c361caa1526fba7fa69d1e7892e613b71d4/maturin-1.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2d6984ab690af509f525dbd2b130714207c06ebb14a5814edbe1e42b17ae0de", size = 8852401, upload-time = "2025-10-07T12:44:59.8Z" }, + { url = "https://files.pythonhosted.org/packages/51/8e/653ac3c9f2c25cdd81aefb0a2d17ff140ca5a14504f5e3c7f94dcfe4dbb7/maturin-1.9.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5c2252b0956bb331460ac750c805ddf0d9b44442449fc1f16e3b66941689d0bc", size = 8425057, upload-time = "2025-10-07T12:45:01.711Z" }, + { url = "https://files.pythonhosted.org/packages/db/29/f13490328764ae9bfc1da55afc5b707cebe4fa75ad7a1573bfa82cfae0c6/maturin-1.9.6-py3-none-win32.whl", hash = "sha256:f2c58d29ebdd4346fd004e6be213d071fdd94a77a16aa91474a21a4f9dbf6309", size = 7165956, upload-time = "2025-10-07T12:45:03.766Z" }, + { url = "https://files.pythonhosted.org/packages/db/9f/dd51e5ac1fce47581b8efa03d77a03f928c0ef85b6e48a61dfa37b6b85a2/maturin-1.9.6-py3-none-win_amd64.whl", hash = "sha256:1b39a5d82572c240d20d9e8be024d722dfb311d330c5e28ddeb615211755941a", size = 8145722, upload-time = "2025-10-07T12:45:05.487Z" }, + { url = "https://files.pythonhosted.org/packages/65/f2/e97aaba6d0d78c5871771bf9dd71d4eb8dac15df9109cf452748d2207412/maturin-1.9.6-py3-none-win_arm64.whl", hash = "sha256:ac02a30083553d2a781c10cd6f5480119bf6692fd177e743267406cad2ad198c", size = 6857006, upload-time = "2025-10-07T12:45:06.813Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/b3/6d2b3f149bc5413b0a29761c2c5832d8ce904a1d7f621e86616d96f505cc/pymdown_extensions-10.16.1.tar.gz", hash = "sha256:aace82bcccba3efc03e25d584e6a22d27a8e17caa3f4dd9f207e49b787aa9a91", size = 853277, upload-time = "2025-07-28T16:19:34.167Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/06/43084e6cbd4b3bc0e80f6be743b2e79fbc6eed8de9ad8c629939fa55d972/pymdown_extensions-10.16.1-py3-none-any.whl", hash = "sha256:d6ba157a6c03146a7fb122b2b9a121300056384eafeec9c9f9e584adfdb2a32d", size = 266178, upload-time = "2025-07-28T16:19:31.401Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, + { url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, + { url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, + { url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, + { url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, + { url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, +] + +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" }, + { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" }, + { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819, upload-time = "2025-10-08T22:01:17.964Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766, upload-time = "2025-10-08T22:01:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + +[[package]] +name = "zensical" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "markdown" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "maturin" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1.8" }, + { name = "deepmerge", specifier = ">=2.0" }, + { name = "markdown", specifier = ">=3.7" }, + { name = "pygments", specifier = ">=2.16" }, + { name = "pymdown-extensions", specifier = ">=10.15" }, + { name = "pyyaml", specifier = ">=6.0.2" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "maturin", specifier = ">=1.9.3" }, + { name = "ruff", specifier = ">=0.12.8" }, +]