From ef4894d4ea59363a928c958512a8843b62e122a0 Mon Sep 17 00:00:00 2001 From: Florian Dieminger Date: Mon, 24 Jun 2024 16:35:59 +0200 Subject: [PATCH] feat(rari): initial commit --- .cargo/config.toml | 2 + .editorconfig | 18 + .github/ISSUE_TEMPLATE/bug.yml | 36 + .github/ISSUE_TEMPLATE/config.yml | 8 + .github/PULL_REQUEST_TEMPLATE | 19 + .github/settings.yml | 54 + .github/workflows/codeql.yml | 41 + .github/workflows/release.yml | 41 + .github/workflows/welcome-bot.yml | 35 + .gitignore | 2 + .rustfmt.toml | 2 + CODE_OF_CONDUCT.md | 8 + CONTRIBUTING.md | 125 + Cargo.lock | 3889 +++++++++++++++++ Cargo.toml | 44 + LICENSE | 5 + LICENSE.BSD-2-Clause | 26 + LICENSE.MPL-2.0 | 373 ++ README.md | 61 + REVIEWING.md | 17 + SECURITY.md | 13 + TODO.md | 11 + crates/css-definition-syntax/Cargo.toml | 9 + crates/css-definition-syntax/src/error.rs | 19 + crates/css-definition-syntax/src/generate.rs | 203 + crates/css-definition-syntax/src/lib.rs | 7 + crates/css-definition-syntax/src/parser.rs | 1083 +++++ crates/css-definition-syntax/src/tokenizer.rs | 79 + crates/css-definition-syntax/src/walk.rs | 70 + crates/css-syntax-types/Cargo.toml | 12 + crates/css-syntax-types/src/lib.rs | 669 +++ crates/css-syntax/.gitignore | 2 + crates/css-syntax/Cargo.toml | 26 + crates/css-syntax/build.rs | 11 + crates/css-syntax/src/error.rs | 19 + crates/css-syntax/src/lib.rs | 3 + crates/css-syntax/src/syntax.rs | 860 ++++ crates/css-syntax/src/utils.rs | 1 + crates/diff-test/Cargo.toml | 21 + crates/diff-test/src/main.rs | 333 ++ crates/rari-data/Cargo.toml | 14 + crates/rari-data/src/baseline.rs | 154 + crates/rari-data/src/error.rs | 9 + crates/rari-data/src/lib.rs | 3 + crates/rari-data/src/specs.rs | 117 + crates/rari-deps/Cargo.toml | 22 + crates/rari-deps/src/bcd.rs | 66 + crates/rari-deps/src/error.rs | 15 + crates/rari-deps/src/lib.rs | 5 + crates/rari-deps/src/npm.rs | 85 + crates/rari-deps/src/web_features.rs | 9 + crates/rari-deps/src/webref_css.rs | 92 + crates/rari-doc/Cargo.toml | 42 + crates/rari-doc/src/baseline.rs | 76 + crates/rari-doc/src/build.rs | 66 + crates/rari-doc/src/cached_readers.rs | 233 + crates/rari-doc/src/docs/blog.rs | 322 ++ crates/rari-doc/src/docs/build.rs | 300 ++ crates/rari-doc/src/docs/curriculum.rs | 395 ++ crates/rari-doc/src/docs/doc.rs | 288 ++ crates/rari-doc/src/docs/dummy.rs | 170 + crates/rari-doc/src/docs/json.rs | 218 + crates/rari-doc/src/docs/mod.rs | 11 + crates/rari-doc/src/docs/page.rs | 201 + crates/rari-doc/src/docs/parents.rs | 26 + crates/rari-doc/src/docs/sections.rs | 227 + crates/rari-doc/src/docs/title.rs | 83 + crates/rari-doc/src/docs/types.rs | 32 + crates/rari-doc/src/error.rs | 105 + crates/rari-doc/src/html/links.rs | 75 + crates/rari-doc/src/html/mod.rs | 3 + crates/rari-doc/src/html/rewriter.rs | 221 + crates/rari-doc/src/html/sidebar.rs | 325 ++ crates/rari-doc/src/lib.rs | 13 + crates/rari-doc/src/percent.rs | 30 + crates/rari-doc/src/redirects.rs | 94 + crates/rari-doc/src/resolve.rs | 73 + crates/rari-doc/src/specs.rs | 91 + crates/rari-doc/src/templ/api.rs | 92 + crates/rari-doc/src/templ/macros/badges.rs | 60 + crates/rari-doc/src/templ/macros/compat.rs | 64 + crates/rari-doc/src/templ/macros/csssyntax.rs | 59 + crates/rari-doc/src/templ/macros/cssxref.rs | 137 + .../templ/macros/embedinteractiveexample.rs | 30 + crates/rari-doc/src/templ/macros/glossary.rs | 18 + crates/rari-doc/src/templ/macros/jsxref.rs | 47 + crates/rari-doc/src/templ/macros/links.rs | 126 + .../rari-doc/src/templ/macros/listsubpages.rs | 162 + .../rari-doc/src/templ/macros/livesample.rs | 58 + crates/rari-doc/src/templ/macros/mod.rs | 49 + .../src/templ/macros/specification.rs | 14 + crates/rari-doc/src/templ/mod.rs | 4 + crates/rari-doc/src/templ/parser.rs | 266 ++ crates/rari-doc/src/templ/rari-templ.pest | 71 + crates/rari-doc/src/templ/render.rs | 45 + crates/rari-doc/src/utils.rs | 225 + crates/rari-doc/src/walker/mod.rs | 65 + crates/rari-l10n/Cargo.toml | 10 + crates/rari-l10n/src/lib.rs | 84 + crates/rari-linter/Cargo.toml | 9 + crates/rari-linter/src/lib.rs | 3 + crates/rari-md/Cargo.toml | 15 + crates/rari-md/src/anchor.rs | 11 + crates/rari-md/src/bq.rs | 134 + crates/rari-md/src/ctype.rs | 29 + crates/rari-md/src/dl.rs | 62 + crates/rari-md/src/error.rs | 36 + crates/rari-md/src/ext.rs | 8 + crates/rari-md/src/html.rs | 1236 ++++++ crates/rari-md/src/li.rs | 21 + crates/rari-md/src/lib.rs | 144 + crates/rari-md/src/p.rs | 39 + crates/rari-templ-func/Cargo.toml | 17 + crates/rari-templ-func/README.md | 3 + crates/rari-templ-func/src/lib.rs | 89 + crates/rari-templ-func/tests/basic.rs | 71 + crates/rari-tools/Cargo.toml | 21 + crates/rari-tools/src/history.rs | 91 + crates/rari-tools/src/lib.rs | 2 + crates/rari-tools/src/popularities.rs | 43 + crates/rari-types/Cargo.toml | 23 + crates/rari-types/src/error.rs | 11 + crates/rari-types/src/fm_types.rs | 115 + crates/rari-types/src/globals.rs | 159 + crates/rari-types/src/lib.rs | 159 + crates/rari-types/src/locale.rs | 127 + crates/rari-types/src/settings.rs | 71 + src/main.rs | 156 + src/serve.rs | 54 + tests/data/content/files/en-us/_redirects.txt | 0 tests/data/content/files/en-us/basic/index.md | 11 + 131 files changed, 17224 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE create mode 100644 .github/settings.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/welcome-bot.yml create mode 100644 .gitignore create mode 100644 .rustfmt.toml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 LICENSE.BSD-2-Clause create mode 100644 LICENSE.MPL-2.0 create mode 100644 README.md create mode 100644 REVIEWING.md create mode 100644 SECURITY.md create mode 100644 TODO.md create mode 100644 crates/css-definition-syntax/Cargo.toml create mode 100644 crates/css-definition-syntax/src/error.rs create mode 100644 crates/css-definition-syntax/src/generate.rs create mode 100644 crates/css-definition-syntax/src/lib.rs create mode 100644 crates/css-definition-syntax/src/parser.rs create mode 100644 crates/css-definition-syntax/src/tokenizer.rs create mode 100644 crates/css-definition-syntax/src/walk.rs create mode 100644 crates/css-syntax-types/Cargo.toml create mode 100644 crates/css-syntax-types/src/lib.rs create mode 100644 crates/css-syntax/.gitignore create mode 100644 crates/css-syntax/Cargo.toml create mode 100644 crates/css-syntax/build.rs create mode 100644 crates/css-syntax/src/error.rs create mode 100644 crates/css-syntax/src/lib.rs create mode 100644 crates/css-syntax/src/syntax.rs create mode 100644 crates/css-syntax/src/utils.rs create mode 100644 crates/diff-test/Cargo.toml create mode 100644 crates/diff-test/src/main.rs create mode 100644 crates/rari-data/Cargo.toml create mode 100644 crates/rari-data/src/baseline.rs create mode 100644 crates/rari-data/src/error.rs create mode 100644 crates/rari-data/src/lib.rs create mode 100644 crates/rari-data/src/specs.rs create mode 100644 crates/rari-deps/Cargo.toml create mode 100644 crates/rari-deps/src/bcd.rs create mode 100644 crates/rari-deps/src/error.rs create mode 100644 crates/rari-deps/src/lib.rs create mode 100644 crates/rari-deps/src/npm.rs create mode 100644 crates/rari-deps/src/web_features.rs create mode 100644 crates/rari-deps/src/webref_css.rs create mode 100644 crates/rari-doc/Cargo.toml create mode 100644 crates/rari-doc/src/baseline.rs create mode 100644 crates/rari-doc/src/build.rs create mode 100644 crates/rari-doc/src/cached_readers.rs create mode 100644 crates/rari-doc/src/docs/blog.rs create mode 100644 crates/rari-doc/src/docs/build.rs create mode 100644 crates/rari-doc/src/docs/curriculum.rs create mode 100644 crates/rari-doc/src/docs/doc.rs create mode 100644 crates/rari-doc/src/docs/dummy.rs create mode 100644 crates/rari-doc/src/docs/json.rs create mode 100644 crates/rari-doc/src/docs/mod.rs create mode 100644 crates/rari-doc/src/docs/page.rs create mode 100644 crates/rari-doc/src/docs/parents.rs create mode 100644 crates/rari-doc/src/docs/sections.rs create mode 100644 crates/rari-doc/src/docs/title.rs create mode 100644 crates/rari-doc/src/docs/types.rs create mode 100644 crates/rari-doc/src/error.rs create mode 100644 crates/rari-doc/src/html/links.rs create mode 100644 crates/rari-doc/src/html/mod.rs create mode 100644 crates/rari-doc/src/html/rewriter.rs create mode 100644 crates/rari-doc/src/html/sidebar.rs create mode 100644 crates/rari-doc/src/lib.rs create mode 100644 crates/rari-doc/src/percent.rs create mode 100644 crates/rari-doc/src/redirects.rs create mode 100644 crates/rari-doc/src/resolve.rs create mode 100644 crates/rari-doc/src/specs.rs create mode 100644 crates/rari-doc/src/templ/api.rs create mode 100644 crates/rari-doc/src/templ/macros/badges.rs create mode 100644 crates/rari-doc/src/templ/macros/compat.rs create mode 100644 crates/rari-doc/src/templ/macros/csssyntax.rs create mode 100644 crates/rari-doc/src/templ/macros/cssxref.rs create mode 100644 crates/rari-doc/src/templ/macros/embedinteractiveexample.rs create mode 100644 crates/rari-doc/src/templ/macros/glossary.rs create mode 100644 crates/rari-doc/src/templ/macros/jsxref.rs create mode 100644 crates/rari-doc/src/templ/macros/links.rs create mode 100644 crates/rari-doc/src/templ/macros/listsubpages.rs create mode 100644 crates/rari-doc/src/templ/macros/livesample.rs create mode 100644 crates/rari-doc/src/templ/macros/mod.rs create mode 100644 crates/rari-doc/src/templ/macros/specification.rs create mode 100644 crates/rari-doc/src/templ/mod.rs create mode 100644 crates/rari-doc/src/templ/parser.rs create mode 100644 crates/rari-doc/src/templ/rari-templ.pest create mode 100644 crates/rari-doc/src/templ/render.rs create mode 100644 crates/rari-doc/src/utils.rs create mode 100644 crates/rari-doc/src/walker/mod.rs create mode 100644 crates/rari-l10n/Cargo.toml create mode 100644 crates/rari-l10n/src/lib.rs create mode 100644 crates/rari-linter/Cargo.toml create mode 100644 crates/rari-linter/src/lib.rs create mode 100644 crates/rari-md/Cargo.toml create mode 100644 crates/rari-md/src/anchor.rs create mode 100644 crates/rari-md/src/bq.rs create mode 100644 crates/rari-md/src/ctype.rs create mode 100644 crates/rari-md/src/dl.rs create mode 100644 crates/rari-md/src/error.rs create mode 100644 crates/rari-md/src/ext.rs create mode 100644 crates/rari-md/src/html.rs create mode 100644 crates/rari-md/src/li.rs create mode 100644 crates/rari-md/src/lib.rs create mode 100644 crates/rari-md/src/p.rs create mode 100644 crates/rari-templ-func/Cargo.toml create mode 100644 crates/rari-templ-func/README.md create mode 100644 crates/rari-templ-func/src/lib.rs create mode 100644 crates/rari-templ-func/tests/basic.rs create mode 100644 crates/rari-tools/Cargo.toml create mode 100644 crates/rari-tools/src/history.rs create mode 100644 crates/rari-tools/src/lib.rs create mode 100644 crates/rari-tools/src/popularities.rs create mode 100644 crates/rari-types/Cargo.toml create mode 100644 crates/rari-types/src/error.rs create mode 100644 crates/rari-types/src/fm_types.rs create mode 100644 crates/rari-types/src/globals.rs create mode 100644 crates/rari-types/src/lib.rs create mode 100644 crates/rari-types/src/locale.rs create mode 100644 crates/rari-types/src/settings.rs create mode 100644 src/main.rs create mode 100644 src/serve.rs create mode 100644 tests/data/content/files/en-us/_redirects.txt create mode 100644 tests/data/content/files/en-us/basic/index.md diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..0d7558b0 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[env] +TESTING_CONTENT_ROOT = { value = "tests/data/content/files", relative = true } diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..d8c1a629 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[*.rs] +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..091d81b2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,36 @@ +name: "Issue report" +description: Report an unexpected problem or unintended behavior. +labels: ["needs triage"] +body: + - type: markdown + attributes: + value: | + ### Before you start + + **Want to fix the problem yourself?** This project is open source and we welcome fixes and improvements from the community! + + ↩ Check the project [CONTRIBUTING.md](../blob/main/CONTRIBUTING.md) guide to see how to get started. + + --- + - type: textarea + id: problem + attributes: + label: What information was incorrect, unhelpful, or incomplete? + validations: + required: true + - type: textarea + id: expected + attributes: + label: What did you expect to see? + validations: + required: true + - type: textarea + id: references + attributes: + label: Do you have any supporting links, references, or citations? + description: Link to information that helps us confirm your issue. + - type: textarea + id: more-info + attributes: + label: Do you have anything more you want to share? + description: For example, steps to reproduce, screenshots, screen recordings, or sample code. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..51cc1603 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Content or feature request + url: https://github.com/mdn/mdn/issues/new/choose + about: Propose new content for MDN Web Docs or submit a feature request using this link. + - name: MDN GitHub Discussions + url: https://github.com/orgs/mdn/discussions + about: Does the issue involve a lot of changes, or is it hard to split it into actionable tasks? Start a discussion before opening an issue. diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE new file mode 100644 index 00000000..14435aff --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE @@ -0,0 +1,19 @@ + + +### Description + + + +### Motivation + + + +### Additional details + + + +### Related issues and pull requests + + + + diff --git a/.github/settings.yml b/.github/settings.yml new file mode 100644 index 00000000..d796759a --- /dev/null +++ b/.github/settings.yml @@ -0,0 +1,54 @@ +repository: + # See https://github.com/apps/settings for all available settings. + + # The name of the repository. Changing this will rename the repository + name: project-template + + # A short description of the repository that will show up on GitHub + description: MDN Web Docs project template + + # A URL with more information about the repository + homepage: https://github.com/mdn/project-template + + # The branch used by default for pull requests and when the repository is cloned/viewed. + default_branch: main + + # This repository is a template that others can use to start a new repository. + is_template: true + +branches: + - name: main + protection: + # Required. Require at least one approving review on a pull request, before merging. Set to null to disable. + required_pull_request_reviews: + # The number of approvals required. (1-6) + required_approving_review_count: 1 + # Dismiss approved reviews automatically when a new commit is pushed. + dismiss_stale_reviews: true + # Blocks merge until code owners have reviewed. + require_code_owner_reviews: true + +collaborators: + - username: Rumyra + permission: admin + + - username: fiji-flo + permission: admin + +labels: + - name: bug + color: D41130 + description: "Something that's wrong or not working as expected" + - name: chore + color: 258CD3 + description: "A routine task" + - name: "good first issue" + color: 48B71D + description: "Great for newcomers to start contributing" + - name: "help wanted" + color: 2E7A10 + description: "Contributions welcome" + +teams: + - name: core + permission: admin diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..91787dd2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +# TODO: Rename this file to codeql.yml and fill in the TODOs +name: "CodeQL" + +on: + push: + branches: ["main"] + paths-ignore: + - "**.md" + pull_request: + # The branches below must be a subset of the branches above + branches: ["main"] + paths-ignore: + - "**.md" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + matrix: + language: ["rust"] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..39b5278a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,41 @@ +name: Release + +on: + push: + tags: + - v[0-9]+.* + +jobs: + create-release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: taiki-e/create-gh-release-action@v1 + with: + allow-missing-changelog: true + token: ${{ secrets.GITHUB_TOKEN }} + + upload-assets: + needs: create-release + strategy: + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + build-tool: cargo-zigbuild + - target: aarch64-unknown-linux-musl + os: ubuntu-latest + build-tool: cargo-zigbuild + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: taiki-e/upload-rust-binary-action@v1 + with: + bin: rari + target: ${{ matrix.target }} + build-tool: ${{ matrix.build-tool }} + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/welcome-bot.yml b/.github/workflows/welcome-bot.yml new file mode 100644 index 00000000..ba7a0851 --- /dev/null +++ b/.github/workflows/welcome-bot.yml @@ -0,0 +1,35 @@ +# This workflow is hosted at: https://github.com/mdn/workflows/blob/main/.github/workflows/allo-allo.yml +# Docs for this workflow: https://github.com/mdn/workflows/blob/main/README.md#allo-allo +name: "AlloAllo" + +on: + issues: + types: + - opened + pull_request_target: + branches: + - main + types: + - opened + - closed + +jobs: + allo-allo: + uses: mdn/workflows/.github/workflows/allo-allo.yml@main + with: + target-repo: "TODO - add this repo in 'mdn/REPOSITORY_NAME' format" + issue-welcome: > + It looks like this is your first issue. Welcome! 👋 + One of the project maintainers will be with you as soon as possible. We + appreciate your patience. To safeguard the health of the project, please + take a moment to read our [code of conduct](../blob/main/CODE_OF_CONDUCT.md). + pr-welcome: > + It looks like this is your first pull request. 🎉 + Thank you for your contribution! One of the project maintainers will triage + and assign the pull request for review. We appreciate your patience. To + safeguard the health of the project, please take a moment to read our + [code of conduct](../blob/main/CODE_OF_CONDUCT.md). + pr-merged: > + Congratulations on your first merged pull request. 🎉 Thank you for your contribution! + Did you know we have a [project board](https://github.com/orgs/mdn/projects/25) with high-impact contribution opportunities? + We look forward to your next contribution. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..91580ef7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.config.toml diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 00000000..3a3f3f1d --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,2 @@ +imports_granularity = "Module" +group_imports = "StdExternalCrate" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..bd86da55 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,8 @@ +# Code of conduct + +This repository is governed by Mozilla's code of conduct and etiquette guidelines. +For more details, read [Mozilla's Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). + +## Reporting violations + +For more information on how to report violations of the Community Participation Guidelines, read the [How to report](https://www.mozilla.org/about/governance/policies/participation/reporting/) page. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..b6ead394 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contribution guide + +![github-profile](https://user-images.githubusercontent.com/10350960/166113119-629295f6-c282-42c9-9379-af2de5ad4338.png) + +- [Ways to contribute](#ways-to-contribute) +- [Finding an issue](#finding-an-issue) +- [Asking for help](#asking-for-help) +- [Pull request process](#pull-request-process) +- [Setting up the development environment](#setting-up-the-development-environment) + - [Forking and cloning the project](#forking-and-cloning-the-project) + - [Prerequisites](#prerequisites) +- [Signing commits](#signing-commits) + +Welcome 👋 Thank you for your interest in contributing to MDN Web Docs. We are happy to have you join us! 💖 + +As you get started, you are in the best position to give us feedback on project areas we might have forgotten about or assumed to work well. +These include, but are not limited to: + +- Problems found while setting up a new developer environment +- Gaps in our documentation +- Bugs in our automation scripts + +If anything doesn't make sense or work as expected, please open an issue and let us know! + +## Ways to contribute + +We welcome many different types of contributions including: + + + +- New features and content suggestions. +- Identifying and filing issues. +- Providing feedback on existing issues. +- Engaging with the community and answering questions. +- Contributing documentation or code. +- Promoting the project in personal circles and social media. + +## Finding an issue + +We have issues labeled `good first issue` for new contributors and `help wanted` suitable for any contributor. +Good first issues have extra information to help you make your first contribution a success. +Help wanted issues are ideal when you feel a bit more comfortable with the project details. + +Sometimes there won't be any issues with these labels, but there is likely still something for you to work on. +If you want to contribute but don't know where to start or can't find a suitable issue, speak to us on [Matrix](https://matrix.to/#/#mdn:mozilla.org), and we will be happy to help. + +Once you find an issue you'd like to work on, please post a comment saying you want to work on it. +Something like "I want to work on this" is fine. +Also, mention the community team using the `@mdn/mdn-community-engagement` handle to ensure someone will get back to you. + +## Asking for help + +The best way to reach us with a question when contributing is to use the following channels in the following order of precedence: + +- [Start a discussion](https://github.com/orgs/mdn/discussions) +- Ask your question or highlight your discussion on [Matrix](https://matrix.to/#/#mdn:mozilla.org). +- File an issue and tag the community team using the `@mdn/mdn-community-engagement` handle. + +## Pull request process + +The MDN Web Docs project has a well-defined pull request process which is documented in the [Pull request guidelines](https://developer.mozilla.org/en-US/docs/MDN/Community/Pull_requests). +Make sure you read and understand this process before you start working on a pull request. + + + +## Setting up the development environment + + + +### Forking and cloning the project + +The first step in setting up your development environment is to [fork the repository](https://docs.github.com/en/get-started/quickstart/fork-a-repo) and [clone](https://docs.github.com/en/get-started/quickstart/fork-a-repo#cloning-your-forked-repository) the repository to your local machine. + +### Prerequisites + + + +### Building the project + + + +## Signing commits + +We require all commits to be signed to verify the author's identity. +GitHub has a detailed guide on [setting up signed commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). +If you get stuck, please [ask for help](#asking-for-help). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..a67161a8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3889 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "getrandom 0.2.15", + "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 = "allocator-api2" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "ansi-to-html" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73c455ae09fa2223a75114789f30ad605e9e297f79537953523366c05995f5f" +dependencies = [ + "regex", + "thiserror", +] + +[[package]] +name = "anstream" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + +[[package]] +name = "anyhow" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3" + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "async-compression" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c90a406b4495d129f00461241616194cb8a032c8d1c53c657f0961d5f8e0498" +dependencies = [ + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "backtrace" +version = "0.3.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" + +[[package]] +name = "cc" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-targets 0.52.5", +] + +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + +[[package]] +name = "clap" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-verbosity-flag" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb9b20c0dd58e4c2e991c8d203bbeb76c11304d1011659686b5b644bc29aa478" +dependencies = [ + "clap", + "log", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + +[[package]] +name = "comrak" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a972c8ec1be8065f7b597b5f7f5b3be535db780280644aebdcd1966decf58dc" +dependencies = [ + "derive_builder", + "entities", + "memchr", + "once_cell", + "regex", + "slug", + "syntect", + "typed-arena", + "unicode_categories", +] + +[[package]] +name = "config" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7328b20597b53c2454f0b1919720c25c7339051c02b72b7e05409e00b14132be" +dependencies = [ + "lazy_static", + "nom", + "pathdiff", + "serde", + "toml", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3855a8a784b474f333699ef2bbca9db2c4a1f6d9088a90a2d25b1eb53111eaa" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +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-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "css-definition-syntax" +version = "0.0.1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "css-syntax" +version = "0.0.1" +dependencies = [ + "anyhow", + "css-definition-syntax", + "css-syntax-types", + "html-escape", + "once_cell", + "rari-deps", + "rari-types", + "regress", + "serde", + "serde_json", + "thiserror", + "url", +] + +[[package]] +name = "css-syntax-types" +version = "0.0.1" +dependencies = [ + "regress", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "cssparser" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 0.4.8", + "matches", + "phf 0.8.0", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.31.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 1.0.11", + "phf 0.11.2", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.63", +] + +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa 1.0.11", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + +[[package]] +name = "darling" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 2.0.63", +] + +[[package]] +name = "darling_macro" +version = "0.20.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_builder" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0350b5cb0331628a5916d6c5c0b72e97393b8b6b03b47a9284f4e7f5a405ffd7" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48cda787f839151732d396ac69e3473923d54312c070ee21e9effcaa8ca0b1d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206868b8242f27cecce124c19fd88157fbd0dd334df2587f36417bafbc85097b" +dependencies = [ + "derive_builder_core", + "syn 2.0.63", +] + +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + +[[package]] +name = "deunicode" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00" + +[[package]] +name = "diff-test" +version = "0.0.1" +dependencies = [ + "ansi-to-html", + "anyhow", + "clap", + "html-minifier", + "ignore", + "itertools", + "jsonpath_lib", + "once_cell", + "prettydiff", + "regex", + "serde", + "serde_json", + "similar", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "dtoa" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" + +[[package]] +name = "dtoa-short" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbaceec3c6e4211c79e7b1800fb9680527106beb2f9c51904a3210c03a448c74" +dependencies = [ + "dtoa", +] + +[[package]] +name = "educe" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4bd92664bf78c4d3dba9b7cdafce6fa15b13ed3ed16175218196942e99168a8" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "ego-tree" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a68a4904193147e0a8dec3314640e6db742afd5f6e634f428a6af230d9b3591" + +[[package]] +name = "either" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47c1c47d2f5964e29c61246e81db715514cd532db6b5116a25ea3c03d6780a2" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "entities" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" + +[[package]] +name = "enum-ordinalize" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea0dcfa4e54eeb516fe454635a95753ddd39acda650ce703031c6973e315dd5" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "filetime" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.4.1", + "windows-sys 0.52.0", +] + +[[package]] +name = "flate2" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "gimli" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" + +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "html-escape" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +dependencies = [ + "utf8-width", +] + +[[package]] +name = "html-minifier" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8f62e1c8ee7bbdda853aebbbfb78b75a549a45ab031665a5da07a1579a1ad8" +dependencies = [ + "cow-utils", + "educe", + "html-escape", + "minifier", +] + +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.11", +] + +[[package]] +name = "http-body" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe575dd17d0862a9a33781c8c4696a55c320909004a67a00fb286ba8b1bc496d" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "itoa 1.0.11", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b875924a60b96e5d7b9ae7b066540b1dd1cbd90d1828f54c92e02a283351c56" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collator" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d370371887d31d56f361c3eaa15743e54f13bc677059c9191c77e099ed6966b2" +dependencies = [ + "displaydoc", + "icu_collator_data", + "icu_collections", + "icu_locid_transform", + "icu_normalizer", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_collator_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee3f88741364b7d6269cce6827a3e6a8a2cf408a78f766c9224ab479d5e4ae5" + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f8ac670d7422d7f76b32e17a5db556510825b29ec9154f235977c9caba61036" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown 0.14.5", + "serde", +] + +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "is-terminal" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f23ff5ef2b80d608d61efee834934d862cd92461afc0560dedf493e4c033738b" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "jsonpath_lib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa63191d68230cccb81c5aa23abd53ed64d83337cacbb25a7b8c7979523774f" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + +[[package]] +name = "libc" +version = "0.2.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.5.0", + "libc", +] + +[[package]] +name = "line-wrap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9" +dependencies = [ + "safemem", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" + +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "lol_html" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4629ff9c2deeb7aad9b2d0f379fc41937a02f3b739f007732c46af40339dee5" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "cssparser 0.27.2", + "encoding_rs", + "hashbrown 0.13.2", + "lazy_static", + "lazycell", + "memchr", + "mime", + "selectors 0.22.0", + "thiserror", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minifier" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95bbbf96b9ac3482c2a25450b67a15ed851319bc5fabf3b40742ea9066e84282" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys 0.48.0", +] + +[[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normalize-path" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d" + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "onig" +version = "6.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c4b31c8722ad9171c6d77d3557db078cab2bd50afcc9d09c8b315c59df8ca4f" +dependencies = [ + "bitflags 1.3.2", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b829e3d7e9cc74c7e315ee8edb185bf4190da5acde74afd7fc59c35b1f086e7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.5.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "pad" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ad9b889f1b12e0b9ee24db044b5129150d5eada288edc800f789928dc8c0e3" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "parking_lot" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4af0ca4f6caed20e900d564c242b8e5d4903fdacf31d3daf527b66fe6f42fb" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.1", + "smallvec", + "windows-targets 0.52.5", +] + +[[package]] +name = "pathdiff" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pest" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560131c633294438da9f7c4b08189194b20946c8274c6b9e38881a7874dc8ee8" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26293c9193fbca7b1a3bf9b79dc1e388e927e6cacaa78b4a3ab705a1d3d41459" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ec22af7d3fb470a85dd2ca96b7c577a1eb4ef6f1683a9fe9a8c16e136c04687" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "pest_meta" +version = "2.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a240022f37c361ec1878d646fc5b7d7c4d28d5946e1a80ad5a7a4f4ca0bdcd" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_macros 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_macros 0.11.2", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.5", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3444646e286606587e49f3bcf1679b8cef1dc2c5ecc29ddacaffc305180d464b" +dependencies = [ + "phf_generator 0.11.2", + "phf_shared 0.11.2", + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + +[[package]] +name = "plist" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5699cc8a63d1aa2b1ee8e12b9ad70ac790d65788cd36101fa37f87ea46c4cef" +dependencies = [ + "base64 0.21.7", + "indexmap", + "line-wrap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettydiff" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abec3fb083c10660b3854367697da94c674e9e82aa7511014dc958beeb7215e9" +dependencies = [ + "owo-colors", + "pad", + "prettytable-rs", +] + +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ad3d49ab951a01fbaafe34f2ec74122942fe18a3f9814c3268f1bb72042131b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.15", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rari" +version = "0.0.1" +dependencies = [ + "anyhow", + "clap", + "clap-verbosity-flag", + "rari-deps", + "rari-doc", + "rari-tools", + "rari-types", + "serde_json", + "tiny_http", + "tracing", + "tracing-log", + "tracing-subscriber", +] + +[[package]] +name = "rari-data" +version = "0.0.1" +dependencies = [ + "chrono", + "indexmap", + "serde", + "serde_json", + "thiserror", + "url", +] + +[[package]] +name = "rari-deps" +version = "0.0.1" +dependencies = [ + "chrono", + "css-syntax-types", + "flate2", + "once_cell", + "reqwest", + "serde", + "serde_json", + "tar", + "thiserror", +] + +[[package]] +name = "rari-doc" +version = "0.0.1" +dependencies = [ + "base64 0.22.1", + "chrono", + "crossbeam-channel", + "css-syntax", + "enum_dispatch", + "html-escape", + "html5ever", + "icu_collator", + "icu_locid", + "ignore", + "lol_html", + "once_cell", + "percent-encoding", + "pest", + "pest_derive", + "rari-data", + "rari-l10n", + "rari-md", + "rari-templ-func", + "rari-types", + "rayon", + "regex", + "scraper", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tracing", + "validator", + "yaml-rust", +] + +[[package]] +name = "rari-l10n" +version = "0.0.1" +dependencies = [ + "rari-types", +] + +[[package]] +name = "rari-linter" +version = "0.0.1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "rari-md" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.22.1", + "comrak", + "once_cell", + "rari-types", + "regex", + "thiserror", +] + +[[package]] +name = "rari-templ-func" +version = "0.0.1" +dependencies = [ + "anyhow", + "quote", + "rari-types", + "syn 2.0.63", +] + +[[package]] +name = "rari-tools" +version = "0.0.1" +dependencies = [ + "chrono", + "csv", + "once_cell", + "rari-types", + "reqwest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "rari-types" +version = "0.0.1" +dependencies = [ + "chrono", + "config", + "dirs", + "indexmap", + "normalize-path", + "once_cell", + "serde", + "serde_json", + "serde_variant", + "strum", + "thiserror", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" +dependencies = [ + "bitflags 2.5.0", +] + +[[package]] +name = "redox_users" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + +[[package]] +name = "regress" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eae2a1ebfecc58aff952ef8ccd364329abe627762f5bf09ff42eb9d98522479" +dependencies = [ + "hashbrown 0.14.5", + "memchr", +] + +[[package]] +name = "reqwest" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +dependencies = [ + "async-compression", + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.5.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64 0.22.1", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" + +[[package]] +name = "rustversion" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092474d1a01ea8278f69e6a358998405fae5b8b963ddaeb2b0b04a128bf1dfb0" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "safemem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" + +[[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 = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b80b33679ff7a0ea53d37f3b39de77ea0c75b12c5805ac43ec0c33b3051af1b" +dependencies = [ + "ahash", + "cssparser 0.31.2", + "ego-tree", + "getopts", + "html5ever", + "indexmap", + "once_cell", + "selectors 0.25.0", + "tendril", +] + +[[package]] +name = "security-framework" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +dependencies = [ + "bitflags 2.5.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.27.2", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.1.1", + "smallvec", + "thin-slice", +] + +[[package]] +name = "selectors" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06" +dependencies = [ + "bitflags 2.5.0", + "cssparser 0.31.2", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf 0.10.1", + "phf_codegen 0.10.0", + "precomputed-hash", + "servo_arc 0.3.0", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" + +[[package]] +name = "serde" +version = "1.0.201" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780f1cebed1629e4753a1a38a3c72d30b97ec044f0aef68cb26650a3c5cf363c" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.201" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e405930b9796f1c00bee880d03fc7e0bb4b9a11afc776885ffe84320da2865" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "serde_json" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +dependencies = [ + "indexmap", + "itoa 1.0.11", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa 1.0.11", + "ryu", + "serde", +] + +[[package]] +name = "serde_variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0068df419f9d9b6488fdded3f1c818522cdea328e02ce9d9f147380265a432" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa 1.0.11", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "servo_arc" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "similar" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42c91313f1d05da9b26f267f931cf178d4aba455b4c4622dd7355eb80c6640" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "slug" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d8cec3501a5194c432b2b7976db6b7d10ec95c253208b45f83f7136aa985e29" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf59daf282c0a494ba14fd21610a0325f9f90ec9d1231dea26bcb1d696c946" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.63", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf5be731623ca1a1fb7d8be6f261a3be6d3e2337b8a1f97be944d020c8fcb704" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "syntect" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874dcfa363995604333cf947ae9f751ca3af4522c60886774c4963943b4746b1" +dependencies = [ + "bincode", + "bitflags 1.3.2", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "onig", + "plist", + "regex-syntax", + "serde", + "serde_derive", + "serde_json", + "thiserror", + "walkdir", + "yaml-rust", +] + +[[package]] +name = "tar" +version = "0.4.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" +dependencies = [ + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "thin-slice" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" + +[[package]] +name = "thiserror" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "time" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +dependencies = [ + "deranged", + "itoa 1.0.11", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.48.0", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e43f8cc456c9704c851ae29c67e17ef65d2c30017c17a9765b89c382dc8bba" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c127785850e8c20836d49732ae6abfa47616e60bf9d9f57c43c250361a9db96c" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + +[[package]] +name = "tower-service" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" + +[[package]] +name = "tracing" +version = "0.1.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "tracing-core" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-width" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "validator" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db79c75af171630a3148bd3e6d7c4f42b6a9a014c2945bc5ed0020cbb8d9478e" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55591299b7007f551ed1eb79a684af7672c19c3193fb9e0a31936987bb2438ec" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.63", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "web-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "winnow" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c52e9c97a68071b23e836c9380edae937f17b9c4667bd021973efc689f618d" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "xattr" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8da84f1a25939b27f6820d92aed108f83ff920fdf11a7b19366c27c4cda81d4f" +dependencies = [ + "libc", + "linux-raw-sys", + "rustix", +] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae87e3fcd617500e5d106f0380cf7b77f3c6092aae37191433159dda23cfb087" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15e934569e47891f7d9411f1a451d947a60e000ab3bd24fbb970f000387d1b3b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] + +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb2cc8827d6c0994478a15c53f374f46fbd41bea663d809b14744bc42e6b109c" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97cf56601ee5052b4417d90c8755c6683473c926039908196cf35d99f893ebe7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.63", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..184aef08 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "rari" +version = "0.0.1" +edition = "2021" +license = "MPL-2.0" +authors = ["Florian Dieminger "] + +[workspace] +resolver = "2" +members = [ + "crates/rari-data", + "crates/rari-deps", + "crates/rari-types", + "crates/rari-templ-func", + "crates/rari-md", + "crates/rari-doc", + "crates/rari-linter", + "crates/rari-l10n", + "crates/rari-tools", + "crates/css-syntax", + "crates/css-syntax-types", + "crates/css-definition-syntax", + "crates/diff-test", +] + +[workspace.package] +version = "0.0.1" +edition = "2021" +license = "MPL-2.0" +authors = ["Florian Dieminger "] + +[dependencies] +anyhow = "1" +clap = { version = "4.5.1", features = ["derive"] } +clap-verbosity-flag = "2" +rari-doc = { path = "crates/rari-doc" } +rari-tools = { path = "crates/rari-tools" } +rari-deps = { path = "crates/rari-deps" } +rari-types = { path = "crates/rari-types" } +serde_json = { version = "1", features = ["preserve_order"] } +tiny_http = "0.12" +tracing = "0.1" +tracing-subscriber = "0.3" +tracing-log = "0.2" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f098b6c6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,5 @@ +This project is licensed under the Mozilla Public License 2.0. +See LICENSE.MPL-2.0 file for more information. + +This project includes code from Comrak licensed under the BSD 2-Clause License. +See LICENSE.BSD-2-Clause file for more information. diff --git a/LICENSE.BSD-2-Clause b/LICENSE.BSD-2-Clause new file mode 100644 index 00000000..27595a34 --- /dev/null +++ b/LICENSE.BSD-2-Clause @@ -0,0 +1,26 @@ +Copyright (c) 2017–2024, Asherah Connor and Comrak contributors + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/LICENSE.MPL-2.0 b/LICENSE.MPL-2.0 new file mode 100644 index 00000000..a612ad98 --- /dev/null +++ b/LICENSE.MPL-2.0 @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md new file mode 100644 index 00000000..2e77b95f --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +> [!Warning] +> This project is work in project and lacking most of its documentation. +> Anything might change and code will move a lot. We do not encourage using it yet. +> We'll have an official announcement before we migrate, so stay tuned. + + +# Welcome to `rari` + +`rari` is the build system for [MDN](https://developer.mozilla.org). + +`rari` is hosted by [MDN](https://github.com/mdn). + +## Getting Started + +To get up and running, follow these steps: + +Make sure you have [Rust](https://www.rust-lang.org/) installed, otherwise go to [https://rustup.rs/](https://rustup.rs/). + +Clone this repository and run: +```plain +cargo run -- --help +``` + +### Configuation + +Create a `.config.toml` in the current working directory. +Add the following: + +```toml +content_root = "//files" +build_out_root = "/tmp/rari" +``` + +## Contributing + +For now we're aiming for a parity rewrite of [yari's](https://github.com/mdn/yari) `yarn build -n`. Which generates the `index.json` +for all docs. Until we reach that point the codebase will be unstable and may change at any point. Therefore we won't accept contributions for now. + + +By participating in and contributing to our projects and discussions, you acknowledge that you have read and agree to our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Resources + +For more information about `rari`, see the following resources: + +To be updated... + + + +## Communications + +If you have any questions, please reach out to us on [Discord](https://developer.mozilla.org/discord) + + +## License + +This project is licensed under the [Mozilla Public License 2.0](LICENSE.md). diff --git a/REVIEWING.md b/REVIEWING.md new file mode 100644 index 00000000..348d903a --- /dev/null +++ b/REVIEWING.md @@ -0,0 +1,17 @@ +# Reviewing guide + +All reviewers must abide by the [code of conduct](CODE_OF_CONDUCT.md); they are also protected by the code of conduct. +A reviewer should not tolerate poor behavior and is encouraged to [report any behavior](CODE_OF_CONDUCT.md#Reporting_violations) that violates the code of conduct. + +## Review process + +The MDN Web Docs team has a well-defined review process that must be followed by reviewers in all repositories under the GitHub MDN organization. +This process is described in detail on the [Pull request guidelines](https://developer.mozilla.org/en-US/docs/MDN/Community/Pull_requests) page. + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..f7a2c218 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Reporting a Vulnerability + +If you've discovered a security issue, please report it through the form linked +below, which will create a secure, private ticket. +https://bugzilla.mozilla.org/form.web.bounty + +MDN may be eligible for +[Mozilla's Security Bug Bounty Program](https://www.mozilla.org/en-US/security/bug-bounty/). +You can find more information about the bounty program in the +[Mozilla Web Bug Bounty FAQ](https://www.mozilla.org/en-US/security/bug-bounty/faq-webapp/). +You can use the above form even if you are not interested in a bounty reward. diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..781c329d --- /dev/null +++ b/TODO.md @@ -0,0 +1,11 @@ +# TODO's + +- [ ] Change compat macro to no args only and refactor `data-multiple` +- [ ] browser_compat sections should be able to contain content in yari ?! +- [ ] land curriculum has implementation +- [ ] unify prev next ... +- [ ] short_title falls back to title?! +- [ ] base url?! +- [ ] blog order uses inverse title compare as fallback +- [ ] unify link rendering +- [ ] locale compare for sidebar?! diff --git a/crates/css-definition-syntax/Cargo.toml b/crates/css-definition-syntax/Cargo.toml new file mode 100644 index 00000000..cc619e84 --- /dev/null +++ b/crates/css-definition-syntax/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "css-definition-syntax" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +thiserror = "1" diff --git a/crates/css-definition-syntax/src/error.rs b/crates/css-definition-syntax/src/error.rs new file mode 100644 index 00000000..fa4b7c2d --- /dev/null +++ b/crates/css-definition-syntax/src/error.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +use crate::parser::Node; + +#[derive(Debug, Clone, Error)] +pub enum SyntaxDefinitionError { + #[error("Expected Range node")] + ExpectedRangeNode, + #[error("Unknown node type {0:?}")] + UnknownNodeType(Node), + #[error("Parse error: Expected {0}")] + ParseErrorExpected(char), + #[error("Parse error: Expected function")] + ParseErrorExpectedFunction, + #[error("Parse error: Expected keyword")] + ParseErrorExpectedKeyword, + #[error("Parse error: Unexpected input")] + ParseErrorUnexpectedInput, +} diff --git a/crates/css-definition-syntax/src/generate.rs b/crates/css-definition-syntax/src/generate.rs new file mode 100644 index 00000000..76dc3e02 --- /dev/null +++ b/crates/css-definition-syntax/src/generate.rs @@ -0,0 +1,203 @@ +use crate::error::SyntaxDefinitionError; +use crate::parser::{Group, Node, Range}; + +fn generate_multiplier_simple(min: u32, max: u32, comma: bool) -> Option<&'static str> { + match (min, max) { + (0, 0) if comma => Some("#?"), + (0, 0) => Some("*"), + (0, 1) => Some("?"), + (1, 0) if comma => Some("#"), + (1, 0) => Some("+"), + (1, 1) => Some(""), + _ => None, + } +} +fn generate_multiplier(min: u32, max: u32, comma: bool) -> String { + if let Some(result) = generate_multiplier_simple(min, max, comma) { + return result.to_string(); + } + + let number_sign = if comma { "#" } else { "" }; + match (min, max) { + (min, max) if min == max => format!("{}{{{}}}", number_sign, min), + (min, 0) => format!("{}{{{},}}", number_sign, min), + (min, max) => format!("{}{{{},{}}}", number_sign, min, max), + } +} + +fn generate_type_opts(node: &Node) -> Result { + if let Node::Range(Range { + min, + max, + min_unit, + max_unit, + }) = node + { + let min_unit = min_unit + .as_ref() + .and_then(|unit| { + if min.is_finite() { + Some(unit.as_str()) + } else { + None + } + }) + .unwrap_or_default(); + let max_unit = max_unit + .as_ref() + .and_then(|unit| { + if max.is_finite() { + Some(unit.as_str()) + } else { + None + } + }) + .unwrap_or_default(); + Ok(format!(" [{min}{min_unit},{max}{max_unit}]")) + } else { + Err(SyntaxDefinitionError::ExpectedRangeNode) + } +} + +fn internal_generate<'a>( + node: &'a Node, + decorate: DecorateFn<'a>, + force_braces: bool, + compact: bool, +) -> Result { + let out = match node { + Node::Multiplier(multiplier) => { + let terms = internal_generate(&multiplier.term, decorate, force_braces, compact)?; + let multiplier = generate_multiplier(multiplier.min, multiplier.max, multiplier.comma); + let decorated = decorate(multiplier, node); + format!("{}{}", terms, decorated) + } + Node::Token(token) => token.value.to_string(), + Node::Property(property) => format!("<'{}'>", property.name), + Node::Type(typ) => { + let opts = if let Some(opts) = &typ.opts { + Some(decorate(generate_type_opts(opts)?, opts)) + } else { + None + }; + format!("<{}{}>", typ.name, opts.as_deref().unwrap_or_default()) + } + Node::Function(function) => format!("{}(", function.name), + Node::Keyword(keyword) => keyword.name.clone(), + Node::Comma => ",".to_string(), + Node::String(s) => s.value.clone(), + Node::AtKeyword(at_keyword) => format!("@{}", at_keyword.name), + Node::Group(group) => { + format!( + "{}{}", + generate_sequence(group, decorate, force_braces, compact)?, + if group.disallow_empty { "!" } else { "" } + ) + } + n => Err(SyntaxDefinitionError::UnknownNodeType(n.clone()))?, + }; + + Ok(decorate(out, node)) +} + +fn generate_sequence<'a>( + group: &'a Group, + decorate: DecorateFn<'a>, + force_braces: bool, + compact: bool, +) -> Result { + let combinator = if compact { + group.combinator.as_str_compact() + } else { + group.combinator.as_str() + }; + + let result = group + .terms + .iter() + .map(|node| internal_generate(node, decorate, force_braces, compact)) + .collect::, _>>()? + .join(combinator); + if group.explicit || force_braces { + let start = if compact || result.starts_with(',') { + "[" + } else { + "[ " + }; + let end = if compact { "]" } else { " ]" }; + Ok(format!("{}{}{}", start, result, end)) + } else { + Ok(result) + } +} + +fn noop(s: String, _: &Node) -> String { + s +} + +pub type DecorateFn<'a> = &'a dyn Fn(String, &'a Node) -> String; + +pub struct GenerateOptions<'a> { + pub compact: bool, + pub force_braces: bool, + pub decorate: DecorateFn<'a>, +} + +impl<'a> Default for GenerateOptions<'a> { + fn default() -> Self { + Self { + compact: Default::default(), + force_braces: Default::default(), + decorate: &noop, + } + } +} + +pub fn generate<'a>( + node: &'a Node, + options: GenerateOptions<'a>, +) -> Result { + internal_generate( + node, + options.decorate, + options.force_braces, + options.compact, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse; + + #[test] + fn test_generate() -> Result<(), SyntaxDefinitionError> { + let input = "[ | {0,0}] "; + let node = parse(input)?; + let result = generate(&node, Default::default()).unwrap(); + assert_eq!(result, "[ | * ] "); + + let input = "+#{1,2}"; + let node = parse(input)?; + let result = generate( + &node, + GenerateOptions { + decorate: &|s, _| format!("!!{}¡¡", s), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(result, "!!!!!!!!¡¡!!+¡¡¡¡!!#{1,2}¡¡¡¡¡¡"); + + let input = ""; + let node = parse(input)?; + let result = generate(&node, Default::default()).unwrap(); + assert_eq!(result, ""); + + let input = " [ [ '+' | '-' ] ]*"; + let node = parse(input)?; + let result = generate(&node, Default::default()).unwrap(); + assert_eq!(result, " [ [ '+' | '-' ] ]*"); + Ok(()) + } +} diff --git a/crates/css-definition-syntax/src/lib.rs b/crates/css-definition-syntax/src/lib.rs new file mode 100644 index 00000000..46f088c7 --- /dev/null +++ b/crates/css-definition-syntax/src/lib.rs @@ -0,0 +1,7 @@ +pub mod error; +pub mod generate; +pub mod parser; +pub mod tokenizer; +pub mod walk; + +pub use generate::generate; diff --git a/crates/css-definition-syntax/src/parser.rs b/crates/css-definition-syntax/src/parser.rs new file mode 100644 index 00000000..43fd1cf2 --- /dev/null +++ b/crates/css-definition-syntax/src/parser.rs @@ -0,0 +1,1083 @@ +use std::cmp::Ordering; +use std::collections::HashSet; +use std::fmt::Display; +use std::iter::empty; + +use crate::error::SyntaxDefinitionError; +use crate::tokenizer::Tokenizer; + +const TAB: char = '\t'; +const N: char = '\n'; +const F: char = '\u{c}'; +const R: char = '\r'; +const SPACE: char = ' '; +const EXCLAMATION_MARK: char = '!'; +const NUMBER_SIGN: char = '#'; +const AMPERSAND: char = '&'; +const APOSTROPHE: char = '\''; +const LEFT_PARENTHESIS: char = '('; +const RIGHT_PARENTHESIS: char = ')'; +const ASTERISK: char = '*'; +const PLUS_SIGN: char = '+'; +const COMMA: char = ','; +const HYPER_MINUS: char = '-'; +const LESS_THAN_SIGN: char = '<'; +const GREATER_THAN_SIGN: char = '>'; +const QUESTION_MARK: char = '?'; +const COMMERCIAL_AT: char = '@'; +const LEFT_SQUARE_BRACKET: char = '['; +const RIGHT_SQUARE_BRACKET: char = ']'; +const LEFT_CURLY_BRACKET: char = '{'; +const VERTICAL_LINE: char = '|'; +const RIGHT_CURLY_BRACKET: char = '}'; +const INFINITY: char = '∞'; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum CombinatorType { + Space, + DoubleAmpersand, + DoubleVerticalLine, + VerticalLine, +} + +impl CombinatorType { + pub fn as_str(&self) -> &'static str { + match self { + CombinatorType::Space => " ", + CombinatorType::DoubleAmpersand => " && ", + CombinatorType::DoubleVerticalLine => " || ", + CombinatorType::VerticalLine => " | ", + } + } + + pub fn as_str_compact(&self) -> &'static str { + match self { + CombinatorType::Space => " ", + CombinatorType::DoubleAmpersand => "&&", + CombinatorType::DoubleVerticalLine => "||", + CombinatorType::VerticalLine => "|", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MaybeMultiplier { + pub comma: bool, + pub min: u32, + pub max: u32, + pub term: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Multiplier { + pub comma: bool, + pub min: u32, + pub max: u32, + pub term: Box, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Token { + pub value: char, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Property { + pub name: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Range { + pub min: IntI, + pub max: IntI, + pub min_unit: Option, + pub max_unit: Option, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Type { + pub name: String, + pub opts: Option>, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Function { + pub name: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Keyword { + pub name: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Combinator { + pub value: CombinatorType, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct StringNode { + pub value: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Spaces { + pub value: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AtKeyword { + pub name: String, +} +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Group { + pub terms: Vec, + pub combinator: CombinatorType, + pub disallow_empty: bool, + pub explicit: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Node { + Multiplier(Multiplier), + Token(Token), + Property(Property), + Range(Range), + Type(Type), + Function(Function), + Keyword(Keyword), + Combinator(Combinator), + Comma, + String(StringNode), + Spaces(Spaces), + AtKeyword(AtKeyword), + Group(Group), +} + +impl Node { + pub fn str_name(&self) -> &str { + match self { + Node::Multiplier(_) => "Multiplier", + Node::Token(_) => "Token", + Node::Property(_) => "Property", + Node::Range(_) => "Range", + Node::Type(_) => "Type", + Node::Function(_) => "Function", + Node::Keyword(_) => "Keyword", + Node::Combinator(_) => "Combinator", + Node::Comma => "Comma", + Node::String(_) => "String", + Node::Spaces(_) => "Spaces", + Node::AtKeyword(_) => "AtKeyword", + Node::Group(_) => "Group", + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum IntI { + Finite(T), + Infinity, + NegativeInfinity, +} +impl IntI { + pub fn is_finite(&self) -> bool { + matches!(self, IntI::Finite(_)) + } +} + +impl Display for IntI +where + T: Display, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use IntI::*; + match self { + Finite(x) => write!(f, "{}", x), + Infinity => write!(f, "∞"), + NegativeInfinity => write!(f, "-∞"), + } + } +} + +impl PartialOrd for IntI +where + T: PartialOrd, +{ + fn partial_cmp(&self, other: &Self) -> Option { + use IntI::*; + match (self, other) { + (Infinity, Infinity) | (NegativeInfinity, NegativeInfinity) => Some(Ordering::Equal), + (Infinity, _) | (_, NegativeInfinity) => Some(Ordering::Greater), + (NegativeInfinity, _) | (_, Infinity) => Some(Ordering::Less), + (Finite(xf), Finite(yf)) => xf.partial_cmp(yf), + } + } +} + +const fn is_name_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' +} + +fn scan_spaces(tokenizer: &mut Tokenizer) -> String { + tokenizer.substring_to_pos(tokenizer.find_ws_end(tokenizer.pos)) +} + +fn scan_word( + tokenizer: &mut Tokenizer, + can_be_last: bool, +) -> Result { + let end = tokenizer + .str + .iter() + .skip(tokenizer.pos) + .position(|c| !is_name_char(*c)) + .map(|pos| pos + tokenizer.pos) + .or(if can_be_last { + Some(tokenizer.str.len()) + } else { + None + }) + .ok_or(SyntaxDefinitionError::ParseErrorExpectedKeyword)?; + + Ok(tokenizer.substring_to_pos(end)) +} + +fn scan_number(tokenizer: &mut Tokenizer) -> String { + let end = tokenizer + .str + .iter() + .skip(tokenizer.pos) + .position(|c| !c.is_ascii_digit()) + .map(|pos| pos + tokenizer.pos) + .unwrap_or_else(|| { + tokenizer.pos = tokenizer.str.len(); + tokenizer.error("Expect a number"); + tokenizer.str.len() + }); + + tokenizer.substring_to_pos(end) +} + +fn scan_string(tokenizer: &mut Tokenizer) -> String { + let end = tokenizer + .str + .iter() + .skip(tokenizer.pos + 1) + .position(|c| *c == '\'') + .map(|pos| pos + tokenizer.pos) + .unwrap_or_else(|| { + tokenizer.pos = tokenizer.str.len(); + tokenizer.error("Expect an apostrophe"); + 0 + }); + + tokenizer.substring_to_pos(end + 2) +} + +pub struct MultiplierRange { + pub min: u32, + pub max: u32, +} + +// TODO: This should ignore whitespace and comments +// See https://www.w3.org/TR/css-values-4/#component-multipliers +fn read_multiplier_range( + tokenizer: &mut Tokenizer, +) -> Result { + tokenizer.eat(LEFT_CURLY_BRACKET)?; + let min = scan_number(tokenizer).parse::().unwrap(); + + let max = if tokenizer.char_code() == COMMA { + tokenizer.pos += 1; + if tokenizer.char_code() != RIGHT_CURLY_BRACKET { + scan_number(tokenizer).parse::().unwrap() + } else { + 0 + } + } else { + min + }; + + tokenizer.eat(RIGHT_CURLY_BRACKET)?; + + Ok(MultiplierRange { min, max }) +} + +fn read_multiplier( + tokenizer: &mut Tokenizer, +) -> Result, SyntaxDefinitionError> { + let mut comma = false; + let range = match tokenizer.char_code() { + '*' => { + tokenizer.pos += 1; + MultiplierRange { min: 0, max: 0 } + } + '+' => { + tokenizer.pos += 1; + MultiplierRange { min: 1, max: 0 } + } + '?' => { + tokenizer.pos += 1; + MultiplierRange { min: 0, max: 1 } + } + '#' => { + tokenizer.pos += 1; + comma = true; + if tokenizer.char_code() == LEFT_CURLY_BRACKET { + read_multiplier_range(tokenizer)? + } else if tokenizer.char_code() == '?' { + tokenizer.pos += 1; + MultiplierRange { min: 0, max: 0 } + } else { + MultiplierRange { min: 1, max: 0 } + } + } + '{' => read_multiplier_range(tokenizer)?, + + _ => return Ok(None), + }; + + Ok(Some(MaybeMultiplier { + comma, + min: range.min, + max: range.max, + term: None, + })) +} + +fn maybe_multiplied(tokenizer: &mut Tokenizer, node: Node) -> Result { + let multiplier = read_multiplier(tokenizer)?; + if let Some(MaybeMultiplier { + comma, + min, + max, + term: _, + }) = multiplier + { + // https://www.w3.org/TR/css-values-4/#component-multipliers + // > The + and # multipliers may be stacked as +#; + // Represent "+#" as nested multipliers: + // { ..., + // term: { + // ..., + // term: node + // } + // } + if tokenizer.char_code() == NUMBER_SIGN + && tokenizer.char_code_at(tokenizer.pos - 1) == PLUS_SIGN + { + return maybe_multiplied( + tokenizer, + Node::Multiplier(Multiplier { + comma, + min, + max, + term: Box::new(node), + }), + ); + } + return Ok(Node::Multiplier(Multiplier { + comma, + min, + max, + term: Box::new(node), + })); + } + Ok(node) +} + +fn maybe_token(tokenizer: &mut Tokenizer) -> Option { + let ch = tokenizer.peek(); + if ch == '\0' { + return None; + } + Some(Node::Token(Token { value: ch })) +} + +fn read_property(tokenizer: &mut Tokenizer) -> Result { + tokenizer.eat(LESS_THAN_SIGN)?; + tokenizer.eat(APOSTROPHE)?; + + let name = scan_word(tokenizer, false)?; + + tokenizer.eat(APOSTROPHE)?; + tokenizer.eat(GREATER_THAN_SIGN)?; + + maybe_multiplied(tokenizer, Node::Property(Property { name })) +} + +// https://drafts.csswg.org/css-values-3/#numeric-ranges +// 4.1. Range Restrictions and Range Definition Notation +// +// Range restrictions can be annotated in the numeric type notation using CSS bracketed +// range notation—[min,max]—within the angle brackets, after the identifying keyword, +// indicating a closed range between (and including) min and max. +// For example, indicates an integer between 0 and 10, inclusive. +fn read_type_range(tokenizer: &mut Tokenizer) -> Result { + tokenizer.eat(LEFT_SQUARE_BRACKET)?; + + let sign = if tokenizer.char_code() == HYPER_MINUS { + tokenizer.peek(); + -1 + } else { + 1 + }; + + let (min, min_unit) = if sign == -1 && tokenizer.char_code() == INFINITY { + tokenizer.peek(); + (IntI::NegativeInfinity, None) + } else { + let min = scan_number(tokenizer) + .parse::() + .map(|x| IntI::Finite(x * sign)) + .unwrap_or(IntI::NegativeInfinity); + + if is_name_char(tokenizer.char_code()) { + (min, Some(scan_word(tokenizer, false)?)) + } else { + (min, None) + } + }; + + scan_spaces(tokenizer); + tokenizer.eat(COMMA)?; + scan_spaces(tokenizer); + + let (max, max_unit) = if tokenizer.char_code() == INFINITY { + tokenizer.peek(); + (IntI::Infinity, None) + } else { + let sign = if tokenizer.char_code() == HYPER_MINUS { + tokenizer.peek(); + -1 + } else { + 1 + }; + + let max = scan_number(tokenizer) + .parse::() + .map(|x| IntI::Finite(x * sign)) + .unwrap_or(IntI::Infinity); + + if is_name_char(tokenizer.char_code()) { + (max, Some(scan_word(tokenizer, false)?)) + } else { + (max, None) + } + }; + + if min_unit.is_some() && max_unit.is_some() && min_unit != max_unit { + tokenizer.error("Mismatched units in range"); + } + + tokenizer.eat(RIGHT_SQUARE_BRACKET)?; + + Ok(Node::Range(Range { + min, + max, + min_unit, + max_unit, + })) +} + +fn read_type(tokenizer: &mut Tokenizer) -> Result { + tokenizer.eat(LESS_THAN_SIGN)?; + let mut name = scan_word(tokenizer, false)?; + + if tokenizer.char_code() == LEFT_PARENTHESIS && tokenizer.next_char_code() == RIGHT_PARENTHESIS + { + tokenizer.pos += 2; + name.push_str("()") + } + + let opts = + if tokenizer.char_code_at(tokenizer.find_ws_end(tokenizer.pos)) == LEFT_SQUARE_BRACKET { + scan_spaces(tokenizer); + Some(Box::new(read_type_range(tokenizer)?)) + } else { + None + }; + tokenizer.eat(GREATER_THAN_SIGN)?; + + maybe_multiplied(tokenizer, Node::Type(Type { name, opts })) +} + +fn read_keyword_or_function(tokenizer: &mut Tokenizer) -> Result { + let name = scan_word(tokenizer, true)?; + + if tokenizer.char_code() == LEFT_PARENTHESIS { + tokenizer.pos += 1; + if tokenizer.pos >= tokenizer.str.len() { + return Err(SyntaxDefinitionError::ParseErrorExpectedFunction); + } + + return Ok(Node::Function(Function { name })); + } + + maybe_multiplied(tokenizer, Node::Keyword(Keyword { name })) +} + +fn regroup_terms( + mut terms: Vec, + combinators: HashSet, +) -> (Vec, CombinatorType) { + let mut combinators = combinators.into_iter().collect::>(); + combinators.sort(); + combinators.reverse(); + + let combinator = combinators + .first() + .copied() + .unwrap_or(CombinatorType::Space); + + while let Some(combinator) = combinators.pop() { + let mut i = 0; + let mut subgroup_start: Option = None; + + while i < terms.len() { + let term = &terms[i]; + + if let Node::Combinator(Combinator { value }) = term { + if *value == combinator { + if subgroup_start.is_none() { + subgroup_start = if i > 0 { Some(i - 1) } else { None }; + } + terms.remove(i); + continue; + } else { + if let Some(subgroup_start) = subgroup_start { + if i - subgroup_start > 1 { + let group = terms.splice(subgroup_start..i, empty()).collect(); + terms.insert( + subgroup_start, + Node::Group(Group { + terms: group, + combinator, + disallow_empty: false, + explicit: false, + }), + ); + i = subgroup_start + 1; + } + } + subgroup_start = None; + } + } + i += 1; + } + + if let Some(subgroup_start) = subgroup_start { + if !combinators.is_empty() { + let group = terms.splice(subgroup_start..i, empty()).collect(); + terms.insert( + subgroup_start, + Node::Group(Group { + terms: group, + combinator, + disallow_empty: false, + explicit: false, + }), + ); + } + } + } + (terms, combinator) +} + +fn read_implicit_group(tokenizer: &mut Tokenizer) -> Result { + let mut prev_token_pos = tokenizer.pos; + let mut combinators = HashSet::new(); + let mut terms = vec![]; + + while let Some(token) = peek(tokenizer)? { + match (&token, terms.last()) { + (Node::Spaces(Spaces { value: _ }), _) => continue, + ( + Node::Combinator(Combinator { value: _ }), + Some(Node::Combinator(Combinator { value: _ })) | None, + ) => { + tokenizer.pos = prev_token_pos; + tokenizer.error("Unexpected combinator"); + } + (Node::Combinator(Combinator { value }), _) => { + combinators.insert(*value); + } + (_, Some(Node::Combinator(Combinator { value: _ })) | None) => {} + _ => { + combinators.insert(CombinatorType::Space); + terms.push(Node::Combinator(Combinator { + value: CombinatorType::Space, + })); + } + } + terms.push(token); + prev_token_pos = tokenizer.pos; + } + + if let Some(Node::Combinator(Combinator { value: _ })) = terms.last() { + tokenizer.pos = prev_token_pos; + tokenizer.error("Unexpected combinator"); + } + let (terms, combinator) = regroup_terms(terms, combinators); + Ok(Group { + terms, + combinator, + disallow_empty: false, + explicit: false, + }) +} + +fn read_group(tokenizer: &mut Tokenizer) -> Result { + tokenizer.eat(LEFT_SQUARE_BRACKET)?; + let mut group = read_implicit_group(tokenizer)?; + tokenizer.eat(RIGHT_SQUARE_BRACKET)?; + + group.explicit = true; + + if tokenizer.char_code() == EXCLAMATION_MARK { + tokenizer.pos += 1; + group.disallow_empty = true; + } + + Ok(Node::Group(group)) +} + +fn peek(tokenizer: &mut Tokenizer) -> Result, SyntaxDefinitionError> { + let code = tokenizer.char_code(); + if is_name_char(code) { + return Ok(Some(read_keyword_or_function(tokenizer)?)); + } + + Ok(match code { + RIGHT_SQUARE_BRACKET => None, + LEFT_SQUARE_BRACKET => { + let group = read_group(tokenizer)?; + Some(maybe_multiplied(tokenizer, group)?) + } + LESS_THAN_SIGN => Some(if tokenizer.next_char_code() == APOSTROPHE { + read_property(tokenizer)? + } else { + read_type(tokenizer)? + }), + VERTICAL_LINE => { + tokenizer.eat(VERTICAL_LINE)?; + let value = if tokenizer.char_code() == VERTICAL_LINE { + tokenizer.eat(VERTICAL_LINE)?; + CombinatorType::DoubleVerticalLine + } else { + CombinatorType::VerticalLine + }; + + Some(Node::Combinator(Combinator { value })) + } + AMPERSAND => { + tokenizer.pos += 1; + tokenizer.eat(AMPERSAND)?; + Some(Node::Combinator(Combinator { + value: CombinatorType::DoubleAmpersand, + })) + } + COMMA => { + tokenizer.pos += 1; + Some(Node::Comma) + } + APOSTROPHE => { + let value = scan_string(tokenizer); + Some(maybe_multiplied( + tokenizer, + Node::String(StringNode { value }), + )?) + } + SPACE | TAB | N | R | F => { + let value = scan_spaces(tokenizer); + Some(Node::Spaces(Spaces { value })) + } + COMMERCIAL_AT => { + let code = tokenizer.next_char_code(); + if is_name_char(code) { + tokenizer.pos += 1; + Some(Node::AtKeyword(AtKeyword { + name: scan_word(tokenizer, true)?, + })) + } else { + maybe_token(tokenizer) + } + } + ASTERISK | PLUS_SIGN | QUESTION_MARK | NUMBER_SIGN | EXCLAMATION_MARK => None, + LEFT_CURLY_BRACKET => { + let code = tokenizer.next_char_code(); + if !code.is_ascii_digit() { + maybe_token(tokenizer) + } else { + None + } + } + + _ => maybe_token(tokenizer), + }) +} + +pub fn parse(source: &str) -> Result { + let mut tokenizer = Tokenizer::new(source); + let mut result = read_implicit_group(&mut tokenizer)?; + + if tokenizer.pos != tokenizer.str.len() { + return Err(SyntaxDefinitionError::ParseErrorUnexpectedInput); + } + + Ok( + if matches!(result.terms.as_slice(), &[Node::Group(_)]) && result.terms.len() == 1 { + result.terms.pop().unwrap() + } else { + Node::Group(result) + }, + ) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_scan_spaces() { + let mut tokenizer = Tokenizer::new(" \t\nfoo"); + let result = scan_spaces(&mut tokenizer); + assert_eq!(result, " \t\n"); + } + + #[test] + fn test_scan_string() { + let mut tokenizer = Tokenizer::new("hello' 123 'foo'"); + let result = scan_string(&mut tokenizer); + assert_eq!(result, "hello'"); + } + + #[test] + fn test_scan_number() { + let mut tokenizer = Tokenizer::new("'hello' 123 'foo'"); + tokenizer.pos = 8; + let result = scan_number(&mut tokenizer); + assert_eq!(result, "123"); + } + + #[test] + fn test_scan_word() -> Result<(), SyntaxDefinitionError> { + let mut tokenizer = Tokenizer::new("color 123 'foo'"); + let result = scan_word(&mut tokenizer, false)?; + assert_eq!(result, "color"); + Ok(()) + } + + #[test] + fn test_scan_multiplier_range() -> Result<(), SyntaxDefinitionError> { + let mut tokenizer = Tokenizer::new("{1,2}"); + let result = read_multiplier_range(&mut tokenizer)?; + assert_eq!(result.min, 1); + assert_eq!(result.max, 2); + + let mut tokenizer = Tokenizer::new("{1,}"); + let result = read_multiplier_range(&mut tokenizer)?; + assert_eq!(result.min, 1); + assert_eq!(result.max, 0); + + let mut tokenizer = Tokenizer::new("{1}"); + let result = read_multiplier_range(&mut tokenizer)?; + assert_eq!(result.min, 1); + assert_eq!(result.max, 1); + Ok(()) + } + + #[test] + fn test_read_multiplier() -> Result<(), SyntaxDefinitionError> { + let mut tokenizer = Tokenizer::new("#{1,4}"); + if let Some(MaybeMultiplier { + comma, + min, + max, + term: _, + }) = read_multiplier(&mut tokenizer)? + { + assert_eq!(min, 1); + assert_eq!(max, 4); + assert!(comma); + } else { + panic!("Expected a multiplier"); + } + Ok(()) + } + + #[test] + fn test_read_range() -> Result<(), SyntaxDefinitionError> { + let mut tokenizer = Tokenizer::new("[1,2]"); + if let Node::Range(Range { + min, + max, + min_unit, + max_unit, + }) = read_type_range(&mut tokenizer)? + { + assert_eq!(min, IntI::Finite(1)); + assert_eq!(max, IntI::Finite(2)); + assert_eq!(min_unit, None); + assert_eq!(max_unit, None); + } else { + panic!("Expected a range"); + } + + let mut tokenizer = Tokenizer::new("[-∞,2]"); + if let Node::Range(Range { + min, + max, + min_unit, + max_unit, + }) = read_type_range(&mut tokenizer)? + { + assert_eq!(min, IntI::NegativeInfinity); + assert_eq!(max, IntI::Finite(2)); + assert_eq!(min_unit, None); + assert_eq!(max_unit, None); + } else { + panic!("Expected a range"); + } + + let mut tokenizer = Tokenizer::new("[-100deg,∞]"); + if let Node::Range(Range { + min, + max, + min_unit, + max_unit, + }) = read_type_range(&mut tokenizer)? + { + assert_eq!(min, IntI::Finite(-100)); + assert_eq!(max, IntI::Infinity); + assert_eq!(min_unit, Some("deg".to_string())); + assert_eq!(max_unit, None); + } else { + panic!("Expected a range"); + } + Ok(()) + } + + #[test] + fn test_read_type() -> Result<(), SyntaxDefinitionError> { + let mut tokenizer = Tokenizer::new(""); + if let Node::Type(Type { name, opts }) = read_type(&mut tokenizer)? { + assert_eq!(name, "integer"); + assert_eq!(opts, None); + } else { + panic!("Expected a type"); + } + + let mut tokenizer = Tokenizer::new(""); + if let Node::Type(Type { name, opts }) = read_type(&mut tokenizer)? { + assert_eq!(name, "integer"); + assert_eq!( + opts, + Some(Box::new(Node::Range(Range { + min: IntI::Finite(0), + max: IntI::Finite(10), + min_unit: None, + max_unit: None, + }))) + ); + } else { + panic!("Expected a type"); + } + + Ok(()) + } + + #[test] + fn test_combinator_order() { + let mut combinators = vec![ + CombinatorType::DoubleVerticalLine, + CombinatorType::Space, + CombinatorType::VerticalLine, + CombinatorType::DoubleAmpersand, + ]; + + combinators.sort(); + + assert_eq!( + combinators, + vec![ + CombinatorType::Space, + CombinatorType::DoubleAmpersand, + CombinatorType::DoubleVerticalLine, + CombinatorType::VerticalLine + ] + ); + } + + #[test] + fn test_parse_simple() -> Result<(), SyntaxDefinitionError> { + let result = parse(" | | ")?; + assert_eq!( + result, + Node::Group(Group { + terms: vec![ + Node::Type(Type { + name: "color".to_string(), + opts: None + }), + Node::Type(Type { + name: "integer".to_string(), + opts: None + }), + Node::Type(Type { + name: "percentage".to_string(), + opts: None + }) + ], + combinator: CombinatorType::VerticalLine, + disallow_empty: false, + explicit: false + }) + ); + Ok(()) + } + + #[test] + fn test_parse_complex() -> Result<(), SyntaxDefinitionError> { + let syntax = "a b | c() && [ ? || <'e'> || ( f{2,4} ) ]*"; + let result = parse(syntax)?; + assert_eq!( + result, + Node::Group(Group { + terms: vec![ + Node::Group(Group { + terms: vec![ + Node::Keyword(Keyword { + name: "a".to_string() + }), + Node::Keyword(Keyword { + name: "b".to_string() + }) + ], + combinator: CombinatorType::Space, + disallow_empty: false, + explicit: false, + }), + Node::Group(Group { + terms: vec![ + Node::Group(Group { + terms: vec![ + Node::Function(Function { + name: "c".to_string() + }), + Node::Token(Token { value: ')' }) + ], + combinator: CombinatorType::Space, + disallow_empty: false, + explicit: false, + }), + Node::Multiplier(Multiplier { + comma: false, + min: 0, + max: 0, + term: Box::new(Node::Group(Group { + terms: vec![ + Node::Multiplier(Multiplier { + comma: false, + min: 0, + max: 1, + term: Box::new(Node::Type(Type { + name: "d".to_string(), + opts: None, + })) + }), + Node::Property(Property { + name: "e".to_string() + }), + Node::Group(Group { + terms: vec![ + Node::Token(Token { value: '(' }), + Node::Multiplier(Multiplier { + comma: false, + min: 2, + max: 4, + term: Box::new(Node::Keyword(Keyword { + name: "f".to_string() + })) + }), + Node::Token(Token { value: ')' }) + ], + combinator: CombinatorType::Space, + disallow_empty: false, + explicit: false, + }) + ], + combinator: CombinatorType::DoubleVerticalLine, + disallow_empty: false, + explicit: true, + })) + }) + ], + combinator: CombinatorType::DoubleAmpersand, + disallow_empty: false, + explicit: false, + }) + ], + combinator: CombinatorType::VerticalLine, + disallow_empty: false, + explicit: false, + }) + ); + Ok(()) + } + + #[test] + fn test_parse_with_range() -> Result<(), SyntaxDefinitionError> { + let _ = parse("")?; + Ok(()) + } + + #[test] + fn test_parse_quoted_plus() -> Result<(), SyntaxDefinitionError> { + let result = parse("[ '+' | '-' ]")?; + assert_eq!( + result, + Node::Group(Group { + terms: vec![ + Node::String(StringNode { + value: "'+'".into() + }), + Node::String(StringNode { + value: "'-'".into() + }) + ], + combinator: CombinatorType::VerticalLine, + disallow_empty: false, + explicit: true + }) + ); + Ok(()) + } + + #[test] + fn test_parse_function() -> Result<(), SyntaxDefinitionError> { + let result = parse("rgb() | rgba()")?; + assert_eq!( + result, + Node::Group(Group { + terms: vec![ + Node::Group(Group { + terms: vec![ + Node::Function(Function { name: "rgb".into() }), + Node::Token(Token { value: ')' }) + ], + combinator: CombinatorType::Space, + disallow_empty: false, + explicit: false + }), + Node::Group(Group { + terms: vec![ + Node::Function(Function { + name: "rgba".into() + }), + Node::Token(Token { value: ')' }) + ], + combinator: CombinatorType::Space, + disallow_empty: false, + explicit: false + }) + ], + combinator: CombinatorType::VerticalLine, + disallow_empty: false, + explicit: false + }) + ); + Ok(()) + } +} diff --git a/crates/css-definition-syntax/src/tokenizer.rs b/crates/css-definition-syntax/src/tokenizer.rs new file mode 100644 index 00000000..7f385b5b --- /dev/null +++ b/crates/css-definition-syntax/src/tokenizer.rs @@ -0,0 +1,79 @@ +use crate::error::SyntaxDefinitionError; + +pub struct Tokenizer { + pub str: Vec, + pub pos: usize, +} + +impl Tokenizer { + pub fn new(str: &str) -> Tokenizer { + Tokenizer { + str: str.chars().collect(), + pos: 0, + } + } + + pub fn char_code_at(&self, pos: usize) -> char { + if pos < self.str.len() { + self.str[pos] + } else { + '\0' + } + } + + pub fn char_code(&self) -> char { + self.char_code_at(self.pos) + } + + pub fn next_char_code(&self) -> char { + self.char_code_at(self.pos + 1) + } + + pub fn next_non_ws_code(&self, pos: usize) -> char { + self.char_code_at(self.find_ws_end(pos)) + } + + pub fn find_ws_end(&self, pos: usize) -> usize { + self.str + .iter() + .skip(pos) + .position(|c| !matches!(c, '\r' | '\n' | '\u{c}' | ' ' | '\t')) + .map(|p| pos + p) + .unwrap_or(self.str.len()) + } + + pub fn substring_to_pos(&mut self, end: usize) -> String { + let substring = self + .str + .iter() + .skip(self.pos) + .take(end - self.pos) + .collect(); + self.pos = end; + substring + } + + pub fn eat(&mut self, code: char) -> Result<(), SyntaxDefinitionError> { + if self.char_code() != code { + return Err(SyntaxDefinitionError::ParseErrorExpected(code)); + } + + self.pos += 1; + Ok(()) + } + + pub fn peek(&mut self) -> char { + if self.pos < self.str.len() { + let ch = self.str[self.pos]; + self.pos += 1; + ch + } else { + '\0' + } + } + + pub fn error(&self, message: &str) { + println!("Tokenizer error: {}", message); + panic!("Tokenizer error: {}", message); + } +} diff --git a/crates/css-definition-syntax/src/walk.rs b/crates/css-definition-syntax/src/walk.rs new file mode 100644 index 00000000..75ab05c6 --- /dev/null +++ b/crates/css-definition-syntax/src/walk.rs @@ -0,0 +1,70 @@ +use crate::error::SyntaxDefinitionError; +use crate::parser::Node; + +pub struct WalkOptions { + pub enter: fn(&Node, &mut T) -> Result<(), SyntaxDefinitionError>, + pub leave: fn(&Node, &mut T) -> Result<(), SyntaxDefinitionError>, +} + +fn noop(_: &Node, _: &mut T) -> Result<(), SyntaxDefinitionError> { + Ok(()) +} + +impl Default for WalkOptions { + fn default() -> Self { + Self { + enter: noop, + leave: noop, + } + } +} + +pub fn walk( + node: &Node, + options: &WalkOptions, + context: &mut T, +) -> Result<(), SyntaxDefinitionError> { + (options.enter)(node, context)?; + match node { + Node::Group(group) => { + for term in &group.terms { + walk(term, options, context)?; + } + } + Node::Multiplier(multiplier) => { + walk(&multiplier.term, options, context)?; + } + Node::Token(_) + | Node::Property(_) + | Node::Type(_) + | Node::Function(_) + | Node::Keyword(_) + | Node::Comma + | Node::String(_) + | Node::AtKeyword(_) => {} + _ => Err(SyntaxDefinitionError::UnknownNodeType(node.clone()))?, + } + (options.leave)(node, context)?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::parser::parse; + + #[test] + fn test_walk() -> Result<(), SyntaxDefinitionError> { + let syntax = parse(" | {0,0} ")?; + + walk( + &syntax, + &WalkOptions { + enter: |_, _| Ok(()), + leave: |_, _| Ok(()), + }, + &mut (), + )?; + Ok(()) + } +} diff --git a/crates/css-syntax-types/Cargo.toml b/crates/css-syntax-types/Cargo.toml new file mode 100644 index 00000000..cdaa9422 --- /dev/null +++ b/crates/css-syntax-types/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "css-syntax-types" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +regress = "0.9" +serde_json = { version = "1", features = ["preserve_order"] } +serde = { version = "1", features = ["derive"] } +url = { version = "2", features = ["serde"] } diff --git a/crates/css-syntax-types/src/lib.rs b/crates/css-syntax-types/src/lib.rs new file mode 100644 index 00000000..0150f805 --- /dev/null +++ b/crates/css-syntax-types/src/lib.rs @@ -0,0 +1,669 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use url::Url; + +pub mod error { + pub struct ConversionError(std::borrow::Cow<'static, str>); + impl std::error::Error for ConversionError {} + impl std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + std::fmt::Display::fmt(&self.0, f) + } + } + impl std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Css { + pub atrules: BTreeMap, + pub properties: BTreeMap, + pub selectors: BTreeMap, + pub spec: SpecInExtract, + pub values: CssValues, + pub warnings: Option>, +} +impl From<&Css> for Css { + fn from(value: &Css) -> Self { + value.clone() + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AtRule { + pub descriptors: BTreeMap, + pub href: Option, + pub name: String, + pub prose: Option, + pub value: Option, + pub values: Option, +} +impl From<&AtRule> for AtRule { + fn from(value: &AtRule) -> Self { + value.clone() + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Property { + pub href: Option, + pub name: String, + #[serde(rename = "newValues", default)] + pub new_values: Option, + #[serde(rename = "styleDeclaration", default)] + pub style_declaration: Vec, + pub value: Option, + pub values: Option, +} +impl From<&Property> for Property { + fn from(value: &Property) -> Self { + value.clone() + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Selector { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub href: Option, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prose: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub values: Option, +} +impl From<&Selector> for Selector { + fn from(value: &Selector) -> Self { + value.clone() + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AtruleDescriptor { + #[serde(rename = "for")] + pub for_: String, + pub href: Option, + pub name: String, + pub value: Option, + pub values: Option, +} +impl From<&AtruleDescriptor> for AtruleDescriptor { + fn from(value: &AtruleDescriptor) -> Self { + value.clone() + } +} +pub type CssValues = BTreeMap; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct CssValuesItem { + pub href: Option, + pub name: String, + pub prose: Option, + #[serde(rename = "type")] + pub type_: CssValueType, + pub value: Option, + pub values: Option, +} +impl From<&CssValuesItem> for CssValuesItem { + fn from(value: &CssValuesItem) -> Self { + value.clone() + } +} +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub enum CssValueType { + #[serde(rename = "type")] + Type, + #[serde(rename = "function")] + Function, + #[serde(rename = "value")] + Value, + #[serde(rename = "selector")] + Selector, +} +impl CssValueType { + pub fn as_str(&self) -> &str { + match *self { + Self::Type => "type", + Self::Function => "function", + Self::Value => "value", + Self::Selector => "selector", + } + } +} +impl From<&CssValueType> for CssValueType { + fn from(value: &CssValueType) -> Self { + *value + } +} + +impl std::str::FromStr for CssValueType { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + match value { + "type" => Ok(Self::Type), + "function" => Ok(Self::Function), + "value" => Ok(Self::Value), + "selector" => Ok(Self::Selector), + _ => Err("invalid value".into()), + } + } +} +impl std::convert::TryFrom<&str> for CssValueType { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for CssValueType { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for CssValueType { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Extensiontype { + Variant0(Interfacetype), + Variant1(String), +} +impl From<&Extensiontype> for Extensiontype { + fn from(value: &Extensiontype) -> Self { + value.clone() + } +} +impl From for Extensiontype { + fn from(value: Interfacetype) -> Self { + Self::Variant0(value) + } +} +#[derive(Clone, Debug, Deserialize, Serialize, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[serde(untagged)] +pub enum Global { + Variant0(Interface), + Variant1(String), +} +impl From<&Global> for Global { + fn from(value: &Global) -> Self { + value.clone() + } +} +impl std::str::FromStr for Global { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + if let Ok(v) = value.parse() { + Ok(Self::Variant0(v)) + } else if let Ok(v) = value.parse() { + Ok(Self::Variant1(v)) + } else { + Err("string conversion failed for all variants".into()) + } + } +} +impl std::convert::TryFrom<&str> for Global { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for Global { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for Global { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} + +impl From for Global { + fn from(value: Interface) -> Self { + Self::Variant0(value) + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IdlFragmentInSpec { + pub fragment: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub href: Option, + pub spec: SpecInExtract, +} +impl From<&IdlFragmentInSpec> for IdlFragmentInSpec { + fn from(value: &IdlFragmentInSpec) -> Self { + value.clone() + } +} +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub struct Interface(String); +impl std::ops::Deref for Interface { + type Target = String; + fn deref(&self) -> &String { + &self.0 + } +} +impl From for String { + fn from(value: Interface) -> Self { + value.0 + } +} +impl From<&Interface> for Interface { + fn from(value: &Interface) -> Self { + value.clone() + } +} +impl std::str::FromStr for Interface { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + if regress::Regex::new("^[A-Z]([A-Za-z0-9_])*$|^console$") + .unwrap() + .find(value) + .is_none() + { + return Err("doesn't match pattern \"^[A-Z]([A-Za-z0-9_])*$|^console$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl std::convert::TryFrom<&str> for Interface { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for Interface { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for Interface { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +impl<'de> serde::Deserialize<'de> for Interface { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Interfaces(pub Vec); +impl std::ops::Deref for Interfaces { + type Target = Vec; + fn deref(&self) -> &Vec { + &self.0 + } +} +impl From for Vec { + fn from(value: Interfaces) -> Self { + value.0 + } +} +impl From<&Interfaces> for Interfaces { + fn from(value: &Interfaces) -> Self { + value.clone() + } +} +impl From> for Interfaces { + fn from(value: Vec) -> Self { + Self(value) + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct InterfacesByGlobal(pub std::collections::BTreeMap); +impl std::ops::Deref for InterfacesByGlobal { + type Target = std::collections::BTreeMap; + fn deref(&self) -> &std::collections::BTreeMap { + &self.0 + } +} +impl From for std::collections::BTreeMap { + fn from(value: InterfacesByGlobal) -> Self { + value.0 + } +} +impl From<&InterfacesByGlobal> for InterfacesByGlobal { + fn from(value: &InterfacesByGlobal) -> Self { + value.clone() + } +} +impl From> for InterfacesByGlobal { + fn from(value: std::collections::BTreeMap) -> Self { + Self(value) + } +} +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub enum Interfacetype { + #[serde(rename = "dictionary")] + Dictionary, + #[serde(rename = "interface")] + Interface, + #[serde(rename = "interface mixin")] + InterfaceMixin, + #[serde(rename = "enum")] + Enum, + #[serde(rename = "typedef")] + Typedef, + #[serde(rename = "callback")] + Callback, + #[serde(rename = "callback interface")] + CallbackInterface, + #[serde(rename = "namespace")] + Namespace, +} +impl From<&Interfacetype> for Interfacetype { + fn from(value: &Interfacetype) -> Self { + *value + } +} +impl Interfacetype { + pub fn as_str(&self) -> &str { + match *self { + Self::Dictionary => "dictionary", + Self::Interface => "interface", + Self::InterfaceMixin => "interface mixin", + Self::Enum => "enum", + Self::Typedef => "typedef", + Self::Callback => "callback", + Self::CallbackInterface => "callback interface", + Self::Namespace => "namespace", + } + } +} +impl std::str::FromStr for Interfacetype { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + match value { + "dictionary" => Ok(Self::Dictionary), + "interface" => Ok(Self::Interface), + "interface mixin" => Ok(Self::InterfaceMixin), + "enum" => Ok(Self::Enum), + "typedef" => Ok(Self::Typedef), + "callback" => Ok(Self::Callback), + "callback interface" => Ok(Self::CallbackInterface), + "namespace" => Ok(Self::Namespace), + _ => Err("invalid value".into()), + } + } +} +impl std::convert::TryFrom<&str> for Interfacetype { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for Interfacetype { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for Interfacetype { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Links(pub std::collections::BTreeMap); +impl std::ops::Deref for Links { + type Target = std::collections::BTreeMap; + fn deref(&self) -> &std::collections::BTreeMap { + &self.0 + } +} +impl From for std::collections::BTreeMap { + fn from(value: Links) -> Self { + value.0 + } +} +impl From<&Links> for Links { + fn from(value: &Links) -> Self { + value.clone() + } +} +impl From> for Links { + fn from(value: std::collections::BTreeMap) -> Self { + Self(value) + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LinksValue { + #[serde(default)] + pub anchors: Vec, + #[serde(rename = "specShortname", default)] + pub spec_shortname: Option, +} +impl From<&LinksValue> for LinksValue { + fn from(value: &LinksValue) -> Self { + value.clone() + } +} +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub struct Shortname(String); +impl std::ops::Deref for Shortname { + type Target = String; + fn deref(&self) -> &String { + &self.0 + } +} +impl From for String { + fn from(value: Shortname) -> Self { + value.0 + } +} +impl From<&Shortname> for Shortname { + fn from(value: &Shortname) -> Self { + value.clone() + } +} +impl std::str::FromStr for Shortname { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + if regress::Regex::new("^[\\w\\-]+((?<=-v?\\d+)\\.\\d+)?$") + .unwrap() + .find(value) + .is_none() + { + return Err("doesn't match pattern \"^[\\w\\-]+((?<=-v?\\d+)\\.\\d+)?$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl std::convert::TryFrom<&str> for Shortname { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for Shortname { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for Shortname { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +impl<'de> serde::Deserialize<'de> for Shortname { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SpecInExtract { + pub title: String, + pub url: Url, +} +impl From<&SpecInExtract> for SpecInExtract { + fn from(value: &SpecInExtract) -> Self { + value.clone() + } +} + +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub struct ValueName(String); +impl std::ops::Deref for ValueName { + type Target = String; + fn deref(&self) -> &String { + &self.0 + } +} +impl From for String { + fn from(value: ValueName) -> Self { + value.0 + } +} +impl From<&ValueName> for ValueName { + fn from(value: &ValueName) -> Self { + value.clone() + } +} +impl std::str::FromStr for ValueName { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + if regress::Regex::new("^<[^>]+>$|^.*()$") + .unwrap() + .find(value) + .is_none() + { + return Err("doesn't match pattern \"^<[^>]+>$|^.*()$\"".into()); + } + Ok(Self(value.to_string())) + } +} +impl std::convert::TryFrom<&str> for ValueName { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for ValueName { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for ValueName { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +impl<'de> serde::Deserialize<'de> for ValueName { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub enum ValuesItemType { + #[serde(rename = "type")] + Type, + #[serde(rename = "function")] + Function, +} + +impl ValuesItemType { + pub fn as_str(&self) -> &str { + match *self { + Self::Type => "type", + Self::Function => "function", + } + } +} +impl From<&ValuesItemType> for ValuesItemType { + fn from(value: &ValuesItemType) -> Self { + *value + } +} + +impl std::str::FromStr for ValuesItemType { + type Err = self::error::ConversionError; + fn from_str(value: &str) -> Result { + match value { + "type" => Ok(Self::Type), + "function" => Ok(Self::Function), + _ => Err("invalid value".into()), + } + } +} +impl std::convert::TryFrom<&str> for ValuesItemType { + type Error = self::error::ConversionError; + fn try_from(value: &str) -> Result { + value.parse() + } +} +impl std::convert::TryFrom<&String> for ValuesItemType { + type Error = self::error::ConversionError; + fn try_from(value: &String) -> Result { + value.parse() + } +} +impl std::convert::TryFrom for ValuesItemType { + type Error = self::error::ConversionError; + fn try_from(value: String) -> Result { + value.parse() + } +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Warning { + pub msg: String, + pub name: String, +} +impl From<&Warning> for Warning { + fn from(value: &Warning) -> Self { + value.clone() + } +} diff --git a/crates/css-syntax/.gitignore b/crates/css-syntax/.gitignore new file mode 100644 index 00000000..eda68a2e --- /dev/null +++ b/crates/css-syntax/.gitignore @@ -0,0 +1,2 @@ +/package +webref-css-*.tgz diff --git a/crates/css-syntax/Cargo.toml b/crates/css-syntax/Cargo.toml new file mode 100644 index 00000000..f5c1d790 --- /dev/null +++ b/crates/css-syntax/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "css-syntax" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +once_cell = "1" +thiserror = "1" +regress = "0.9" +serde_json = { version = "1", features = ["preserve_order"] } +serde = { version = "1", features = ["derive"] } +url = { version = "2", features = ["serde"] } +css-syntax-types = { path = "../css-syntax-types" } +css-definition-syntax = { path = "../css-definition-syntax" } +html-escape = "0.2" +rari-deps = { path = "../rari-deps" } +rari-types = { path = "../rari-types", optional = true } + +[build-dependencies] +anyhow = "1" + +[features] +default = [] +rari = ["dep:rari-types"] diff --git a/crates/css-syntax/build.rs b/crates/css-syntax/build.rs new file mode 100644 index 00000000..2e636123 --- /dev/null +++ b/crates/css-syntax/build.rs @@ -0,0 +1,11 @@ +use anyhow::Error; + +fn main() -> Result<(), Error> { + #[cfg(not(feature = "rari"))] + { + let package_path = std::path::Path::new("@webref/css"); + rari_deps::webref_css::update_webref_css(package_path)?; + } + println!("cargo::rerun-if-changed=build.rs"); + Ok(()) +} diff --git a/crates/css-syntax/src/error.rs b/crates/css-syntax/src/error.rs new file mode 100644 index 00000000..4b59ad1e --- /dev/null +++ b/crates/css-syntax/src/error.rs @@ -0,0 +1,19 @@ +use std::fmt; + +use css_definition_syntax::error::SyntaxDefinitionError; +use css_definition_syntax::parser::Node; +use thiserror::Error; + +#[derive(Debug, Clone, Error)] +pub enum SyntaxError { + #[error(transparent)] + SyntaxDefinitionError(#[from] SyntaxDefinitionError), + #[error("Expected group node, got: {}", .0.str_name())] + ExpectedGroupNode(Node), + #[error("IoError")] + IoError, + #[error("fmtError")] + FmtError(#[from] fmt::Error), + #[error("Error: could not find syntax for this item")] + NoSyntaxFound, +} diff --git a/crates/css-syntax/src/lib.rs b/crates/css-syntax/src/lib.rs new file mode 100644 index 00000000..e502b090 --- /dev/null +++ b/crates/css-syntax/src/lib.rs @@ -0,0 +1,3 @@ +pub mod error; +pub mod syntax; +pub mod utils; diff --git a/crates/css-syntax/src/syntax.rs b/crates/css-syntax/src/syntax.rs new file mode 100644 index 00000000..de81f311 --- /dev/null +++ b/crates/css-syntax/src/syntax.rs @@ -0,0 +1,860 @@ +use std::cmp::{max, min, Ordering}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::fmt::Write; +use std::fs; + +use css_definition_syntax::generate::{self, GenerateOptions}; +use css_definition_syntax::parser::{parse, CombinatorType, Multiplier, Node, Type}; +use css_definition_syntax::walk::{walk, WalkOptions}; +use css_syntax_types::{Css, CssValueType, CssValuesItem}; +use once_cell::sync::Lazy; +#[cfg(all(feature = "rari", not(test)))] +use rari_types::globals::data_dir; +use serde::Serialize; + +use crate::error::SyntaxError; + +static CSS_REF: Lazy> = Lazy::new(|| { + #[cfg(test)] + { + let package_path = std::path::Path::new("package"); + rari_deps::webref_css::update_webref_css(package_path).unwrap(); + let json_str = fs::read_to_string(package_path.join("@webref/css").join("webref_css.json")) + .expect("no data dir"); + serde_json::from_str(&json_str).expect("Failed to parse JSON") + } + #[cfg(all(not(feature = "rari"), not(test)))] + { + let webref_css: &str = include_str!("../@webref/css/webref_css.json"); + serde_json::from_str(webref_css).expect("Failed to parse JSON") + } + #[cfg(all(feature = "rari", not(test)))] + { + let json_str = fs::read_to_string(data_dir().join("@webref/css").join("webref_css.json")) + .expect("no data dir"); + serde_json::from_str(&json_str).expect("Failed to parse JSON") + } +}); + +fn flatten_values(values: &'static BTreeMap, all: &mut Flattened) { + for (k, v) in values.iter() { + if let Some(map) = match v.type_ { + CssValueType::Type => Some(&mut all.types), + CssValueType::Function => Some(&mut all.functions), + CssValueType::Value => Some(&mut all.values), + CssValueType::Selector => None, + } { + map.entry(k).or_insert(v); + }; + for value in values.values() { + if let Some(values) = value.values.as_ref() { + flatten_values(values, all); + } + } + } +} + +#[derive(Default, Serialize, Debug)] +pub struct Flattened { + pub values: BTreeMap<&'static str, &'static CssValuesItem>, + pub functions: BTreeMap<&'static str, &'static CssValuesItem>, + pub types: BTreeMap<&'static str, &'static CssValuesItem>, +} + +// This relies on the ordered names of CSS_REF. +// "css-values-5" comes after "css-values" and therefore the updated +// overlapping values in "css-values-5" are ignored. +static FLATTENED: Lazy = Lazy::new(|| { + let mut all = Flattened::default(); + let mut entries = CSS_REF.iter().collect::>(); + entries.sort_by(|a, b| { + if b.0.ends_with(|c: char| c.is_ascii_digit() || c == '-') + && b.0 + .trim_end_matches(|c: char| c.is_ascii_digit() || c == '-') + == a.0 + { + Ordering::Greater + } else if a.0.ends_with(|c: char| c.is_ascii_digit() || c == '-') + && a.0 + .trim_end_matches(|c: char| c.is_ascii_digit() || c == '-') + == b.0 + { + Ordering::Less + } else { + a.0.cmp(b.0) + } + }); + for (_, spec) in entries { + for (k, item) in spec.values.iter() { + if let Some(map) = match item.type_ { + CssValueType::Type => Some(&mut all.types), + CssValueType::Function => Some(&mut all.functions), + CssValueType::Value => Some(&mut all.values), + CssValueType::Selector => None, + } { + map.insert(k, item); + }; + if let Some(values) = item.values.as_ref() { + flatten_values(values, &mut all); + } + } + for (_, item) in spec.properties.iter() { + if let Some(values) = item.values.as_ref() { + flatten_values(values, &mut all); + } + } + } + all +}); + +pub enum ItemType { + Property, + AtRule, +} + +#[derive(Debug, Clone, Copy)] +pub enum CssType<'a> { + Property(&'a str), + AtRule(&'a str), + AtRuleDescriptor(&'a str, &'a str), + Function(&'a str), + Type(&'a str), + ShorthandProperty(&'a str), +} + +fn get_specs_for_item<'a>(item_name: &str, item_type: ItemType) -> Vec<&'a str> { + let mut specs = Vec::new(); + for (name, data) in CSS_REF.iter() { + let hit = match item_type { + ItemType::Property => data.properties.contains_key(item_name), + ItemType::AtRule => data.atrules.contains_key(item_name), + }; + if hit { + specs.push(name.as_str()) + } + } + specs +} + +/// Get the formal syntax for a property from the webref data. +/// # Examples +/// +/// ``` +/// let color = css_syntax::syntax::get_property_syntax("color"); +/// assert_eq!(color, ""); +/// ``` +/// +/// ``` +/// let border = css_syntax::syntax::get_property_syntax("border"); +/// assert_eq!(border, " || || "); +/// ``` +/// +/// ``` +/// let grid_template_rows = css_syntax::syntax::get_property_syntax("grid-template-rows"); +/// assert_eq!(grid_template_rows, "none | | | subgrid ?"); +/// ``` +pub fn get_property_syntax(name: &str) -> String { + // 1) Get all specs which list this property + let mut specs = get_specs_for_item(name, ItemType::Property); + // 2) If we have more than one spec, filter out + // specs that end "-n" where n is a number + if specs.len() > 1 { + specs.retain(|s| { + !s.rsplit('-') + .next() + .map(|s| s.chars().all(char::is_numeric)) + .unwrap_or_default() + }); + } + // 3) If we have only one spec, return the syntax it lists + if specs.len() == 1 { + return CSS_REF + .get(specs[0]) + .and_then(|s| s.properties.get(name)) + .and_then(|i| i.value.clone()) + .unwrap_or_default(); + } + // 4) If we have > 1 spec, assume that: + // - one of them is the base spec, which defines `values`, + // - the others define incremental additions as `newValues` + + let (mut syntax, new_syntaxes) = specs.into_iter().fold( + (String::new(), String::new()), + |(mut syntax, mut new_syntaxes), spec_name| { + let base_value = CSS_REF + .get(spec_name) + .and_then(|s| s.properties.get(name)) + .and_then(|i| i.value.as_ref()); + let new_values = CSS_REF + .get(spec_name) + .and_then(|s| s.properties.get(name)) + .and_then(|i| i.new_values.as_ref()); + if let Some(base_value) = base_value { + syntax.push_str(base_value); + } + if let Some(new_values) = new_values { + new_syntaxes.push_str(" | "); + new_syntaxes.push_str(new_values); + } + (syntax, new_syntaxes) + }, + ); + + // Concatenate new_values onto values to return a single syntax string + if !new_syntaxes.is_empty() { + syntax.push_str(&new_syntaxes); + } + syntax +} + +/// Get the formal syntax for an at-rule from the webref data. +/// +/// Example: +/// ``` +/// let media = css_syntax::syntax::get_at_rule_syntax("@media"); +/// assert_eq!(media, "@media { }"); +/// ``` +pub fn get_at_rule_syntax(name: &str) -> String { + let specs = get_specs_for_item(name, ItemType::AtRule); + + return specs + .into_iter() + .find_map(|spec| { + return CSS_REF + .get(spec) + .and_then(|s| s.atrules.get(name)) + .and_then(|a| a.value.clone()); + }) + .unwrap_or_default(); +} + +/// Get the formal syntax for an at-rule descriptor from the webref data. +/// # Example: +/// ``` +/// let descriptor = css_syntax::syntax::get_at_rule_descriptor_syntax("width", "@media"); +/// assert_eq!(descriptor, ""); +/// ``` +pub fn get_at_rule_descriptor_syntax(at_rule_descriptor_name: &str, at_rule_name: &str) -> String { + let specs = get_specs_for_item(at_rule_name, ItemType::AtRule); + + return specs + .into_iter() + .find_map(|spec| { + return CSS_REF + .get(spec) + .and_then(|s| s.atrules.get(at_rule_name)) + .and_then(|a| a.descriptors.get(at_rule_descriptor_name)) + .and_then(|d| d.value.clone()); + }) + .unwrap_or_default(); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Syntax { + pub name: String, + pub syntax: String, +} + +#[inline] +fn skip(name: &str) -> bool { + name == "color" || name == "gradient" +} + +pub fn get_syntax(typ: CssType) -> Syntax { + get_syntax_internal(typ, false) +} +fn get_syntax_internal(typ: CssType, top_level: bool) -> Syntax { + let (name, syntax) = match typ { + CssType::ShorthandProperty(name) | CssType::Property(name) => { + let trimmed = name + .trim_start_matches(['<', '\'']) + .trim_end_matches(['\'', '>']); + (name.to_string(), get_property_syntax(trimmed)) + } + CssType::AtRule(name) => (name.to_string(), get_at_rule_syntax(name)), + CssType::AtRuleDescriptor(name, at_rule_name) => ( + name.to_string(), + get_at_rule_descriptor_syntax(name, at_rule_name), + ), + CssType::Function(name) => { + let name = format!("{name}()"); + ( + format!("<{name}>"), + FLATTENED + .functions + .get(name.as_str()) + .and_then(|item| item.value.clone()) + .unwrap_or_default(), + ) + } + CssType::Type(name) => { + let name = name.trim_end_matches("_value"); + if skip(name) && !top_level { + (format!("<{name}>"), Default::default()) + } else { + let syntax = FLATTENED + .types + .get(name) + .and_then(|item| item.value.clone()) + .unwrap_or( + FLATTENED + .values + .get(name) + .and_then(|item| item.value.clone()) + .unwrap_or_default(), + ); + let formatted_name = format!("<{name}>"); + let syntax = if name == syntax || formatted_name == syntax { + Default::default() + } else { + syntax + }; + (formatted_name, syntax) + } + } + }; + + Syntax { name, syntax } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LinkedToken { + Asterisk, + Plus, + QuestionMark, + CurlyBraces, + HashMark, + ExclamationPoint, + Brackets, + SingleBar, + DoubleBar, + DoubleAmpersand, + Juxtaposition, +} + +impl From for LinkedToken { + fn from(value: CombinatorType) -> Self { + match value { + CombinatorType::Space => Self::Juxtaposition, + CombinatorType::DoubleAmpersand => Self::DoubleAmpersand, + CombinatorType::DoubleVerticalLine => Self::DoubleBar, + CombinatorType::VerticalLine => Self::SingleBar, + } + } +} + +impl LinkedToken { + pub fn fragment(&self) -> &str { + match self { + LinkedToken::Asterisk => "asterisk", + LinkedToken::Plus => "plus", + LinkedToken::QuestionMark => "question_mark", + LinkedToken::CurlyBraces => "curly_braces", + LinkedToken::HashMark => "hash_mark", + LinkedToken::ExclamationPoint => "exclamation_point_!", + LinkedToken::Brackets => "brackets", + LinkedToken::SingleBar => "single_bar", + LinkedToken::DoubleBar => "double_bar", + LinkedToken::DoubleAmpersand => "double_ampersand", + LinkedToken::Juxtaposition => "juxtaposition", + } + } +} + +#[derive(Debug)] +struct Term { + pub length: usize, + pub text: String, +} +pub struct SyntaxRenderer<'a> { + pub locale_str: &'a str, + pub value_definition_url: &'a str, + pub syntax_tooltip: &'a HashMap, + pub constituents: HashSet, +} + +impl<'a> SyntaxRenderer<'a> { + pub fn render(&self, output: &mut String, syntax: &Syntax) -> Result<(), SyntaxError> { + let typ = html_escape::encode_safe(&syntax.name); + write!( + output, + r#"{typ} =
"# + )?; + + let ast = parse(&syntax.syntax)?; + + match ast { + Node::Group(ref group) if group.combinator == CombinatorType::Space => { + let combinator = group.combinator; + output.push_str(&self.render_terms(&[ast], combinator)?) + } + Node::Group(group) => { + output.push_str(&self.render_terms(&group.terms, group.combinator)?) + } + _ => return Err(SyntaxError::ExpectedGroupNode(ast)), + }; + Ok(()) + } + + fn generate_linked_token( + &self, + out: &mut String, + linked_token: LinkedToken, + copy: &str, + pre_suf: Option<(Option<&str>, Option<&str>)>, + ) -> Result<(), SyntaxError> { + let url = &self.value_definition_url; + let fragment = linked_token.fragment(); + let tooltip = self + .syntax_tooltip + .get(&linked_token) + .map(|s| s.as_str()) + .unwrap_or_default(); + let (prefix, suffix) = match pre_suf { + Some((pre, suf)) => (pre.unwrap_or_default(), suf.unwrap_or_default()), + None => ("", ""), + }; + write!( + out, + r#"{prefix}{copy}{suffix}"# + )?; + Ok(()) + } + + fn render_multiplier(&self, multiplier: &Multiplier) -> Result { + let Multiplier { + comma, + min, + max, + term, + } = multiplier; + let mut out = String::new(); + write!(&mut out, "{}", self.render_term(term)?.text)?; + if *comma { + self.generate_linked_token(&mut out, LinkedToken::HashMark, "#", None)? + } + match (min, max) { + (0, 0) if *comma => { + self.generate_linked_token(&mut out, LinkedToken::QuestionMark, "?", None)? + } + (0, 0) => self.generate_linked_token(&mut out, LinkedToken::Asterisk, "*", None)?, + (0, 1) => self.generate_linked_token(&mut out, LinkedToken::QuestionMark, "?", None)?, + (1, 0) if *comma => (), + (1, 0) => self.generate_linked_token(&mut out, LinkedToken::Plus, "+", None)?, + (1, 1) => (), + _ => { + let copy = match (min, max) { + (min, max) if min == max => format!("{{{min}}}"), + (min, 0) => format!("{{{min},}}"), + (min, max) => format!("{{{min},{max}}}"), + }; + self.generate_linked_token(&mut out, LinkedToken::CurlyBraces, ©, None)?; + } + }; + Ok(out) + } + + fn render_node(&self, name: &str, node: &Node) -> Result { + let out = match node { + Node::Multiplier(multiplier) => self.render_multiplier(multiplier)?, + Node::Token(_) if name == ")" => r#")"#.into(), + Node::Property(_) => { + let encoded = html_escape::encode_safe(name); + if name.starts_with("<'") && name.ends_with("'>") { + let slug = &name[2..name.len() - 2]; + format!( + r#"{encoded}"#, + self.locale_str + ) + } else { + format!(r#"{encoded}"#) + } + } + Node::Type(typ) => { + let encoded = html_escape::encode_safe(name); + let slug = match name { + "" => "color_value", + "" => "position_value", + name if name.starts_with('<') && name.ends_with('>') => { + &name[1..name.find(" [").or(name.find('[')).unwrap_or(name.len() - 1)] + } + name => &name[0..name.find(" [").or(name.find('[')).unwrap_or(name.len())], + }; + + if !skip(slug) + && (self.constituents.contains(node) + || self.constituents.contains(&Node::Type(Type { + name: typ.name.clone(), + opts: None, + }))) + { + // FIXME: this should have the class tye but to be compatible we use property + format!(r#"{encoded}"#,) + } else { + // FIXME: this should have the class tye but to be compatible we use property + format!( + r#"{encoded}"#, + self.locale_str + ) + } + } + Node::Function(_) => { + let encoded = html_escape::encode_safe(name); + format!(r#"{encoded}"#) + } + Node::Keyword(_) => { + let encoded = html_escape::encode_safe(name); + format!(r#"{encoded}"#) + } + Node::Group(group) => { + let mut opening_bracket_link = String::new(); + self.generate_linked_token( + &mut opening_bracket_link, + LinkedToken::Brackets, + "[", + Some((None, Some(" "))), + )?; + let mut closing_bracket_link = String::new(); + self.generate_linked_token( + &mut closing_bracket_link, + LinkedToken::Brackets, + "]", + Some((Some(" "), None)), + )?; + + let mut out = name + .replace("[ ", &opening_bracket_link) + .replace(" ]", &closing_bracket_link); + // TODO: remove + if group.combinator != CombinatorType::Space { + let mut combinator_link = String::new(); + self.generate_linked_token( + &mut combinator_link, + group.combinator.into(), + group.combinator.as_str_compact(), + Some((Some(" "), Some(" "))), + )?; + out = out.replace(group.combinator.as_str(), &combinator_link); + } + out + } + _ => name.to_string(), + }; + Ok(out) + } + + fn render_term(&self, term: &Node) -> Result { + let length = generate::generate(term, Default::default())? + .chars() + .count(); + let text = generate::generate( + term, + GenerateOptions { + decorate: &|name, node| self.render_node(&name, node).unwrap_or(name), + ..Default::default() + }, + )?; + Ok(Term { length, text }) + } + + fn render_terms( + &self, + terms: &[Node], + combinator: CombinatorType, + ) -> Result { + let terms = terms + .iter() + .map(|node| self.render_term(node)) + .collect::, SyntaxError>>()?; + + let max_line_len = 50; + let max_term_len = min( + terms.iter().map(|i| i.length).max().unwrap_or_default(), + max_line_len, + ) as i32; + + let len = terms.len(); + terms.into_iter().enumerate().try_fold( + String::new(), + |mut output, (i, Term { text, length })| { + let space_count = max(2, max_term_len + 2 - length as i32); + let combinator_text = if combinator == CombinatorType::Space { + "".to_string() + } else if i < len - 1 { + let linked_token = LinkedToken::from(combinator); + format!( + r#"{}"#, + self.value_definition_url, + linked_token.fragment(), + self.syntax_tooltip + .get(&linked_token) + .map(|s| s.as_str()) + .unwrap_or_default(), + combinator.as_str_compact() + ) + } else { + Default::default() + }; + write!( + output, + " {text}{}{combinator_text}
", + " ".repeat(space_count as usize) + ) + .map_err(|_| SyntaxError::IoError)?; + + Ok::(output) + }, + ) + } + fn get_constituent_syntaxes(&mut self, syntax: Syntax) -> Result, SyntaxError> { + let mut all_constituents = vec![]; + + let mut last_len: usize; + let mut last_syntax_len = 0; + + let mut constituent_syntaxes: Vec = vec![syntax]; + + loop { + last_len = all_constituents.len(); + get_nodes_for_syntaxes( + &constituent_syntaxes[last_syntax_len..], + &mut all_constituents, + )?; + + if all_constituents.len() <= last_len { + break; + } + + last_syntax_len = constituent_syntaxes.len(); + + for constituent in all_constituents[last_len..].iter_mut() { + if let Some(constituent_entry) = match &mut constituent.node { + Node::Type(typ) if typ.name.ends_with("()") => { + let syntax = get_syntax(CssType::Function(&typ.name[..typ.name.len() - 2])); + Some(syntax) + } + Node::Type(typ) => { + let syntax = get_syntax(CssType::Type(&typ.name)); + typ.opts = None; + Some(syntax) + } + Node::Property(property) => { + let mut syntax = get_syntax(CssType::Property(&property.name)); + syntax.name = format!("<{}>", syntax.name); + Some(syntax) + } + // Node::Function(function) => Some(get_syntax(CssType::Function(&function.name))), + Node::AtKeyword(at_keyword) => { + Some(get_syntax(CssType::AtRule(&at_keyword.name))) + } + _ => None, + } { + if !constituent_entry.syntax.is_empty() + && !constituent_syntaxes.contains(&constituent_entry) + { + constituent.syntax_used = true; + constituent_syntaxes.push(constituent_entry) + } + } + } + } + self.constituents + .extend(all_constituents.into_iter().filter_map(|constituent| { + if constituent.syntax_used { + Some(constituent.node) + } else { + None + } + })); + Ok(constituent_syntaxes) + } +} + +#[derive(Debug)] +struct Constituent { + node: Node, + syntax_used: bool, +} + +impl From for Constituent { + fn from(node: Node) -> Self { + Constituent { + node, + syntax_used: false, + } + } +} +pub fn write_formal_syntax( + css: CssType, + locale_str: &str, + value_definition_url: &str, + syntax_tooltip: &'_ HashMap, +) -> Result { + let mut renderer = SyntaxRenderer { + locale_str, + value_definition_url, + syntax_tooltip, + constituents: Default::default(), + }; + let syntax: Syntax = get_syntax_internal(css, true); + if syntax.syntax.is_empty() { + return Err(SyntaxError::NoSyntaxFound); + } + let mut out = String::new(); + write!(out, r#"
"#)?;
+    let constituents = renderer.get_constituent_syntaxes(syntax)?;
+
+    for constituent in constituents {
+        renderer.render(&mut out, &constituent)?;
+        out.push_str("
"); + } + + out.push_str("
"); + Ok(out) +} + +fn get_nodes_for_syntaxes( + syntaxes: &[Syntax], + constituents: &mut Vec, +) -> Result<(), SyntaxError> { + for syntax in syntaxes { + if syntax.syntax.is_empty() { + continue; + } + let ast = parse(&syntax.syntax); + + walk( + &ast?, + &WalkOptions::> { + enter: |node: &Node, context: &mut Vec| { + if !skip(node.str_name()) + && !context.iter().any(|constituent| constituent.node == *node) + { + context.push(node.clone().into()) + } + Ok(()) + }, + ..Default::default() + }, + constituents, + )?; + } + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + + static TOOLTIPS: Lazy> = Lazy::new(|| { + [(LinkedToken::Asterisk, "Asterisk: the entity may occur zero, one or several times".to_string()), + (LinkedToken::Plus, "Plus: the entity may occur one or several times".to_string()), + (LinkedToken::QuestionMark, "Question mark: the entity is optional".to_string()), + (LinkedToken::CurlyBraces, "Curly braces: encloses two integers defining the minimal and maximal numbers of occurrences of the entity, or a single integer defining the exact number required".to_string()), + (LinkedToken::HashMark, "Hash mark: the entity is repeated one or several times, each occurence separated by a comma".to_string()), + (LinkedToken::ExclamationPoint,"Exclamation point: the group must produce at least one value".to_string()), + (LinkedToken::Brackets, "Brackets: enclose several entities, combinators, and multipliers to transform them as a single component".to_string()), + (LinkedToken::SingleBar, "Single bar: exactly one of the entities must be present".to_string()), + (LinkedToken::DoubleBar, "Double bar: one or several of the entities must be present, in any order".to_string()), + (LinkedToken::DoubleAmpersand, "Double ampersand: all of the entities must be present, in any order".to_string())].into_iter().collect() + }); + + #[test] + fn test_get_syntax_color_property_color() { + let Syntax { name, syntax } = get_syntax_internal(CssType::Property("color"), true); + assert_eq!(name, "color"); + assert_eq!(syntax, ""); + } + #[test] + fn test_get_syntax_color_property_content_visibility() { + let Syntax { name, syntax } = get_syntax(CssType::Property("content-visibility")); + assert_eq!(name, "content-visibility"); + assert_eq!(syntax, "visible | auto | hidden"); + } + #[test] + fn test_get_syntax_length_type() { + let Syntax { name, syntax } = get_syntax(CssType::Type("length")); + assert_eq!(name, ""); + assert_eq!(syntax, ""); + } + #[test] + fn test_get_syntax_color_type() { + let Syntax { name, syntax } = get_syntax_internal(CssType::Type("color_value"), true); + assert_eq!(name, ""); + assert_eq!(syntax, " | currentColor | "); + } + #[test] + fn test_get_syntax_minmax_function() { + let Syntax { name, syntax } = get_syntax(CssType::Function("minmax")); + assert_eq!(name, ""); + assert_eq!(syntax, "minmax(min, max)"); + } + #[test] + fn test_get_syntax_sin_function() { + let Syntax { name, syntax } = get_syntax(CssType::Function("sin")); + assert_eq!(name, ""); + assert_eq!(syntax, "sin( )"); + } + #[test] + fn test_get_syntax_media_at_rule() { + let Syntax { name, syntax } = get_syntax(CssType::AtRule("@media")); + assert_eq!(name, "@media"); + assert_eq!(syntax, "@media { }"); + } + #[test] + fn test_get_syntax_padding_property() { + let Syntax { name, syntax } = get_syntax(CssType::Property("padding")); + assert_eq!(name, "padding"); + assert_eq!(syntax, "<'padding-top'>{1,4}"); + } + #[test] + fn test_get_syntax_gradient_type() { + let Syntax { name, syntax } = get_syntax_internal(CssType::Type("gradient"), true); + assert_eq!(name, ""); + assert_eq!(syntax, " | | | "); + } + + #[test] + fn test_render_terms() -> Result<(), SyntaxError> { + let renderer = SyntaxRenderer { + locale_str: "en-US", + value_definition_url: "/en-US/docs/Web/CSS/Value_definition_syntax", + syntax_tooltip: &TOOLTIPS, + constituents: Default::default(), + }; + let Syntax { name: _, syntax } = get_syntax_internal(CssType::Type("color_value"), true); + if let Node::Group(group) = parse(&syntax)? { + let rendered = renderer.render_terms(&group.terms, group.combinator)?; + assert_eq!(rendered, " <color-base> |
currentColor |
<system-color>
"); + } else { + panic!("no group node") + } + Ok(()) + } + + #[test] + fn test_render_node() -> Result<(), SyntaxError> { + let expected = "
padding = 
<'padding-top'>{1,4}

<padding-top> =
<length-percentage [0,∞]>

<length-percentage> =
<length> |
<percentage>

"; + let result = write_formal_syntax( + CssType::Property("padding"), + "en-US", + "/en-US/docs/Web/CSS/Value_definition_syntax", + &TOOLTIPS, + )?; + assert_eq!(result, expected); + Ok(()) + } + + #[test] + fn test_render_function() -> Result<(), SyntaxError> { + let expected = "
<hue-rotate()> = 
hue-rotate( [ <angle> | <zero> ]? )

"; + let result = write_formal_syntax( + CssType::Function("hue-rotate"), + "en-US", + "/en-US/docs/Web/CSS/Value_definition_syntax", + &TOOLTIPS, + )?; + assert_eq!(result, expected); + Ok(()) + } +} diff --git a/crates/css-syntax/src/utils.rs b/crates/css-syntax/src/utils.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/crates/css-syntax/src/utils.rs @@ -0,0 +1 @@ + diff --git a/crates/diff-test/Cargo.toml b/crates/diff-test/Cargo.toml new file mode 100644 index 00000000..63890a4a --- /dev/null +++ b/crates/diff-test/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "diff-test" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +anyhow = "1" +clap = { version = "4.5.1", features = ["derive"] } +ignore = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +jsonpath_lib = "0.3.0" +prettydiff = "0.7" +html-minifier = "5" +ansi-to-html = "0.2" +itertools = "0.13" +similar = "2" +regex = "1" +once_cell = "1" diff --git a/crates/diff-test/src/main.rs b/crates/diff-test/src/main.rs new file mode 100644 index 00000000..3a86aa3f --- /dev/null +++ b/crates/diff-test/src/main.rs @@ -0,0 +1,333 @@ +use std::cmp::max; +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use anyhow::{anyhow, Error}; +use clap::{Args, Parser, Subcommand}; +use ignore::types::TypesBuilder; +use ignore::WalkBuilder; +use itertools::Itertools; +use jsonpath_lib::Compiled; +use once_cell::sync::Lazy; +use prettydiff::diff_words; +use regex::Regex; +use serde_json::Value; + +fn html(body: &str) -> String { + format!( + r#" + + + + + + + + +{body} + + +"# + ) +} + +pub(crate) fn walk_builder(path: &Path) -> Result { + let mut types = TypesBuilder::new(); + types.add_def("json:index.json")?; + types.select("json"); + let mut builder = ignore::WalkBuilder::new(path); + builder.types(types.build()?); + Ok(builder) +} + +pub fn gather(path: &Path, selector: Option<&str>) -> Result, Error> { + let template = if let Some(selector) = selector { + Some(Compiled::compile(selector).map_err(|e| anyhow!("{e}"))?) + } else { + None + }; + walk_builder(path)? + .build() + .filter_map(Result::ok) + .filter(|f| f.file_type().map(|ft| ft.is_file()).unwrap_or(false)) + .map(|p| { + let json_str = fs::read_to_string(p.path())?; + let index: Value = serde_json::from_str(&json_str)?; + + let extract = if let Some(template) = &template { + template + .select(&index) + .unwrap_or_default() + .into_iter() + .next() + .cloned() + .unwrap_or(Value::Null) + } else { + index + }; + Ok::<_, Error>((p.path().strip_prefix(path)?.display().to_string(), extract)) + }) + .collect() +} + +use std::path::PathBuf; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + Diff(BuildArgs), +} +#[derive(Args)] +struct BuildArgs { + #[arg(short, long)] + query: Option, + #[arg(short, long)] + out: PathBuf, + root_a: PathBuf, + root_b: PathBuf, + #[arg(long)] + html: bool, + #[arg(long)] + inline: bool, + #[arg(long)] + ignore_html_whitespace: bool, + #[arg(long)] + value: bool, + #[arg(short, long)] + verbose: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PathIndex { + Object(String), + Array(usize), +} + +fn make_key(path: &[PathIndex]) -> String { + path.iter() + .map(|k| match k { + PathIndex::Object(s) => s.to_owned(), + PathIndex::Array(i) => i.to_string(), + }) + .join(".") +} + +fn is_html(s: &str) -> bool { + s.starts_with('<') && s.ends_with('>') +} + +const IGNORE: &[&str] = &["doc.flaws", "blogMeta.readTime"]; +static WS_DIFF: Lazy = Lazy::new(|| Regex::new(r#"(?>)[\n ]+|[\n ]+(?) { + if path.len() == 1 { + if let PathIndex::Object(s) = &path[0] { + if s == "url" { + return; + } + } + } + if lhs != rhs { + match (lhs, rhs) { + (Value::Array(lhs), Value::Array(rhs)) => { + let len = max(lhs.len(), rhs.len()); + for i in 0..len { + let mut path = path.to_vec(); + path.push(PathIndex::Array(i)); + full_diff( + lhs.get(i).unwrap_or(&Value::Null), + rhs.get(i).unwrap_or(&Value::Null), + &path, + diff, + ); + } + } + (Value::Object(lhs), Value::Object(rhs)) => { + let mut keys: HashSet<&String> = HashSet::from_iter(lhs.keys()); + keys.extend(rhs.keys()); + for key in keys { + let mut path = path.to_vec(); + path.push(PathIndex::Object(key.to_string())); + full_diff( + lhs.get(key).unwrap_or(&Value::Null), + rhs.get(key).unwrap_or(&Value::Null), + &path, + diff, + ); + } + } + (Value::String(lhs), Value::String(rhs)) => { + let mut lhs = lhs.to_owned(); + let mut rhs = rhs.to_owned(); + if is_html(&lhs) && is_html(&rhs) { + lhs = html_minifier::minify(WS_DIFF.replace_all(&lhs, "$x$y")).unwrap(); + rhs = html_minifier::minify(WS_DIFF.replace_all(&rhs, "$x$y")).unwrap(); + } + if lhs != rhs { + let key = make_key(path); + if IGNORE.contains(&key.as_str()) { + return; + } + if key != "doc.sidebarHTML" { + diff.insert( + key, + ansi_to_html::convert(&diff_words(&lhs, &rhs).to_string()).unwrap(), + //similar::TextDiff::from_words(&lhs, &rhs) + // .unified_diff() + // .to_string(), + ); + } else { + diff.insert(key, "differs".into()); + } + } + } + (lhs, rhs) => { + let lhs = lhs.to_string(); + let rhs = rhs.to_string(); + if lhs != rhs { + let key = make_key(path); + if IGNORE.contains(&key.as_str()) { + return; + } + diff.insert( + key, + //ansi_to_html::convert( + // &similar::TextDiff::from_words(&lhs, &rhs) + // .unified_diff() + // .to_string(), + //) + //.unwrap(), + ansi_to_html::convert(&diff_words(&lhs, &rhs).to_string()).unwrap(), + ); + } + } + } + } +} + +fn main() -> Result<(), anyhow::Error> { + let cli = Cli::parse(); + + match &cli.command { + Commands::Diff(arg) => { + println!("Gathering everything 🧺"); + let start = std::time::Instant::now(); + let a = gather(&arg.root_a, arg.query.as_deref())?; + let b = gather(&arg.root_b, arg.query.as_deref())?; + + let hits = max(a.len(), b.len()); + let mut same = 0; + if arg.html { + let mut out = Vec::new(); + out.push("
    ".to_string()); + for (k, v) in a.iter() { + if b.get(k) == Some(v) { + same += 1; + continue; + } + + if arg.value { + let left = v; + let right = b.get(k).unwrap_or(&Value::Null); + let mut diff = BTreeMap::new(); + full_diff(left, right, &[], &mut diff); + if !diff.is_empty() { + out.push(format!( + r#"
  • {k}
    {}
  • "#, + serde_json::to_string_pretty(&diff).unwrap_or_default(), + )); + } else { + same += 1; + } + continue; + } else { + let left = &v.as_str().unwrap_or_default(); + let right = b + .get(k) + .unwrap_or(&Value::Null) + .as_str() + .unwrap_or_default(); + let htmls = if arg.ignore_html_whitespace { + let left_html = + html_minifier::minify(WS_DIFF.replace_all(left, "$x$y")).unwrap(); + let right_html = + html_minifier::minify(WS_DIFF.replace_all(right, "$x$y")).unwrap(); + Some((left_html, right_html)) + } else { + None + }; + + let (left, right) = htmls + .as_ref() + .map(|(l, r)| (l.as_str(), r.as_str())) + .unwrap_or((left, right)); + let broken_link = r#" class="page-not-created" title="This is a link to an unwritten page""#; + let left = left.replace(broken_link, ""); + if left == right { + println!("only broken links differ"); + same += 1; + continue; + } + if arg.inline { + println!("{}", diff_words(&left, right)); + } + out.push(format!( + r#"
  • {k}
    {}
    {}
  • "#, + left, right + )) + } + } + out.push("
".to_string()); + let mut file = File::create(&arg.out)?; + file.write_all(html(&out.into_iter().collect::()).as_bytes())?; + } + + println!("Took: {:?} - {same}/{hits}", start.elapsed()); + } + } + Ok(()) +} diff --git a/crates/rari-data/Cargo.toml b/crates/rari-data/Cargo.toml new file mode 100644 index 00000000..a82988f9 --- /dev/null +++ b/crates/rari-data/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "rari-data" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +url = { version = "2", features = ["serde"] } +chrono = { version = "0.4", features = ["serde"] } +indexmap = "2" diff --git a/crates/rari-data/src/baseline.rs b/crates/rari-data/src/baseline.rs new file mode 100644 index 00000000..2198fe13 --- /dev/null +++ b/crates/rari-data/src/baseline.rs @@ -0,0 +1,154 @@ +use std::collections::BTreeMap; +use std::marker::PhantomData; +use std::path::Path; +use std::{fmt, fs}; + +use chrono::NaiveDate; +use serde::de::{self, value, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use url::Url; + +use crate::error::Error; + +pub struct WebFeatures { + pub features: BTreeMap, +} + +impl WebFeatures { + pub fn from_file(path: &Path) -> Result { + let json_str = fs::read_to_string(path)?; + Ok(Self { + features: serde_json::from_str(&json_str)?, + }) + } + + pub fn feature_status(&self, features: &[&str]) -> Option<&SupportStatus> { + if features.is_empty() { + return None; + } + + self.features.values().find_map(|feature_data| { + if let Some(ref status) = feature_data.status { + if feature_data + .compat_features + .iter() + .any(|key| features.contains(&key.as_str())) + { + return Some(status); + } + } + None + }) + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct FeatureData { + /** Alias identifier */ + #[serde( + deserialize_with = "t_or_vec", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub alias: Vec, + /** Specification */ + #[serde( + deserialize_with = "t_or_vec", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub spec: Vec, + /** caniuse.com identifier */ + #[serde( + deserialize_with = "t_or_vec", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub caniuse: Vec, + /** Whether a feature is considered a "baseline" web platform feature and when it achieved that status */ + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, + /** Sources of support data for this feature */ + #[serde( + deserialize_with = "t_or_vec", + default, + skip_serializing_if = "Vec::is_empty" + )] + pub compat_features: Vec, + /** Usage stats */ + #[serde(deserialize_with = "t_or_vec", default)] + pub usage_stats: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum BrowserIdentifier { + Chrome, + ChromeAndroid, + Edge, + Firefox, + FirefoxAndroid, + Safari, + SafariIos, +} + +#[derive(Deserialize, Serialize, Clone, Copy, Debug)] +#[serde(rename_all = "snake_case")] +pub enum BaselineHighLow { + High, + Low, + #[serde(untagged)] + False(bool), +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct SupportStatus { + /// Whether the feature is Baseline (low substatus), Baseline (high substatus), or not (false) + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline: Option, + /// Date the feature achieved Baseline low status + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_low_date: Option, + /// Date the feature achieved Baseline high status + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_high_date: Option, + /// Browser versions that most-recently introduced the feature + pub support: BTreeMap, +} + +pub fn t_or_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct TOrVec(PhantomData); + + impl<'de, T> Visitor<'de> for TOrVec + where + T: Deserialize<'de>, + { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string or list of strings") + } + + fn visit_str(self, s: &str) -> Result + where + E: de::Error, + { + Ok(vec![Deserialize::deserialize( + value::StrDeserializer::new(s), + )?]) + } + + fn visit_seq(self, seq: S) -> Result + where + S: SeqAccess<'de>, + { + Deserialize::deserialize(value::SeqAccessDeserializer::new(seq)) + } + } + + deserializer.deserialize_any(TOrVec::(PhantomData)) +} diff --git a/crates/rari-data/src/error.rs b/crates/rari-data/src/error.rs new file mode 100644 index 00000000..55262c10 --- /dev/null +++ b/crates/rari-data/src/error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + JsonError(#[from] serde_json::Error), +} diff --git a/crates/rari-data/src/lib.rs b/crates/rari-data/src/lib.rs new file mode 100644 index 00000000..02d5c0b5 --- /dev/null +++ b/crates/rari-data/src/lib.rs @@ -0,0 +1,3 @@ +pub mod baseline; +pub mod error; +pub mod specs; diff --git a/crates/rari-data/src/specs.rs b/crates/rari-data/src/specs.rs new file mode 100644 index 00000000..78c93666 --- /dev/null +++ b/crates/rari-data/src/specs.rs @@ -0,0 +1,117 @@ +use std::fs; +use std::path::Path; + +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use crate::error::Error; + +#[derive(Deserialize, Serialize, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub struct Series { + pub shortname: String, + pub current_specification: String, + pub title: String, + pub short_title: String, + pub nightly_url: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub struct Nightly { + pub url: String, + pub status: String, + pub source_path: Option, + pub alternate_urls: Vec, + pub repository: Option, + pub filename: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub struct Group { + pub name: String, + pub url: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub struct Tests { + pub repository: String, + pub test_paths: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub struct WebSpec { + pub url: String, + pub series_composition: String, + pub shortname: String, + pub series: Series, + pub nightly: Option, + pub organization: String, + pub groups: Vec, + pub title: String, + pub source: String, + pub short_title: String, + pub categories: Vec, + pub standing: String, + pub tests: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct WebSpecs { + pub specs: IndexMap, +} + +impl WebSpecs { + pub fn from_file(path: &Path) -> Result { + let json_str = fs::read_to_string(path)?; + let list: Vec = serde_json::from_str(&json_str)?; + Ok(Self { + specs: list + .into_iter() + .map(|spec| (spec.url.clone(), spec)) + .collect(), + }) + } + + pub fn get_spec(&self, url: &str) -> Option<&WebSpec> { + if let Some(spec) = self.specs.get(url) { + return Some(spec); + } + + self.specs.values().find(|spec| { + url.starts_with(&spec.url) + || spec + .nightly + .as_ref() + .map(|nighty| { + url.starts_with(&nighty.url) + || nighty.alternate_urls.iter().any(|s| url.starts_with(s)) + || spec.shortname == spec.series.current_specification + && spec + .series + .nightly_url + .as_ref() + .map(|s| url.starts_with(s)) + .unwrap_or_default() + }) + .unwrap_or_default() + }) + } +} + +#[derive(Debug, Clone, Default)] +pub struct BCDSpecUrls { + pub specs_urls_by_key: IndexMap>, +} + +impl BCDSpecUrls { + pub fn from_file(path: &Path) -> Result { + let json_str = fs::read_to_string(path)?; + Ok(Self { + specs_urls_by_key: serde_json::from_str(&json_str)?, + }) + } +} diff --git a/crates/rari-deps/Cargo.toml b/crates/rari-deps/Cargo.toml new file mode 100644 index 00000000..debd4f78 --- /dev/null +++ b/crates/rari-deps/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rari-deps" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +once_cell = "1" +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +css-syntax-types = { path = "../css-syntax-types" } +reqwest = { version = "0.12", default-features = false, features = [ + "blocking", + "json", + "native-tls", + "gzip", +] } +tar = "0.4" +flate2 = "1" +chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/rari-deps/src/bcd.rs b/crates/rari-deps/src/bcd.rs new file mode 100644 index 00000000..a6a5aca0 --- /dev/null +++ b/crates/rari-deps/src/bcd.rs @@ -0,0 +1,66 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use serde_json::Value; + +use crate::error::DepsError; +use crate::npm::get_package; + +pub fn update_bcd(base_path: &Path) -> Result<(), DepsError> { + if let Some(path) = get_package("@mdn/browser-compat-data", None, base_path)? { + extract_spec_urls(&path)?; + } + get_package("web-specs", None, base_path)?; + Ok(()) +} + +pub fn gather_spec_urls(value: &Value, path: &str, map: &mut HashMap>) { + match &value["__compat"]["spec_url"] { + Value::String(spec_url) => { + map.insert(path.to_string(), vec![spec_url.clone()]); + } + Value::Array(spec_urls) => { + map.insert( + path.to_string(), + spec_urls + .iter() + .filter_map(|s| s.as_str().map(String::from)) + .collect(), + ); + } + _ => {} + }; + if let Value::Object(o) = value { + for (k, v) in o.iter().filter(|(k, _)| *k != "__compat") { + gather_spec_urls( + v, + &format!("{path}{}{k}", if path.is_empty() { "" } else { "." }), + map, + ) + } + } +} + +pub fn extract_spec_urls(package_path: &Path) -> Result<(), DepsError> { + let text = fs::read_to_string(package_path.join("package/data.json"))?; + let json: Value = serde_json::from_str(&text)?; + let mut map: HashMap> = HashMap::new(); + gather_spec_urls(&json, "", &mut map); + let spec_urls_out_path = package_path.join("spec_urls.json"); + fs::write(spec_urls_out_path, serde_json::to_string(&map)?)?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_extract_spec_urls() -> Result<(), DepsError> { + extract_spec_urls(Path::new( + "/Users/fiji/Library/Application Support/rari/@mdn/browser-compat-data/", + ))?; + Ok(()) + } +} diff --git a/crates/rari-deps/src/error.rs b/crates/rari-deps/src/error.rs new file mode 100644 index 00000000..68a6cf5b --- /dev/null +++ b/crates/rari-deps/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DepsError { + #[error(transparent)] + JsonError(#[from] serde_json::Error), + #[error(transparent)] + IoError(#[from] std::io::Error), + #[error(transparent)] + FetchError(#[from] reqwest::Error), + #[error("no version for webref")] + WebRefMissingVersionError, + #[error("no tarball for webref")] + WebRefMissingTarballError, +} diff --git a/crates/rari-deps/src/lib.rs b/crates/rari-deps/src/lib.rs new file mode 100644 index 00000000..70535bf4 --- /dev/null +++ b/crates/rari-deps/src/lib.rs @@ -0,0 +1,5 @@ +pub mod bcd; +pub mod error; +pub mod npm; +pub mod web_features; +pub mod webref_css; diff --git a/crates/rari-deps/src/npm.rs b/crates/rari-deps/src/npm.rs new file mode 100644 index 00000000..39ca4fc9 --- /dev/null +++ b/crates/rari-deps/src/npm.rs @@ -0,0 +1,85 @@ +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use chrono::{DateTime, Duration, Utc}; +use flate2::read::GzDecoder; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tar::Archive; + +use crate::error::DepsError; + +#[derive(Deserialize, Serialize, Default, Debug)] +pub struct Current { + pub latest_last_check: Option>, + pub version: String, +} + +/// Download and unpack an npm package for a given version (defaults to latest). +pub fn get_package( + package: &str, + version: Option<&str>, + out_path: &Path, +) -> Result, DepsError> { + let version = version.unwrap_or("latest"); + let package_path = out_path.join(package); + let last_check_path = package_path.join("last_check.json"); + let now = Utc::now(); + let current = fs::read_to_string(last_check_path) + .ok() + .and_then(|current| serde_json::from_str::(¤t).ok()) + .unwrap_or_default(); + if version != current.version + || version == "latest" + && current.latest_last_check.unwrap_or_default() < now - Duration::days(1) + { + let body: Value = + reqwest::blocking::get(format!("https://registry.npmjs.org/{package}/{version}"))? + .json()?; + + let latest_version = body["version"] + .as_str() + .ok_or(DepsError::WebRefMissingVersionError)?; + let tarball_url = body["dist"]["tarball"] + .as_str() + .ok_or(DepsError::WebRefMissingTarballError)?; + let package_json_path = package_path.join("package").join("package.json"); + let download_update = if package_json_path.exists() { + let json_str = fs::read_to_string(package_json_path)?; + let package_json: Value = serde_json::from_str(&json_str)?; + let current_version = package_json["version"] + .as_str() + .ok_or(DepsError::WebRefMissingVersionError)?; + current_version == latest_version + } else { + true + }; + + if download_update { + if package_path.exists() { + fs::remove_dir_all(&package_path)?; + } + fs::create_dir_all(&package_path)?; + let mut buf = vec![]; + let _ = reqwest::blocking::get(tarball_url)?.read_to_end(&mut buf)?; + let gz = GzDecoder::new(&buf[..]); + let mut ar = Archive::new(gz); + ar.unpack(&package_path)?; + } + + if version == "latest" { + fs::write( + package_path.join("last_check.json"), + serde_json::to_string_pretty(&Current { + version: version.to_string(), + latest_last_check: Some(now), + })?, + )?; + } + if download_update { + return Ok(Some(package_path)); + } + } + Ok(None) +} diff --git a/crates/rari-deps/src/web_features.rs b/crates/rari-deps/src/web_features.rs new file mode 100644 index 00000000..fabfec6a --- /dev/null +++ b/crates/rari-deps/src/web_features.rs @@ -0,0 +1,9 @@ +use std::path::Path; + +use crate::error::DepsError; +use crate::npm::get_package; + +pub fn update_web_features(base_path: &Path) -> Result<(), DepsError> { + get_package("web-features", None, base_path)?; + Ok(()) +} diff --git a/crates/rari-deps/src/webref_css.rs b/crates/rari-deps/src/webref_css.rs new file mode 100644 index 00000000..88801bc1 --- /dev/null +++ b/crates/rari-deps/src/webref_css.rs @@ -0,0 +1,92 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use css_syntax_types::Css; +use serde_json::Value; + +use crate::error::DepsError; +use crate::npm::get_package; + +fn normalize_name(name: &str) -> String { + name.trim_start_matches('<') + .trim_end_matches('>') + .to_string() +} + +fn by_name(values: Vec) -> BTreeMap { + values + .into_iter() + .map(|mut value| { + let name = normalize_name(value["name"].as_str().unwrap()); + if value["descriptors"].is_array() { + let descriptors = + by_name(serde_json::from_value(value["descriptors"].take()).unwrap()); + value["descriptors"] = serde_json::to_value(descriptors).unwrap(); + } + if value["values"].is_array() { + let values = by_name(serde_json::from_value(value["values"].take()).unwrap()); + value["values"] = serde_json::to_value(values).unwrap(); + } + (name, value) + }) + .collect() +} + +fn list_all(folder: &Path) -> Result, DepsError> { + let mut all = Vec::new(); + let files = fs::read_dir(folder.join("package"))?; + + for file in files { + let file = file?; + let path = file.path(); + let file_name = path.file_stem().unwrap().to_string_lossy().to_string(); + + if path.is_file() && path.extension().unwrap() == "json" && file_name != "package" { + let text = fs::read_to_string(&path)?; + let json: Value = serde_json::from_str(&text)?; + all.push((file_name, json)); + } + } + + let parse: BTreeMap = all + .into_iter() + .map(|(name, mut data)| { + data["properties"] = serde_json::to_value(by_name( + serde_json::from_value(data["properties"].take()).unwrap(), + )) + .unwrap(); + data["selectors"] = serde_json::to_value(by_name( + serde_json::from_value(data["selectors"].take()).unwrap(), + )) + .unwrap(); + data["atrules"] = serde_json::to_value(by_name( + serde_json::from_value(data["atrules"].take()).unwrap(), + )) + .unwrap(); + data["values"] = serde_json::to_value(by_name( + serde_json::from_value(data["values"].take()).unwrap(), + )) + .unwrap(); + + if data["warnings"].is_array() { + data["warnings"] = serde_json::to_value(by_name( + serde_json::from_value(data["warnings"].take()).unwrap(), + )) + .unwrap(); + } + (name, serde_json::from_value(data).unwrap()) + }) + .collect(); + + Ok(parse) +} + +pub fn update_webref_css(base_path: &Path) -> Result<(), DepsError> { + if let Some(package_path) = get_package("@webref/css", None, base_path)? { + let webref_css_dest_path = package_path.join("webref_css.json"); + let webref_css = list_all(&package_path)?; + fs::write(webref_css_dest_path, serde_json::to_string(&webref_css)?)?; + } + Ok(()) +} diff --git a/crates/rari-doc/Cargo.toml b/crates/rari-doc/Cargo.toml new file mode 100644 index 00000000..ca61dd36 --- /dev/null +++ b/crates/rari-doc/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "rari-doc" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +serde_yaml = "0.9" +yaml-rust = "0.4" +percent-encoding = "2" +pest = "2" +pest_derive = "2" +regex = "1" +base64 = "0.22" +validator = { version = "0.18", features = ["derive"] } +once_cell = "1" +scraper = { version = "0.19", features = ["deterministic"] } +chrono = { version = "0.4", features = ["serde"] } +lol_html = "1" +html-escape = "0.2" +html5ever = "0.26" +tracing = "0.1" +ignore = "0.4" +crossbeam-channel = "0.5" +rayon = "1" +enum_dispatch = "0.3" +icu_collator = "1" +icu_locid = "1" + +rari-types = { path = "../rari-types" } +rari-md = { path = "../rari-md" } +rari-data = { path = "../rari-data" } +rari-templ-func = { path = "../rari-templ-func" } +rari-l10n = { path = "../rari-l10n" } +css-syntax = { path = "../css-syntax", features = ["rari"] } + +[dev-dependencies] +rari-types = { path = "../rari-types", features = ["testing"] } diff --git a/crates/rari-doc/src/baseline.rs b/crates/rari-doc/src/baseline.rs new file mode 100644 index 00000000..6788b047 --- /dev/null +++ b/crates/rari-doc/src/baseline.rs @@ -0,0 +1,76 @@ +use once_cell::sync::Lazy; +use rari_data::baseline::{SupportStatus, WebFeatures}; +use rari_types::globals::data_dir; +use tracing::warn; + +static WEB_FEATURES: Lazy> = Lazy::new(|| { + let web_features = WebFeatures::from_file( + &data_dir() + .join("web-features") + .join("package") + .join("index.json"), + ); + match web_features { + Ok(web_features) => Some(web_features), + Err(e) => { + warn!("{e:?}"); + None + } + } +}); + +static DISALLOW_LIST: &[&str] = &[ + // https://github.com/web-platform-dx/web-features/blob/cf718ad/feature-group-definitions/async-clipboard.yml + "api.Clipboard.read", + "api.Clipboard.readText", + "api.Clipboard.write", + "api.Clipboard.writeText", + "api.ClipboardEvent", + "api.ClipboardEvent.ClipboardEvent", + "api.ClipboardEvent.clipboardData", + "api.ClipboardItem", + "api.ClipboardItem.ClipboardItem", + "api.ClipboardItem.getType", + "api.ClipboardItem.presentationStyle", + "api.ClipboardItem.types", + "api.Navigator.clipboard", + "api.Permissions.permission_clipboard-read", + // https://github.com/web-platform-dx/web-features/blob/cf718ad/feature-group-definitions/custom-elements.yml + "api.CustomElementRegistry", + "api.CustomElementRegistry.builtin_element_support", + "api.CustomElementRegistry.define", + "api.Window.customElements", + "css.selectors.defined", + "css.selectors.host", + "css.selectors.host-context", + "css.selectors.part", + // https://github.com/web-platform-dx/web-features/blob/cf718ad/feature-group-definitions/input-event.yml + "api.Element.input_event", + "api.InputEvent.InputEvent", + "api.InputEvent.data", + "api.InputEvent.dataTransfer", + "api.InputEvent.getTargetRanges", + "api.InputEvent.inputType", + // https://github.com/web-platform-dx/web-features/issues/1038 + // https://github.com/web-platform-dx/web-features/blob/64d2cfd/features/screen-orientation-lock.dist.yml + "api.ScreenOrientation.lock", + "api.ScreenOrientation.unlock", +]; + +pub fn get_baseline(browser_compat: &[String]) -> Option<&'static SupportStatus> { + if let Some(ref web_features) = *WEB_FEATURES { + if browser_compat.is_empty() { + return None; + } + let filtered_browser_compat = browser_compat.iter().filter_map( + |query| + // temporary blocklist while we wait for per-key baseline statuses + // or another solution to the baseline/bcd table discrepancy problem + if !DISALLOW_LIST.contains(&query.as_str()) { + Some(query.as_str()) + } else {None} + ).collect::>(); + return web_features.feature_status(&filtered_browser_compat); + } + None +} diff --git a/crates/rari-doc/src/build.rs b/crates/rari-doc/src/build.rs new file mode 100644 index 00000000..e273b03f --- /dev/null +++ b/crates/rari-doc/src/build.rs @@ -0,0 +1,66 @@ +use std::fs::{self, File}; +use std::io::BufWriter; +use std::iter::once; + +use rari_types::globals::build_out_root; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use tracing::{error, span, Level}; + +use crate::cached_readers::{blog_files, curriculum_files}; +use crate::docs::build::{build_blog_post, build_curriculum, build_doc, build_dummy}; +use crate::docs::dummy::Dummy; +use crate::docs::page::{Page, PageLike}; +use crate::error::DocError; +use crate::resolve::url_to_path_buf; + +pub fn build_single_page(page: &Page) { + let slug = &page.slug(); + let locale = page.locale(); + let span = span!(Level::ERROR, "ctx", "{}:{}", locale, slug); + let _enter = span.enter(); + let built_page = match page { + Page::Doc(doc) => build_doc(doc), + Page::BlogPost(post) => build_blog_post(post), + Page::Dummy(dummy) => build_dummy(dummy), + Page::Curriculum(curriculum) => build_curriculum(curriculum), + }; + match built_page { + Ok(built_page) => { + let out_path = build_out_root() + .expect("No BUILD_OUT_ROOT") + .join(url_to_path_buf(page.url().trim_start_matches('/'))); + fs::create_dir_all(&out_path).unwrap(); + let out_file = out_path.join("index.json"); + let file = File::create(out_file).unwrap(); + let buffed = BufWriter::new(file); + + serde_json::to_writer(buffed, &built_page).unwrap(); + } + Err(e) => { + error!("Error: {e}"); + } + } +} + +pub fn build_docs(docs: Vec) -> Result<(), DocError> { + docs.into_par_iter() + .for_each(|page| build_single_page(&page)); + Ok(()) +} + +pub fn build_curriculum_pages() -> Result<(), DocError> { + curriculum_files() + .by_path + .iter() + .for_each(|(_, page)| build_single_page(page)); + Ok(()) +} + +pub fn build_blog_pages() -> Result<(), DocError> { + blog_files() + .posts + .values() + .chain(once(&Dummy::from_url("/en-US/blog/").unwrap())) + .for_each(build_single_page); + Ok(()) +} diff --git a/crates/rari-doc/src/cached_readers.rs b/crates/rari-doc/src/cached_readers.rs new file mode 100644 index 00000000..74b6961d --- /dev/null +++ b/crates/rari-doc/src/cached_readers.rs @@ -0,0 +1,233 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::fs::read_to_string; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +use once_cell::sync::{Lazy, OnceCell}; +use rari_types::globals::{blog_root, cache_content, curriculum_root}; +use rari_types::locale::Locale; +use tracing::error; + +use crate::docs::blog::{Author, AuthorFrontmatter, BlogPost, BlogPostBuildMeta}; +use crate::docs::curriculum::{CurriculumIndexEntry, CurriculumPage}; +use crate::docs::page::{Page, PageLike}; +use crate::error::DocError; +use crate::html::sidebar::{MetaSidebar, Sidebar}; +use crate::utils::{root_for_locale, split_fm}; +use crate::walker::{read_docs_parallel, walk_builder}; + +pub static STATIC_PAGE_FILES: OnceCell> = OnceCell::new(); +pub static CACHED_PAGE_FILES: OnceCell>>> = OnceCell::new(); +type SidebarFilesCache = Arc>>>; +pub static CACHED_SIDEBAR_FILES: Lazy = + Lazy::new(|| Arc::new(RwLock::new(HashMap::new()))); +pub static CACHED_CURRICULUM: OnceCell = OnceCell::new(); + +#[derive(Debug, Default, Clone)] +pub struct BlogFiles { + pub posts: HashMap, + pub authors: HashMap>, + pub sorted_meta: Vec, +} +pub static BLOG_FILES: OnceCell = OnceCell::new(); + +#[derive(Debug, Default, Clone)] +pub struct CurriculumFiles { + pub by_url: HashMap, + pub by_path: HashMap, + pub index: Vec, +} + +pub fn read_sidebar(name: &str, locale: Locale) -> Result, DocError> { + let mut file = root_for_locale(locale)?.to_path_buf(); + file.push("sidebars"); + file.push(locale.as_folder_str()); + file.push(name); + file.set_extension("yaml"); + if cache_content() { + if let Some(sidebar) = CACHED_SIDEBAR_FILES.read()?.get(&file) { + return Ok(sidebar.clone()); + } + } + let raw = read_to_string(&file)?; + let sidebar: Sidebar = serde_yaml::from_str(&raw)?; + let sidebar = Arc::new(MetaSidebar::from(sidebar)); + if cache_content() { + CACHED_SIDEBAR_FILES.write()?.insert(file, sidebar.clone()); + } + Ok(sidebar) +} + +pub fn page_from_static_files(path: &Path) -> Option> { + STATIC_PAGE_FILES.get().map(|static_files| { + if let Some(page) = static_files.get(path) { + return Ok(page.clone()); + } + Err(DocError::NotFoundInStaticCache(path.into())) + }) +} + +pub fn gather_blog_posts() -> Result, DocError> { + if let Some(blog_root) = blog_root() { + let post_root = blog_root.join("posts"); + Ok(read_docs_parallel::(&[post_root], None)? + .into_iter() + .map(|page| (page.url().to_ascii_lowercase(), page)) + .collect()) + } else { + Err(DocError::NoBlogRoot) + } +} + +pub fn gather_curriculum() -> Result { + if let Some(curriculum_root) = curriculum_root() { + let curriculum_root = curriculum_root.join("curriculum"); + let pages: Vec = + read_docs_parallel::(&[curriculum_root], Some("*.md"))? + .into_iter() + .collect(); + let by_url: HashMap = pages + .iter() + .cloned() + .map(|page| (page.url().to_ascii_lowercase(), page)) + .collect(); + let mut index: Vec<(PathBuf, CurriculumIndexEntry)> = pages + .iter() + .filter_map(|c| { + if let Page::Curriculum(c) = c { + Some(c) + } else { + None + } + }) + .map(|c| { + ( + c.full_path().to_path_buf(), + CurriculumIndexEntry { + url: c.url().to_string(), + title: c.title().to_string(), + slug: Some(c.slug().to_string()), + children: Vec::new(), + summary: c.meta.summary.clone(), + topic: c.meta.topic, + }, + ) + }) + .collect(); + index.sort_by(|a, b| a.0.cmp(&b.0)); + let index = index.into_iter().map(|(_, entry)| entry).collect(); + + let by_path = pages + .into_iter() + .map(|page| (page.full_path().to_path_buf(), page)) + .collect(); + + Ok(CurriculumFiles { + by_url, + by_path, + index, + }) + } else { + Err(DocError::NoCurriculumRoot) + } +} + +pub fn curriculum_files() -> Cow<'static, CurriculumFiles> { + if cache_content() { + Cow::Borrowed(CACHED_CURRICULUM.get_or_init(|| { + gather_curriculum() + .map_err(|e| error!("{e}")) + .ok() + .unwrap_or_default() + })) + } else { + Cow::Owned( + gather_curriculum() + .map_err(|e| error!("{e}")) + .unwrap_or_default(), + ) + } +} + +pub fn gather_blog_authors() -> Result>, DocError> { + if let Some(blog_authors_path) = blog_root().map(|br| br.join("authors")) { + Ok(walk_builder(&[blog_authors_path], None)? + .build() + .filter_map(|f| f.ok()) + .filter(|f| f.file_type().map(|ft| ft.is_file()).unwrap_or(false)) + .map(|f| { + let path = f.into_path(); + let raw = read_to_string(&path)?; + let (fm, _) = split_fm(&raw); + let frontmatter: AuthorFrontmatter = serde_yaml::from_str(fm.unwrap_or_default())?; + let name = path + .parent() + .and_then(|p| p.file_name()) + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(); + let author = Author { frontmatter, path }; + Ok((name, Arc::new(author))) + }) + .collect::>, DocError>>()?) + } else { + Err(DocError::NoBlogRoot) + } +} + +pub fn blog_files() -> Cow<'static, BlogFiles> { + fn gather() -> BlogFiles { + let posts = gather_blog_posts().unwrap_or_else(|e| { + error!("{e}"); + Default::default() + }); + let authors = gather_blog_authors().unwrap_or_else(|e| { + error!("{e}"); + Default::default() + }); + let mut sorted_meta = posts + .values() + .filter_map(|post| match post { + Page::BlogPost(p) => Some(p.meta.clone()), + _ => None, + }) + .collect::>(); + sorted_meta.sort_by(|a, b| { + if a.date != b.date { + a.date.cmp(&b.date) + } else { + // TODO: use proper order + b.title.cmp(&a.title) + } + }); + BlogFiles { + posts, + authors, + sorted_meta, + } + } + if cache_content() { + Cow::Borrowed(BLOG_FILES.get_or_init(gather)) + } else { + Cow::Owned(gather()) + } +} + +pub fn blog_auhtor_by_name(name: &str) -> Option> { + blog_files().authors.get(name).cloned() +} + +pub fn blog_from_url(url: &str) -> Option { + let _ = blog_root()?; + blog_files().posts.get(&url.to_ascii_lowercase()).cloned() +} + +pub fn curriculum_from_url(url: &str) -> Option { + let _ = curriculum_root()?; + curriculum_files().by_url.get(url).cloned() +} + +pub fn curriculum_from_path(path: &Path) -> Option { + let _ = curriculum_root()?; + curriculum_files().by_path.get(path).cloned() +} diff --git a/crates/rari-doc/src/docs/blog.rs b/crates/rari-doc/src/docs/blog.rs new file mode 100644 index 00000000..3d6a5e30 --- /dev/null +++ b/crates/rari-doc/src/docs/blog.rs @@ -0,0 +1,322 @@ +use std::fs::read_to_string; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use chrono::{NaiveDate, NaiveDateTime}; +use rari_md::m2h; +use rari_types::fm_types::{FeatureStatus, PageType}; +use rari_types::globals::blog_root; +use rari_types::locale::Locale; +use rari_types::RariEnv; +use serde::{Deserialize, Serialize}; + +use super::page::{Page, PageCategory, PageLike, PageReader}; +use super::types::{PrevNextBlog, SlugNTitle}; +use crate::cached_readers::{blog_auhtor_by_name, blog_files}; +use crate::error::DocError; +use crate::resolve::build_url; +use crate::utils::{locale_and_typ_from_path, modified_dt, readtime, split_fm}; + +#[derive(Clone, Debug, Default)] +pub struct Author { + pub frontmatter: AuthorFrontmatter, + pub path: PathBuf, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +pub struct AuthorLink { + pub name: Option, + pub link: Option, + pub avatar_url: Option, +} + +impl AuthorLink { + pub fn from_author(author: &Author, name: &str) -> Self { + AuthorLink { + name: author.frontmatter.name.clone(), + link: author.frontmatter.link.clone(), + avatar_url: author.frontmatter.avatar.as_ref().map(|avatar| { + format!( + "/{}/blog/author/{name}/{avatar}", + Locale::default().as_url_str() + ) + }), + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +pub struct AuthorFrontmatter { + pub name: Option, + pub link: Option, + pub avatar: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(default)] +pub struct AuthorMetadata { + pub name: String, + pub link: Option, + pub avatar_url: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(default)] +pub struct BlogImage { + pub file: String, + pub alt: Option, + pub source: Option, + pub creator: Option, +} + +#[derive(Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct BlogMeta { + pub slug: String, + pub title: String, + pub description: String, + pub image: BlogImage, + #[serde(skip_serializing_if = "String::is_empty")] + pub keywords: String, + pub sponsored: bool, + #[serde(serialize_with = "modified_dt")] + pub date: NaiveDateTime, + pub author: AuthorLink, + #[serde(skip_serializing_if = "PrevNextBlog::is_none")] + pub links: PrevNextBlog, + pub read_time: usize, +} + +#[derive(Serialize, Clone, Debug)] +pub struct BlogPostBuildMeta { + pub slug: String, + pub title: String, + pub description: String, + pub image: BlogImage, + pub keywords: String, + pub sponsored: bool, + pub published: bool, + pub date: NaiveDate, + pub author: String, + pub url: String, + pub full_path: PathBuf, + pub path: PathBuf, + pub read_time: usize, +} + +impl From<&BlogPostBuildMeta> for BlogMeta { + fn from(value: &BlogPostBuildMeta) -> Self { + let BlogPostBuildMeta { + slug, + title, + description, + image, + keywords, + sponsored, + date, + author, + url, + read_time, + .. + } = value.to_owned(); + let links = prev_next(&url); + Self { + slug, + title, + description, + image, + keywords, + sponsored, + date: NaiveDateTime::from(date), + read_time, + author: blog_auhtor_by_name(&author) + .map(|a| AuthorLink::from_author(&a, &author)) + .unwrap_or(AuthorLink { + name: Some(author), + ..Default::default() + }), + links, + } + } +} + +impl BlogPostBuildMeta { + pub fn from_fm( + fm: BlogPostFrontmatter, + full_path: impl Into, + read_time: usize, + ) -> Result { + let full_path = full_path.into(); + let BlogPostFrontmatter { + slug, + title, + description, + image, + keywords, + sponsored, + published, + date, + author, + } = fm; + let (locale, _) = locale_and_typ_from_path(&full_path) + .unwrap_or((Default::default(), PageCategory::BlogPost)); + let url = build_url(&slug, &locale, PageCategory::BlogPost); + let path = full_path + .strip_prefix(blog_root().ok_or(DocError::NoBlogRoot)?)? + .to_path_buf(); + Ok(Self { + url, + slug, + title, + description, + image, + keywords, + sponsored, + published, + date, + author, + full_path, + path, + read_time, + }) + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(default)] +pub struct BlogPostFrontmatter { + pub slug: String, + pub title: String, + pub description: String, + pub image: BlogImage, + pub keywords: String, + pub sponsored: bool, + pub published: bool, + pub date: NaiveDate, + pub author: String, +} + +#[derive(Debug, Clone)] +pub struct BlogPost { + pub meta: BlogPostBuildMeta, + raw: String, + content_start: usize, +} + +impl PageReader for BlogPost { + fn read(path: impl Into) -> Result { + read_blog_post(path).map(Arc::new).map(Page::BlogPost) + } +} + +impl PageLike for BlogPost { + fn url(&self) -> &str { + &self.meta.url + } + + fn slug(&self) -> &str { + &self.meta.slug + } + + fn title(&self) -> &str { + &self.meta.title + } + + fn short_title(&self) -> Option<&str> { + None + } + + fn locale(&self) -> Locale { + Locale::EnUs + } + + fn content(&self) -> &str { + &self.raw[self.content_start..] + } + + fn rari_env(&self) -> Option> { + Some(RariEnv { + url: &self.meta.url, + locale: Default::default(), + title: &self.meta.title, + tags: &[], + browser_compat: &[], + spec_urls: &[], + page_type: PageType::BlogPost, + slug: &self.meta.slug, + }) + } + + fn render(&self) -> Result { + Ok(m2h(self.content(), Locale::EnUs)?) + } + + fn title_suffix(&self) -> Option<&str> { + Some("MDN Blog") + } + + fn page_type(&self) -> PageType { + PageType::BlogPost + } + + fn status(&self) -> &[FeatureStatus] { + &[] + } + + fn full_path(&self) -> &Path { + &self.meta.full_path + } + + fn path(&self) -> &Path { + &self.meta.path + } + + fn base_slug(&self) -> &str { + "/en-US/" + } + + fn trailing_slash(&self) -> bool { + true + } +} + +fn read_blog_post(path: impl Into) -> Result { + let full_path = path.into(); + let raw = read_to_string(&full_path)?; + let (fm, content_start) = split_fm(&raw); + let fm = fm.ok_or(DocError::NoFrontmatter)?; + let fm: BlogPostFrontmatter = serde_yaml::from_str(fm)?; + + let read_time = readtime(&raw[content_start..]); + Ok(BlogPost { + meta: BlogPostBuildMeta::from_fm(fm, full_path, read_time)?, + raw, + content_start, + }) +} + +fn prev_next(url: &str) -> PrevNextBlog { + let sorted_meta = &blog_files().sorted_meta; + if let Some(i) = sorted_meta.iter().position(|m| m.url == url) { + PrevNextBlog { + previous: if i > 0 { + sorted_meta.get(i - 1).map(|m| SlugNTitle { + slug: m.slug.clone(), + title: m.title.clone(), + }) + } else { + None + }, + next: if i < sorted_meta.len() - 1 { + sorted_meta.get(i + 1).map(|m| SlugNTitle { + slug: m.slug.clone(), + title: m.title.clone(), + }) + } else { + None + }, + } + } else { + Default::default() + } +} diff --git a/crates/rari-doc/src/docs/build.rs b/crates/rari-doc/src/docs/build.rs new file mode 100644 index 00000000..b659478a --- /dev/null +++ b/crates/rari-doc/src/docs/build.rs @@ -0,0 +1,300 @@ +use rari_types::fm_types::PageType; +use rari_types::globals::{base_url, content_branch, git_history, popularities}; +use rari_types::locale::Locale; +use scraper::Html; + +use super::blog::BlogPost; +use super::curriculum::{ + build_landing_modules, build_overview_modules, build_sidebar, curriculum_group, + prev_next_modules, prev_next_overview, CurriculumPage, Template, +}; +use super::doc::{render_md_to_html, Doc}; +use super::dummy::Dummy; +use super::json::{ + BuiltDocy, Compat, JsonBlogPost, JsonBlogPostDoc, JsonCurriculum, JsonDoADoc, JsonDoc, Prose, + Section, Source, SpecificationSection, TocEntry, +}; +use super::page::PageLike; +use super::parents::parents; +use super::sections::{split_sections, BuildSection, BuildSectionType}; +use super::title::{page_title, transform_title}; +use crate::baseline::get_baseline; +use crate::error::DocError; +use crate::html::rewriter::post_process_html; +use crate::html::sidebar::render_sidebar; +use crate::specs::extract_specifications; +use crate::templ::render::render; + +impl<'a> From> for Section { + fn from(value: BuildSection) -> Self { + match value.typ { + BuildSectionType::Prose | BuildSectionType::Unknown => Self::Prose(Prose { + title: value.heading.map(|h| h.inner_html()), + content: value.body.join("\n"), + is_h3: value.is_h3, + id: value.id, + }), + BuildSectionType::Specification => { + let title = value + .heading + .map(|h| h.inner_html()) + .or_else(|| value.query.clone()); + let id = value.id.or_else(|| value.query.clone()); + let specifications = extract_specifications( + &value + .query + .as_ref() + .map(|q| { + q.split(',') + .map(String::from) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .unwrap_or_default(), + &value + .spec_urls + .map(|q| { + q.split(',') + .map(String::from) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .unwrap_or_default(), + ); + let query = value.query.unwrap_or_default(); + let query = if query.is_empty() { + "undefined".to_string() + } else { + query + }; + Self::Specifications(SpecificationSection { + id, + title, + is_h3: value.is_h3, + specifications, + query, + content: if value.body.is_empty() { + None + } else { + Some(value.body.join("\n")) + }, + }) + } + BuildSectionType::Compat => { + let title = value + .heading + .map(|h| h.inner_html()) + .or_else(|| value.query.clone()); + let id = value.id.or_else(|| value.query.clone()); + Self::BrowserCompatibility(Compat { + title, + is_h3: value.is_h3, + id, + query: value.query.unwrap_or_default(), + content: if value.body.is_empty() { + None + } else { + Some(value.body.join("\n")) + }, + }) + } + } + } +} + +impl<'a> BuildSection<'a> { + pub fn make_toc_entry(&self, with_h3: bool) -> Option { + let id = self.id.clone(); + let text = self.heading.map(|h| h.inner_html()); + if let (Some(id), Some(text)) = (id, text) { + if !self.is_h3 || with_h3 { + return Some(TocEntry { id, text }); + } + } + None + } +} + +pub struct PageContent { + body: Vec
, + toc: Vec, + summary: Option, +} + +pub fn make_toc(sections: &[BuildSection], with_h3: bool) -> Vec { + sections + .iter() + .filter_map(|section| section.make_toc_entry(with_h3)) + .collect() +} + +pub fn build_content(doc: &T) -> Result { + let html = render_md_to_html( + doc.content(), + doc.locale(), + Some(&doc.full_path().display()), + )?; + let ks_rendered_doc = if let Some(rari_env) = &doc.rari_env() { + render(rari_env, &html)? + } else { + html + }; + let post_processed_html = post_process_html(&ks_rendered_doc, doc, false)?; + let fragment = Html::parse_fragment(&post_processed_html); + let (sections, summary) = split_sections(&fragment).expect("DOOM"); + let toc = make_toc(§ions, matches!(doc.page_type(), PageType::Curriculum)); + let body = sections.into_iter().map(Into::into).collect(); + Ok(PageContent { body, toc, summary }) +} + +pub fn build_doc(doc: &Doc) -> Result { + let PageContent { body, toc, summary } = build_content(doc)?; + let sidebar_html = render_sidebar(doc)?; + let baseline = get_baseline(&doc.meta.browser_compat); + let folder = doc + .meta + .path + .parent() + .unwrap_or(&doc.meta.path) + .to_path_buf(); + let filename = doc + .meta + .path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let history = git_history().get(&doc.meta.path); + let modified = history.map(|entry| entry.modified).unwrap_or_default(); + let short_title = doc + .short_title() + .map(String::from) + .unwrap_or(transform_title(doc.title()).to_string()); + + let github_url = format!( + "https://github.com/mdn/{}/blob/{}/files/{}", + if doc.locale() == Locale::default() { + "content" + } else { + "translated-content" + }, + content_branch(), + doc.meta.path.display() + ); + + let last_commit_url = format!( + "https://github.com/mdn/{}/commit/{}", + if doc.locale() == Locale::default() { + "content" + } else { + "translated-content" + }, + history.map(|entry| entry.hash.as_str()).unwrap_or_default() + ); + + let popularity = popularities().popularities.get(doc.url()).cloned(); + + Ok(BuiltDocy::Doc(Box::new(JsonDoADoc { + doc: JsonDoc { + title: doc.title().to_string(), + is_markdown: true, + locale: doc.locale(), + native: doc.locale().into(), + mdn_url: doc.meta.url.clone(), + is_translated: doc.meta.locale != Locale::default(), + short_title, + is_active: true, + parents: parents(doc), + page_title: page_title(doc, true)?, + body, + sidebar_html, + toc, + baseline, + modified, + summary, + popularity, + sidebar_macro: doc.meta.sidebar.first().cloned(), + source: Source { + folder, + filename, + github_url, + last_commit_url, + }, + browser_compat: doc.meta.browser_compat.clone(), + ..Default::default() + }, + url: doc.meta.url.clone(), + ..Default::default() + }))) +} + +pub fn build_blog_post(post: &BlogPost) -> Result { + let PageContent { body, toc, .. } = build_content(post)?; + Ok(BuiltDocy::BlogPost(Box::new(JsonBlogPost { + doc: JsonBlogPostDoc { + title: post.title().to_string(), + mdn_url: post.url().to_owned(), + native: post.locale().into(), + page_title: page_title(post, true)?, + locale: post.locale(), + body, + toc, + summary: Some(post.meta.description.clone()), + ..Default::default() + }, + url: post.url().to_owned(), + locale: post.locale(), + blog_meta: Some((&post.meta).into()), + page_title: page_title(post, false)?, + image: Some(format!( + "{}{}{}", + base_url(), + post.url(), + post.meta.image.file + )), + ..Default::default() + }))) +} + +pub fn build_dummy(dummy: &Dummy) -> Result { + dummy.as_built_doc() +} + +pub fn build_curriculum(curriculum: &CurriculumPage) -> Result { + let PageContent { body, toc, .. } = build_content(curriculum)?; + let sidebar = build_sidebar().ok(); + let parents = parents(curriculum); + let group = curriculum_group(&parents); + let modules = match curriculum.meta.template { + Template::Overview => build_overview_modules(curriculum.slug())?, + Template::Landing => build_landing_modules()?, + _ => Default::default(), + }; + let prev_next = match curriculum.meta.template { + Template::Module => prev_next_modules(curriculum.slug())?, + Template::Overview => prev_next_overview(curriculum.slug())?, + _ => None, + }; + Ok(BuiltDocy::Curriculum(Box::new(JsonCurriculum { + doc: super::json::JsonCurriculumDoc { + title: curriculum.title().to_string(), + locale: curriculum.locale(), + native: curriculum.locale().into(), + mdn_url: curriculum.meta.url.clone(), + parents, + page_title: page_title(curriculum, true)?, + summary: curriculum.meta.summary.clone(), + body, + sidebar, + toc, + group, + modules, + prev_next, + topic: Some(curriculum.meta.topic), + ..Default::default() + }, + url: curriculum.url().to_owned(), + page_title: page_title(curriculum, false)?, + locale: curriculum.locale(), + }))) +} diff --git a/crates/rari-doc/src/docs/curriculum.rs b/crates/rari-doc/src/docs/curriculum.rs new file mode 100644 index 00000000..0f9b3290 --- /dev/null +++ b/crates/rari-doc/src/docs/curriculum.rs @@ -0,0 +1,395 @@ +use std::fs::{self, read_to_string}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use once_cell::sync::Lazy; +use rari_types::fm_types::{FeatureStatus, PageType}; +use rari_types::globals::curriculum_root; +use rari_types::locale::Locale; +use rari_types::RariEnv; +use regex::Regex; +use serde::{Deserialize, Serialize}; + +use super::json::Parent; +use super::page::{Page, PageLike, PageReader}; +use super::types::{PrevNextBlog, PrevNextCurriculum, UrlNTitle}; +use crate::cached_readers::{curriculum_files, curriculum_from_path}; +use crate::error::DocError; +use crate::utils::{as_null, split_fm}; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum Template { + Module, + Overview, + Landing, + About, + #[default] + #[serde(other)] + Default, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, Default)] +pub enum Topic { + #[serde(rename = "Web Standards & Semantics")] + WebStandards, + Styling, + Scripting, + #[serde(rename = "Best Practices")] + BestPractices, + Tooling, + #[default] + #[serde(other, serialize_with = "as_null", untagged)] + None, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurriculumSidebarEntry { + pub url: String, + pub title: String, + pub slug: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurriculumIndexEntry { + pub url: String, + pub title: String, + pub slug: Option, + pub summary: Option, + pub topic: Topic, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub children: Vec, +} + +#[derive(Clone, Debug, Deserialize, Default)] +#[serde(default)] +pub struct CurriculumFrontmatter { + pub summary: Option, + pub template: Template, + pub topic: Topic, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurriculumBuildMeta { + pub url: String, + pub title: String, + pub slug: String, + pub summary: Option, + pub template: Template, + pub topic: Topic, + pub filename: PathBuf, + pub full_path: PathBuf, + pub path: PathBuf, + pub group: Option, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurriculumMeta { + pub url: String, + pub title: String, + pub slug: Option, + pub summary: Option, + pub template: Template, + pub topic: Topic, + pub filename: PathBuf, + pub sidebar: Vec, + pub modules: Vec, + pub parents: Vec, + pub prev_next: PrevNextBlog, + pub group: Option, +} + +#[derive(Debug, Clone)] +pub struct CurriculumPage { + pub meta: CurriculumBuildMeta, + raw_content: String, +} + +impl PageReader for CurriculumPage { + fn read(path: impl Into) -> Result { + let path = path.into(); + + let page = read_curriculum_page(path) + .map(Arc::new) + .map(Page::Curriculum)?; + Ok(page) + } +} + +static TITLE_RE: Lazy = Lazy::new(|| Regex::new(r#"^[\w\n]*#\s+(.*)\n"#).unwrap()); +static SLUG_RE: Lazy = Lazy::new(|| Regex::new(r#"(\d+-|\.md$|\/0?-?README)"#).unwrap()); + +fn curriculum_file_to_slug(file: &Path) -> String { + SLUG_RE.replace_all(&file.to_string_lossy(), "").to_string() +} + +pub fn curriculum_group(parents: &[Parent]) -> Option { + if parents.len() > 1 { + if let Some(group) = parents.get(parents.len() - 2) { + if group.title.ends_with("modules") { + return Some(group.title.to_string()); + } + } + }; + None +} + +pub fn relative_file_to_curriculum_page( + base_file: &Path, + relative_file: &str, +) -> Result { + let mut path = base_file + .parent() + .ok_or(DocError::NoParent(base_file.to_path_buf()))? + .to_path_buf() + .join(relative_file); + if path.is_dir() { + path = path.join("0-README.md"); + } + let path = fs::canonicalize(path)?; + curriculum_from_path(&path).ok_or(DocError::PageNotFound( + path.to_string_lossy().to_string(), + super::page::PageCategory::Curriculum, + )) +} + +fn read_curriculum_page(path: impl Into) -> Result { + let full_path = path.into(); + let raw = read_to_string(&full_path)?; + let (fm, content_start) = split_fm(&raw); + let fm = fm.ok_or(DocError::NoFrontmatter)?; + + let raw_content = &raw[content_start..]; + let filename = full_path + .strip_prefix(curriculum_root().ok_or(DocError::NoCurriculumRoot)?)? + .to_owned(); + let slug = curriculum_file_to_slug(&filename); + let url = format!("/{}/{slug}/", Locale::default().as_url_str()); + let (title, line) = TITLE_RE + .captures(raw_content) + .map(|cap| (cap[1].to_owned(), cap[0].to_owned())) + .ok_or(DocError::NoH1)?; + let raw_content = raw_content.replacen(&line, "", 1); + let CurriculumFrontmatter { + summary, + template, + topic, + } = serde_yaml::from_str(fm)?; + let path = full_path + .strip_prefix(curriculum_root().ok_or(DocError::NoCurriculumRoot)?)? + .to_path_buf(); + let meta = CurriculumBuildMeta { + url, + title, + slug, + summary, + template, + topic, + filename, + full_path, + path, + group: None, + }; + Ok(CurriculumPage { meta, raw_content }) +} + +impl PageLike for CurriculumPage { + fn url(&self) -> &str { + &self.meta.url + } + + fn slug(&self) -> &str { + &self.meta.slug + } + + fn title(&self) -> &str { + &self.meta.title + } + + fn short_title(&self) -> Option<&str> { + None + } + + fn locale(&self) -> Locale { + Default::default() + } + + fn content(&self) -> &str { + &self.raw_content + } + + fn rari_env(&self) -> Option> { + None + } + + fn render(&self) -> Result { + todo!() + } + + fn title_suffix(&self) -> Option<&str> { + Some("MDN Curriculum") + } + + fn page_type(&self) -> PageType { + PageType::Curriculum + } + + fn status(&self) -> &[FeatureStatus] { + &[] + } + + fn full_path(&self) -> &Path { + &self.meta.full_path + } + + fn path(&self) -> &Path { + &self.meta.path + } + + fn base_slug(&self) -> &str { + "/en-US/" + } + + fn trailing_slash(&self) -> bool { + true + } +} + +pub fn build_sidebar() -> Result, DocError> { + let mut sidebar: Vec<(PathBuf, CurriculumSidebarEntry)> = curriculum_files() + .by_path + .values() + .map(|c| { + ( + c.full_path().to_path_buf(), + CurriculumSidebarEntry { + url: c.url().to_string(), + title: c.title().to_string(), + slug: c.slug().to_string(), + children: Vec::new(), + }, + ) + }) + .collect(); + sidebar.sort_by(|a, b| a.0.cmp(&b.0)); + let sidebar = sidebar.into_iter().fold( + Vec::new(), + |mut acc: Vec, (_, entry)| { + let lvl = entry.slug.split('/').count(); + if lvl > 2 { + if let Some(last) = acc.last_mut() { + last.children.push(entry); + return acc; + } + } + + acc.push(entry); + acc + }, + ); + + Ok(sidebar) +} + +pub fn build_landing_modules() -> Result, DocError> { + Ok(grouped_index()? + .iter() + .filter(|m| !m.children.is_empty()) + .cloned() + .collect()) +} + +pub fn build_overview_modules(slug: &str) -> Result, DocError> { + Ok(grouped_index()? + .iter() + .filter_map(|m| { + if m.slug.as_deref() == Some(slug) { + Some(m.children.clone()) + } else { + None + } + }) + .flatten() + .collect()) +} + +pub fn prev_next_modules(slug: &str) -> Result, DocError> { + let index = &curriculum_files().index; + let i = index + .iter() + .position(|entry| entry.slug.as_deref() == Some(slug)); + prev_next(index, i) +} + +pub fn prev_next_overview(slug: &str) -> Result, DocError> { + let index: Vec<_> = grouped_index()? + .into_iter() + .filter_map(|entry| { + if entry.children.is_empty() { + None + } else { + Some(entry) + } + }) + .collect(); + let i = index + .iter() + .position(|entry| entry.slug.as_deref() == Some(slug)); + prev_next(&index, i) +} + +pub fn prev_next( + index: &[CurriculumIndexEntry], + i: Option, +) -> Result, DocError> { + Ok(i.map(|i| match i { + 0 => PrevNextCurriculum { + prev: None, + next: index.get(1).map(|entry| UrlNTitle { + title: entry.title.clone(), + url: entry.url.clone(), + }), + }, + i if i == index.len() => PrevNextCurriculum { + prev: index.get(i - 1).map(|entry| UrlNTitle { + title: entry.title.clone(), + url: entry.url.clone(), + }), + next: None, + }, + + i => PrevNextCurriculum { + prev: index.get(i - 1).map(|entry| UrlNTitle { + title: entry.title.clone(), + url: entry.url.clone(), + }), + next: index.get(i + 1).map(|entry| UrlNTitle { + title: entry.title.clone(), + url: entry.url.clone(), + }), + }, + })) +} + +fn grouped_index() -> Result, DocError> { + Ok(curriculum_files().index.iter().fold( + Vec::new(), + |mut acc: Vec, entry| { + let lvl = entry.slug.as_deref().unwrap_or_default().split('/').count(); + if lvl > 2 { + if let Some(last) = acc.last_mut() { + last.children.push(entry.clone()); + return acc; + } + } + + acc.push(entry.clone()); + acc + }, + )) +} diff --git a/crates/rari-doc/src/docs/doc.rs b/crates/rari-doc/src/docs/doc.rs new file mode 100644 index 00000000..1b133fae --- /dev/null +++ b/crates/rari-doc/src/docs/doc.rs @@ -0,0 +1,288 @@ +use std::collections::HashMap; +use std::fmt::Display; +use std::fs::read_to_string; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use rari_md::m2h; +use rari_types::fm_types::{FeatureStatus, PageType}; +use rari_types::globals::deny_warnings; +use rari_types::locale::Locale; +use rari_types::RariEnv; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value; +use tracing::warn; +use validator::Validate; + +use super::page::{Page, PageCategory, PageLike, PageReader}; +use crate::cached_readers::{page_from_static_files, CACHED_PAGE_FILES}; +use crate::error::DocError; +use crate::resolve::build_url; +use crate::templ::parser::{decode_ks, encode_ks}; +use crate::utils::{locale_and_typ_from_path, root_for_locale, split_fm, t_or_vec}; + +/* + "attribute-order": [ + "title", + "short-title", + "slug", + "page-type", + "status", + "browser-compat", + "spec-urls" + ] +*/ + +#[derive(Deserialize, Serialize, Clone, Debug, Default, Validate)] +#[serde(default)] +pub struct FrontMatter { + #[validate(length(max = 120))] + pub title: String, + #[serde(rename = "short-title")] + #[validate(length(max = 60))] + pub short_title: Option, + #[serde(default)] + pub tags: Vec, + pub slug: String, + #[serde(rename = "page-type")] + pub page_type: PageType, + #[serde(deserialize_with = "t_or_vec", default)] + pub status: Vec, + #[serde(rename = "browser-compat", deserialize_with = "t_or_vec", default)] + pub browser_compat: Vec, + #[serde(rename = "spec-urls", deserialize_with = "t_or_vec", default)] + pub spec_urls: Vec, + pub original_slug: Option, + #[serde(deserialize_with = "t_or_vec", default)] + pub sidebar: Vec, + #[serde(flatten)] + pub other: HashMap, +} + +#[derive(Debug, Clone)] +pub struct Meta { + pub title: String, + pub short_title: Option, + pub tags: Vec, + pub slug: String, + pub page_type: PageType, + pub status: Vec, + pub browser_compat: Vec, + pub spec_urls: Vec, + pub original_slug: Option, + pub sidebar: Vec, + pub locale: Locale, + pub full_path: PathBuf, + pub path: PathBuf, + pub url: String, +} + +#[derive(Debug, Clone)] +pub struct Doc { + pub meta: Meta, + raw: String, + content_start: usize, +} + +pub type ADoc = Arc; + +impl PageReader for Doc { + fn read(path: impl Into) -> Result { + let path = path.into(); + if let Some(doc) = page_from_static_files(&path) { + return doc; + } + + if let Some(cache) = CACHED_PAGE_FILES.get() { + if let Some(doc) = cache.read()?.get(&path) { + return Ok(doc.clone()); + } + } + let page = read_doc(&path).map(Arc::new).map(Page::Doc)?; + if let Some(cache) = CACHED_PAGE_FILES.get() { + if let Ok(mut cache) = cache.write() { + cache.insert(path, page.clone()); + } + } + Ok(page) + } +} + +impl PageLike for Doc { + fn url(&self) -> &str { + &self.meta.url + } + + fn slug(&self) -> &str { + &self.meta.slug + } + + fn title(&self) -> &str { + &self.meta.title + } + + fn short_title(&self) -> Option<&str> { + self.meta.short_title.as_deref() + } + + fn locale(&self) -> Locale { + self.meta.locale + } + + fn content(&self) -> &str { + &self.raw[self.content_start..] + } + + fn rari_env(&self) -> Option> { + Some(RariEnv { + url: &self.meta.url, + locale: self.meta.locale, + title: &self.meta.title, + tags: &self.meta.tags, + browser_compat: &self.meta.browser_compat, + spec_urls: &self.meta.spec_urls, + page_type: self.meta.page_type, + slug: &self.meta.slug, + }) + } + + fn render(&self) -> Result { + Ok(m2h(self.content(), self.meta.locale)?) + } + + fn title_suffix(&self) -> Option<&str> { + Some("MDN") + } + + fn page_type(&self) -> PageType { + self.meta.page_type + } + + fn status(&self) -> &[FeatureStatus] { + &self.meta.status + } + + fn full_path(&self) -> &Path { + &self.meta.full_path + } + + fn path(&self) -> &Path { + &self.meta.path + } + + fn base_slug(&self) -> &str { + "/docs" + } + + fn trailing_slash(&self) -> bool { + false + } +} + +fn read_doc(path: impl Into) -> Result { + let full_path = path.into(); + let (locale, _) = locale_and_typ_from_path(&full_path)?; + let raw = read_to_string(&full_path)?; + let (fm, content_start) = split_fm(&raw); + let fm = fm.ok_or(DocError::NoFrontmatter)?; + let FrontMatter { + title, + short_title, + tags, + slug, + page_type, + status, + browser_compat, + spec_urls, + original_slug, + sidebar, + .. + } = serde_yaml::from_str(fm)?; + let url = build_url(&slug, &locale, PageCategory::Doc); + let path = full_path + .strip_prefix(root_for_locale(locale)?)? + .to_path_buf(); + + Ok(Doc { + meta: Meta { + title, + short_title, + tags, + slug, + page_type, + status, + browser_compat, + spec_urls, + original_slug, + sidebar, + locale, + full_path, + path, + url, + }, + raw, + content_start, + }) +} + +pub fn render_md_to_html( + input: &str, + locale: Locale, + path: Option<&impl Display>, +) -> Result { + let (encoded, before) = encode_ks(input)?; + let encoded_html = m2h(&encoded, locale)?; + let (html, after) = decode_ks(&encoded_html)?; + if before != after { + if deny_warnings() { + return Err(DocError::InvalidTempl( + path.map(|s| s.to_string()).unwrap_or_default(), + )); + } + warn!( + "invalid templ: {}", + path.map(|s| s.to_string()).unwrap_or_default() + ); + } + + Ok(html) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn feature_status_test() { + let fm = r#" + status: + - non-standard + - experimental + "#; + let meta = serde_yaml::from_str::(fm).unwrap(); + assert_eq!(meta.status.len(), 2); + + let fm = r#" + status: experimental + "#; + let meta = serde_yaml::from_str::(fm).unwrap(); + assert_eq!(meta.status.len(), 1); + } + + #[test] + fn browser_compat_test() { + let fm = r#" + browser-compat: + - foo + - ba + "#; + let meta = serde_yaml::from_str::(fm).unwrap(); + assert_eq!(meta.browser_compat.len(), 2); + + let fm = r#" + browser-compat: foo + "#; + let meta = serde_yaml::from_str::(fm).unwrap(); + assert_eq!(meta.browser_compat.len(), 1); + } +} diff --git a/crates/rari-doc/src/docs/dummy.rs b/crates/rari-doc/src/docs/dummy.rs new file mode 100644 index 00000000..44270961 --- /dev/null +++ b/crates/rari-doc/src/docs/dummy.rs @@ -0,0 +1,170 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use rari_types::fm_types::{FeatureStatus, PageType}; +use rari_types::locale::Locale; +use rari_types::RariEnv; +use serde::Serialize; + +use super::blog::BlogMeta; +use super::json::{BuiltDocy, HyData, JsonBlogPost, JsonBlogPostDoc}; +use super::page::{Page, PageLike, PageReader}; +use super::title::page_title; +use crate::cached_readers::blog_files; +use crate::error::DocError; + +#[derive(Debug, Clone, Serialize)] +pub struct BlogIndex { + pub posts: Vec, +} + +#[derive(Debug, Clone)] +pub enum DummyData { + BlogIndex(BlogIndex), +} + +#[derive(Debug, Clone)] +pub struct Dummy { + pub title: String, + pub slug: String, + pub url: String, + pub locale: Locale, + pub content: String, + pub page_type: PageType, + pub path: PathBuf, + pub typ: Option, + pub base_slug: String, +} + +impl Dummy { + pub fn from_url(url: &str) -> Option { + match url { + "/en-US/blog/" | "/en-us/blog/" => Some(Page::Dummy(Arc::new(Dummy { + title: "MDN Blog".to_string(), + slug: "blog".to_string(), + url: "/en-US/blog/".to_string(), + locale: Locale::EnUs, + content: Default::default(), + page_type: PageType::Dummy, + path: PathBuf::new(), + typ: Some(DummyData::BlogIndex(BlogIndex { + posts: blog_files() + .sorted_meta + .iter() + .rev() + .map(BlogMeta::from) + .map(|mut m| { + m.links = Default::default(); + m + }) + .collect(), + })), + base_slug: "/en-US/".to_string(), + }))), + _ => None, + } + } + pub fn from_sulg(slug: &str, locale: Locale) -> Page { + Dummy::from_url(match (slug, locale) { + ("blog" | "blog/", Locale::EnUs) => "/en-US/blog/", + _ => "", + }) + .unwrap() + } + + pub fn as_built_doc(&self) -> Result { + match self.url() { + "/en-US/blog/" | "/en-us/blog/" => Ok(BuiltDocy::BlogPost(Box::new(JsonBlogPost { + doc: JsonBlogPostDoc { + title: self.title().to_string(), + mdn_url: self.url().to_owned(), + native: self.locale().into(), + page_title: page_title(self, true)?, + locale: self.locale(), + ..Default::default() + }, + url: self.url().to_owned(), + locale: self.locale(), + blog_meta: None, + hy_data: self + .typ + .as_ref() + .map(|DummyData::BlogIndex(b)| HyData::BlogIndex(b.clone())), + page_title: self.title().to_owned(), + ..Default::default() + }))), + url => Err(DocError::PageNotFound( + url.to_string(), + super::page::PageCategory::Dummy, + )), + } + } +} + +impl PageReader for Dummy { + fn read(_: impl Into) -> Result { + todo!() + } +} + +impl PageLike for Dummy { + fn url(&self) -> &str { + &self.url + } + + fn slug(&self) -> &str { + &self.slug + } + + fn title(&self) -> &str { + &self.title + } + + fn short_title(&self) -> Option<&str> { + None + } + + fn locale(&self) -> Locale { + self.locale + } + + fn content(&self) -> &str { + &self.content + } + + fn rari_env(&self) -> Option> { + None + } + + fn render(&self) -> Result { + todo!() + } + + fn title_suffix(&self) -> Option<&str> { + Some("MDN") + } + + fn page_type(&self) -> PageType { + self.page_type + } + + fn status(&self) -> &[FeatureStatus] { + &[] + } + + fn full_path(&self) -> &Path { + &self.path + } + + fn path(&self) -> &Path { + &self.path + } + + fn base_slug(&self) -> &str { + &self.base_slug + } + + fn trailing_slash(&self) -> bool { + self.url().ends_with('/') + } +} diff --git a/crates/rari-doc/src/docs/json.rs b/crates/rari-doc/src/docs/json.rs new file mode 100644 index 00000000..28f4ca9e --- /dev/null +++ b/crates/rari-doc/src/docs/json.rs @@ -0,0 +1,218 @@ +use std::path::PathBuf; + +use chrono::NaiveDateTime; +use rari_data::baseline::SupportStatus; +use rari_types::locale::{Locale, Native}; +use serde::Serialize; + +use super::blog::BlogMeta; +use super::curriculum::{CurriculumIndexEntry, CurriculumSidebarEntry, Topic}; +use super::dummy::BlogIndex; +use super::types::PrevNextCurriculum; +use crate::specs::Specification; +use crate::utils::modified_dt; + +#[derive(Debug, Clone, Serialize, Default)] +pub struct TocEntry { + pub text: String, + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct Source { + pub folder: PathBuf, + pub github_url: String, + pub last_commit_url: String, + pub filename: String, +} +#[derive(Debug, Clone, Serialize, Default)] +pub struct Parent { + pub uri: String, + pub title: String, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct Translation { + pub locale: Locale, + pub native: Native, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct Prose { + pub id: Option, + pub title: Option, + #[serde(rename = "isH3")] + pub is_h3: bool, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct Compat { + pub id: Option, + pub title: Option, + #[serde(rename = "isH3")] + pub is_h3: bool, + pub query: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct SpecificationSection { + pub id: Option, + pub title: Option, + #[serde(rename = "isH3")] + pub is_h3: bool, + pub specifications: Vec, + pub query: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum Section { + Prose(Prose), + BrowserCompatibility(Compat), + Specifications(SpecificationSection), +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct JsonDoc { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub body: Vec
, + #[serde(rename = "isActive")] + pub is_active: bool, + #[serde(rename = "isMarkdown")] + pub is_markdown: bool, + #[serde(rename = "isTranslated")] + pub is_translated: bool, + pub locale: Locale, + pub mdn_url: String, + #[serde(serialize_with = "modified_dt")] + pub modified: NaiveDateTime, + pub native: Native, + #[serde(rename = "noIndexing")] + pub no_indexing: bool, + pub other_translations: Vec, + #[serde(rename = "pageTitle")] + pub page_title: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub parents: Vec, + pub popularity: Option, + pub short_title: String, + #[serde(rename = "sidebarHTML", skip_serializing_if = "Option::is_none")] + pub sidebar_html: Option, + #[serde(rename = "sidebarMacro", skip_serializing_if = "Option::is_none")] + pub sidebar_macro: Option, + pub source: Source, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + pub title: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub toc: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline: Option<&'static SupportStatus>, + #[serde(rename = "browserCompat", skip_serializing_if = "Vec::is_empty")] + pub browser_compat: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum HyData { + BlogIndex(BlogIndex), +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct JsonDoADoc { + #[serde(rename = "blogMeta", skip_serializing_if = "Option::is_none")] + pub blog_meta: Option, + pub doc: JsonDoc, + pub url: String, + #[serde(rename = "hyData", skip_serializing_if = "Option::is_none")] + pub hy_data: Option, + #[serde(rename = "pageTitle", skip_serializing_if = "Option::is_none")] + pub page_title: Option, +} + +pub struct BuiltDoc { + pub json: JsonDoADoc, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct JsonCurriculumDoc { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub body: Vec
, + pub locale: Locale, + pub mdn_url: String, + pub native: Native, + #[serde(rename = "noIndexing")] + pub no_indexing: bool, + #[serde(rename = "pageTitle")] + pub page_title: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub parents: Vec, + pub title: String, + pub summary: Option, + pub toc: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub sidebar: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub topic: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub modules: Vec, + #[serde(rename = "prevNext", skip_serializing_if = "Option::is_none")] + pub prev_next: Option, +} +#[derive(Debug, Clone, Serialize, Default)] +pub struct JsonCurriculum { + pub doc: JsonCurriculumDoc, + pub url: String, + #[serde(rename = "pageTitle")] + pub page_title: String, + pub locale: Locale, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct JsonBlogPostDoc { + #[serde(skip_serializing_if = "Vec::is_empty")] + pub body: Vec
, + pub mdn_url: String, + pub native: Native, + pub locale: Locale, + #[serde(rename = "noIndexing")] + pub no_indexing: bool, + #[serde(rename = "pageTitle")] + pub page_title: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub parents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + pub title: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub toc: Vec, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct JsonBlogPost { + pub doc: JsonBlogPostDoc, + pub locale: Locale, + pub url: String, + pub image: Option, + #[serde(rename = "pageTitle")] + pub page_title: String, + #[serde(rename = "blogMeta", skip_serializing_if = "Option::is_none")] + pub blog_meta: Option, + #[serde(rename = "hyData", skip_serializing_if = "Option::is_none")] + pub hy_data: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum BuiltDocy { + Doc(Box), + Curriculum(Box), + BlogPost(Box), +} diff --git a/crates/rari-doc/src/docs/mod.rs b/crates/rari-doc/src/docs/mod.rs new file mode 100644 index 00000000..d2e8dc3d --- /dev/null +++ b/crates/rari-doc/src/docs/mod.rs @@ -0,0 +1,11 @@ +pub mod blog; +pub mod build; +pub mod curriculum; +pub mod doc; +pub mod dummy; +pub mod json; +pub mod page; +pub mod parents; +pub mod sections; +pub mod title; +pub mod types; diff --git a/crates/rari-doc/src/docs/page.rs b/crates/rari-doc/src/docs/page.rs new file mode 100644 index 00000000..046b77b2 --- /dev/null +++ b/crates/rari-doc/src/docs/page.rs @@ -0,0 +1,201 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use enum_dispatch::enum_dispatch; +use rari_types::fm_types::{FeatureStatus, PageType}; +use rari_types::globals::blog_root; +use rari_types::locale::Locale; +use rari_types::RariEnv; + +use super::curriculum::CurriculumPage; +use super::doc::Doc; +use super::dummy::Dummy; +use crate::cached_readers::{blog_from_url, curriculum_from_url}; +use crate::docs::blog::BlogPost; +use crate::error::DocError; +use crate::resolve::{strip_locale_from_url, url_path_to_path_buf}; +use crate::utils::{locale_and_typ_from_path, root_for_locale}; + +#[derive(Debug, Clone)] +#[enum_dispatch] +pub enum Page { + Doc(Arc), + BlogPost(Arc), + Dummy(Arc), + Curriculum(Arc), +} + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum PageCategory { + Doc, + BlogPost, + Dummy, + Curriculum, +} + +impl Page { + pub fn page_from_url_path(url_path: &str) -> Result { + url_path_to_page(url_path) + } + + pub fn ignore(url_path: &str) -> bool { + if url_path == "/discord" { + return true; + } + if url_path == "/en-US/blog/rss.xml" { + return true; + } + if url_path.starts_with("/users/") { + return true; + } + if url_path.starts_with("/en-US/plus") { + return true; + } + if url_path.starts_with("/en-US/play") { + return true; + } + + false + } + pub fn exists(url_path: &str) -> bool { + if url_path == "/discord" { + return true; + } + if url_path.starts_with("/users/") { + return true; + } + if url_path.starts_with("/en-US/blog") && blog_root().is_none() { + return true; + } + if url_path.starts_with("/en-US/curriculum") { + return true; + } + if strip_locale_from_url(url_path).1 == "/" { + return true; + } + + Page::page_from_url_path(url_path).is_ok() + } +} + +impl PageReader for Page { + fn read(path: impl Into) -> Result { + let path = path.into(); + let (_, typ) = locale_and_typ_from_path(&path)?; + match typ { + PageCategory::Doc => Doc::read(path), + PageCategory::BlogPost => BlogPost::read(path), + PageCategory::Dummy => Dummy::read(path), + PageCategory::Curriculum => CurriculumPage::read(path), + } + } +} + +pub fn url_path_to_page(url_path: &str) -> Result { + if let Some(dummy) = Dummy::from_url(url_path) { + return Ok(dummy); + } + let (path, locale, typ) = url_path_to_path_buf(url_path)?; + match typ { + PageCategory::Doc => { + let mut file = root_for_locale(locale)?.to_path_buf(); + file.push(locale.as_folder_str()); + file.push(path); + file.push("index.md"); + Doc::read(file) + } + PageCategory::BlogPost => blog_from_url(url_path).ok_or(DocError::PageNotFound( + url_path.to_string(), + PageCategory::BlogPost, + )), + PageCategory::Curriculum => curriculum_from_url(&url_path.to_ascii_lowercase()).ok_or( + DocError::PageNotFound(url_path.to_string(), PageCategory::Curriculum), + ), + _ => unreachable!(), + } +} + +#[enum_dispatch(Page)] +pub trait PageLike { + fn url(&self) -> &str; + fn slug(&self) -> &str; + fn title(&self) -> &str; + fn short_title(&self) -> Option<&str>; + fn locale(&self) -> Locale; + fn content(&self) -> &str; + fn rari_env(&self) -> Option>; + fn render(&self) -> Result; + fn title_suffix(&self) -> Option<&str>; + fn page_type(&self) -> PageType; + fn status(&self) -> &[FeatureStatus]; + fn full_path(&self) -> &Path; + fn path(&self) -> &Path; + fn base_slug(&self) -> &str; + fn trailing_slash(&self) -> bool; +} + +impl PageLike for Arc { + fn url(&self) -> &str { + (**self).url() + } + + fn slug(&self) -> &str { + (**self).slug() + } + + fn title(&self) -> &str { + (**self).title() + } + + fn short_title(&self) -> Option<&str> { + (**self).short_title() + } + + fn locale(&self) -> Locale { + (**self).locale() + } + + fn content(&self) -> &str { + (**self).content() + } + + fn rari_env(&self) -> Option> { + (**self).rari_env() + } + + fn render(&self) -> Result { + (**self).render() + } + + fn title_suffix(&self) -> Option<&str> { + (**self).title_suffix() + } + + fn page_type(&self) -> PageType { + (**self).page_type() + } + + fn status(&self) -> &[FeatureStatus] { + (**self).status() + } + + fn full_path(&self) -> &Path { + (**self).full_path() + } + + fn path(&self) -> &Path { + (**self).path() + } + + fn base_slug(&self) -> &str { + (**self).base_slug() + } + + fn trailing_slash(&self) -> bool { + (**self).trailing_slash() + } +} + +pub trait PageReader { + fn read(path: impl Into) -> Result; +} diff --git a/crates/rari-doc/src/docs/parents.rs b/crates/rari-doc/src/docs/parents.rs new file mode 100644 index 00000000..94cf1a90 --- /dev/null +++ b/crates/rari-doc/src/docs/parents.rs @@ -0,0 +1,26 @@ +use super::json::Parent; +use super::page::{Page, PageLike}; +use super::title::transform_title; + +pub fn parents(doc: &T) -> Vec { + let mut url = doc.url(); + let mut parents = vec![Parent { + uri: url.into(), + title: transform_title(doc.short_title().unwrap_or(doc.title())).to_string(), + }]; + while let Some(i) = url.trim_end_matches('/').rfind('/') { + let parent_url = &url[..if doc.trailing_slash() { i + 1 } else { i }]; + if parent_url.ends_with(doc.base_slug()) { + break; + } + if let Ok(parent) = Page::page_from_url_path(parent_url) { + parents.push(Parent { + uri: parent.url().into(), + title: transform_title(parent.title()).to_string(), + }) + } + url = parent_url + } + parents.reverse(); + parents +} diff --git a/crates/rari-doc/src/docs/sections.rs b/crates/rari-doc/src/docs/sections.rs new file mode 100644 index 00000000..42fcbc60 --- /dev/null +++ b/crates/rari-doc/src/docs/sections.rs @@ -0,0 +1,227 @@ +use std::ops::Deref; + +use scraper::{ElementRef, Html, Selector}; + +use crate::error::DocError; + +#[derive(Debug, Clone, Copy)] +pub enum BuildSectionType { + Prose, + Compat, + Specification, + Unknown, +} + +pub struct BuildSection<'a> { + pub heading: Option>, + pub body: Vec, + pub query: Option, + pub spec_urls: Option, + pub is_h3: bool, + pub typ: BuildSectionType, + pub id: Option, +} + +pub fn split_sections(html: &Html) -> Result<(Vec>, Option), DocError> { + let root_children = html.root_element().children(); + let raw_sections = root_children; + let summary_selector = Selector::parse("p").unwrap(); + let summary = html.select(&summary_selector).find_map(|s| { + let text = s.text().collect::(); + if !text.is_empty() { + Some(text) + } else { + None + } + }); + + let (mut sections, mut last) = raw_sections.fold( + (Vec::new(), None::), + |(mut sections, mut maybe_section), current| { + match current.value() { + scraper::Node::Comment(comment) => { + let comment = format!("", comment.deref()); + if let Some(ref mut section) = maybe_section.as_mut().and_then(|section| { + if !matches!(section.typ, BuildSectionType::Compat) { + Some(section) + } else { + None + } + }) { + section.body.push(comment); + } else { + if let Some(compat_section) = maybe_section.take() { + sections.push(compat_section); + } + let _ = maybe_section.insert(BuildSection { + heading: None, + body: vec![comment], + is_h3: false, + typ: BuildSectionType::Unknown, + query: None, + spec_urls: None, + id: None, + }); + } + } + scraper::Node::Text(text) => { + let text = text.deref().trim_matches('\n').to_string(); + if !text.is_empty() { + if let Some(ref mut section) = maybe_section { + section.body.push(text); + } else { + let _ = maybe_section.insert(BuildSection { + heading: None, + body: vec![text], + is_h3: false, + typ: BuildSectionType::Unknown, + query: None, + spec_urls: None, + id: None, + }); + } + } + } + + scraper::Node::Element(element) => match element.name() { + h @ "h2" | h @ "h3" => { + if let Some(section) = maybe_section.take() { + sections.push(section); + } + let heading = ElementRef::wrap(current).unwrap(); + let id = heading.attr("id").map(String::from); + let _ = maybe_section.insert(BuildSection { + heading: Some(heading), + body: vec![], + is_h3: h == "h3", + typ: BuildSectionType::Prose, + query: None, + spec_urls: None, + id, + }); + } + _ => { + let (typ, query, urls) = if element.classes().any(|cls| cls == "bc-data") { + (BuildSectionType::Compat, element.attr("data-query"), None) + } else if element.classes().any(|cls| cls == "bc-specs") { + ( + BuildSectionType::Specification, + element.attr("data-bcd-query"), + element.attr("data-spec-urls"), + ) + } else { + (BuildSectionType::Unknown, None, None) + }; + match (typ, query, urls) { + (BuildSectionType::Compat, Some(query), _) => { + if let Some("true") = element.attr("data-multiple") { + if let Some(section) = maybe_section.take() { + sections.push(section); + } + sections.push(BuildSection { + heading: None, + body: vec![], + is_h3: true, + typ: BuildSectionType::Compat, + query: Some(query.into()), + spec_urls: None, + id: None, + }); + } else if let Some(ref mut section) = maybe_section { + if section.body.is_empty() { + section.typ = BuildSectionType::Compat; + section.query = Some(query.into()); + } else { + // We have already something in body. + // Yari does something weird so we do that to: + // We push compat section and put prose after that 🤷. + + let heading = section.heading.take(); + let is_h3 = section.is_h3; + let id = section.id.take(); + + section.is_h3 = false; + + sections.push(BuildSection { + heading, + body: vec![], + is_h3, + typ: BuildSectionType::Compat, + query: Some(query.into()), + spec_urls: None, + id, + }); + } + } + } + (BuildSectionType::Specification, query, urls) + if query.is_some() || urls.is_some() => + { + if let Some(ref mut section) = maybe_section { + if section.body.is_empty() { + section.typ = BuildSectionType::Specification; + section.query = query.map(String::from); + section.spec_urls = urls.map(String::from); + } else { + // We have already something in body. + // Yari does something weird so we do that to: + // We push compat section and put prose after that 🤷. + + let heading = section.heading.take(); + let is_h3 = section.is_h3; + let id = section.id.take(); + + section.is_h3 = false; + + sections.push(BuildSection { + heading, + body: vec![], + is_h3, + typ: BuildSectionType::Specification, + query: query.map(String::from), + spec_urls: urls.map(String::from), + id, + }); + } + } + } + _ => { + let html = ElementRef::wrap(current).unwrap().html(); + if let Some(ref mut section) = + maybe_section.as_mut().and_then(|section| { + if !matches!(section.typ, BuildSectionType::Compat) { + Some(section) + } else { + None + } + }) + { + section.body.push(html) + } else { + if let Some(compat_section) = maybe_section.take() { + sections.push(compat_section); + } + let _ = maybe_section.insert(BuildSection { + heading: None, + body: vec![html], + is_h3: false, + typ: BuildSectionType::Unknown, + query: None, + spec_urls: None, + id: None, + }); + } + } + } + } + }, + _ => {} + } + (sections, maybe_section) + }, + ); + if let Some(section) = last.take() { + sections.push(section); + } + Ok((sections, summary)) +} diff --git a/crates/rari-doc/src/docs/title.rs b/crates/rari-doc/src/docs/title.rs new file mode 100644 index 00000000..3d38cf41 --- /dev/null +++ b/crates/rari-doc/src/docs/title.rs @@ -0,0 +1,83 @@ +use super::page::{url_path_to_page, PageLike}; +use crate::error::DocError; + +pub fn transform_title(title: &str) -> &str { + if title.starts_with('<') { + if let Some(end) = title.find('>') { + return &title[..end + 1]; + } + } + + match title { + "Web technology for developers" => "References", + "Learn web development" => "Guides", + "HTML: HyperText Markup Language" => "HTML", + "CSS: Cascading Style Sheets" => "CSS", + "Graphics on the Web" => "Graphics", + "HTML elements reference" => "Elements", + "JavaScript reference" => "Reference", + "JavaScript Guide" => "Guide", + "Structuring the web with HTML" => "HTML", + "Learn to style HTML using CSS" => "CSS", + "Web forms — Working with user data" => "Forms", + _ => title, + } +} + +pub fn page_title(doc: &impl PageLike, with_suffix: bool) -> Result { + let mut out = String::new(); + + out.push_str(doc.title()); + + if let Some(root_url) = root_doc_url(doc.url()) { + if root_url != doc.url() { + let root_doc = url_path_to_page(root_url)?; + out.push_str(" - "); + out.push_str(root_doc.title()); + } + } + if with_suffix { + if let Some(suffix) = doc.title_suffix() { + out.push_str(" | "); + out.push_str(suffix); + } + } + Ok(out) +} + +pub fn root_doc_url(url: &str) -> Option<&str> { + let m = url.match_indices('/').map(|(i, _)| i).collect::>(); + if m.len() < 3 { + return None; + } + if url[m[1]..].starts_with("/blog") || url[m[1]..].starts_with("/curriculum") { + return None; + } + if url[m[1]..].starts_with("/docs/Web") { + return Some(&url[..*m.get(4).unwrap_or(&url.len())]); + } + Some(&url[..*m.get(3).unwrap_or(&url.len())]) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_root_doc_url() { + assert_eq!( + root_doc_url("/en-US/docs/Web/CSS/border"), + Some("/en-US/docs/Web/CSS") + ); + assert_eq!( + root_doc_url("/en-US/docs/Web/CSS"), + Some("/en-US/docs/Web/CSS") + ); + assert_eq!( + root_doc_url("/en-US/docs/Learn/foo"), + Some("/en-US/docs/Learn") + ); + assert_eq!(root_doc_url("/en-US/blog/foo"), None); + assert_eq!(root_doc_url("/en-US/curriculum/foo"), None); + } +} diff --git a/crates/rari-doc/src/docs/types.rs b/crates/rari-doc/src/docs/types.rs new file mode 100644 index 00000000..c92bc511 --- /dev/null +++ b/crates/rari-doc/src/docs/types.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(default)] +pub struct PrevNextBlog { + pub previous: Option, + pub next: Option, +} + +impl PrevNextBlog { + pub fn is_none(&self) -> bool { + self.previous.is_none() && self.next.is_none() + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +pub struct SlugNTitle { + pub title: String, + pub slug: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(default)] +pub struct PrevNextCurriculum { + pub prev: Option, + pub next: Option, +} +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +pub struct UrlNTitle { + pub title: String, + pub url: String, +} diff --git a/crates/rari-doc/src/error.rs b/crates/rari-doc/src/error.rs new file mode 100644 index 00000000..be15d8fc --- /dev/null +++ b/crates/rari-doc/src/error.rs @@ -0,0 +1,105 @@ +use std::path::{PathBuf, StripPrefixError}; +use std::sync::PoisonError; + +use css_syntax::error::SyntaxError; +use rari_md::error::MarkdownError; +use rari_types::error::EnvError; +use rari_types::locale::LocaleError; +use rari_types::ArgError; +use thiserror::Error; + +use crate::docs::page::PageCategory; + +#[derive(Debug, Error)] +pub enum DocError { + #[error("No parent")] + NoParent(PathBuf), + #[error(transparent)] + NoSuchPrefix(#[from] StripPrefixError), + #[error("No curricm root set")] + NoCurriculumRoot, + #[error("No H1 found")] + NoH1, + #[error(transparent)] + WalkError(#[from] ignore::Error), + #[error(transparent)] + JsonError(#[from] serde_json::Error), + #[error("File not found in static cache: {0}")] + NotFoundInStaticCache(PathBuf), + #[error("File cache broken")] + FileCacheBroken, + #[error("File cache poisoned")] + FileCachePoisoned, + #[error(transparent)] + IOError(#[from] std::io::Error), + #[error("Error parsing frontmatter: {0}")] + FMError(#[from] yaml_rust::scanner::ScanError), + #[error("Missing frontmatter")] + NoFrontmatter, + #[error("Invalid frontmatter: {0}")] + InvalidFrontmatter(#[from] serde_yaml::Error), + #[error(transparent)] + EnvError(#[from] EnvError), + #[error(transparent)] + UrlError(#[from] UrlError), + #[error(transparent)] + MarkdownError(#[from] MarkdownError), + #[error(transparent)] + LocaleError(#[from] LocaleError), + #[error("failed to convert bytes: {0}")] + StrUtf8Error(#[from] std::str::Utf8Error), + #[error(transparent)] + LolError(#[from] lol_html::errors::RewritingError), + #[error(transparent)] + Utf8Error(#[from] std::string::FromUtf8Error), + #[error("Link to redirect: {from} -> {to}")] + RedirectedLink { from: String, to: String }, + #[error("Sidebar cache poisoned")] + SidebarCachePoisoned, + #[error("Unknown macro: {0}")] + UnknownMacro(String), + #[error("CSS Page type required")] + CssPageTypeRequired, + #[error(transparent)] + ArgError(#[from] ArgError), + #[error("pest error: {0}")] + PestError(String), + #[error("failed to decode ks: {0}")] + DecodeError(#[from] base64::DecodeError), + #[error("failed to de/serialize")] + SerializationError, + #[error(transparent)] + CssSyntaxError(#[from] SyntaxError), + #[error(transparent)] + FmtError(#[from] std::fmt::Error), + #[error("invalid templ: {0}")] + InvalidTempl(String), + #[error("doc not found {0}")] + DocNotFound(PathBuf), + #[error("page({1:?}) not found {0}")] + PageNotFound(String, PageCategory), + #[error("no blog root")] + NoBlogRoot, +} + +impl From> for DocError { + fn from(_: PoisonError) -> Self { + Self::FileCachePoisoned + } +} + +#[derive(Debug, Error)] +pub enum UrlError { + #[error("invalid url")] + InvalidUrl, + #[error(transparent)] + LocaleError(#[from] LocaleError), + #[error(transparent)] + EnvError(#[from] EnvError), +} + +#[derive(Debug, Error)] +pub enum FileError { + #[error("not a subpath")] + NoSubPath(#[from] StripPrefixError), +} diff --git a/crates/rari-doc/src/html/links.rs b/crates/rari-doc/src/html/links.rs new file mode 100644 index 00000000..9a28fc9b --- /dev/null +++ b/crates/rari-doc/src/html/links.rs @@ -0,0 +1,75 @@ +use std::borrow::Cow; + +use rari_types::fm_types::FeatureStatus; +use rari_types::locale::Locale; + +use crate::docs::page::PageLike; +use crate::error::DocError; +use crate::templ::api::RariApi; +use crate::templ::macros::badges::{write_deprecated, write_experimental, write_non_standard}; + +pub fn render_link( + out: &mut String, + link: &str, + locale: Option<&Locale>, + content: Option<&str>, + code: bool, + title: Option<&str>, + with_badges: bool, +) -> Result<(), DocError> { + out.push_str(""); + if code { + out.push_str(""); + } + out.push_str(&content); + if code { + out.push_str(""); + } + out.push_str(""); + if with_badges { + if page.status().contains(&FeatureStatus::Experimental) { + write_experimental(out, &page.locale())?; + } + if page.status().contains(&FeatureStatus::NonStandard) { + write_non_standard(out, &page.locale())?; + } + if page.status().contains(&FeatureStatus::Deprecated) { + write_deprecated(out, &page.locale())?; + } + } + } else { + let url = link; + let content = html_escape::encode_safe(content.unwrap_or(link)); + out.push_str(url); + if let Some(title) = title { + out.push_str("\" title=\""); + out.push_str(title); + } + out.push_str("\">"); + if code { + out.push_str(""); + } + out.push_str(&content); + if code { + out.push_str(""); + } + out.push_str(""); + } + Ok(()) +} diff --git a/crates/rari-doc/src/html/mod.rs b/crates/rari-doc/src/html/mod.rs new file mode 100644 index 00000000..67be4434 --- /dev/null +++ b/crates/rari-doc/src/html/mod.rs @@ -0,0 +1,3 @@ +pub mod links; +pub mod rewriter; +pub mod sidebar; diff --git a/crates/rari-doc/src/html/rewriter.rs b/crates/rari-doc/src/html/rewriter.rs new file mode 100644 index 00000000..10aa439e --- /dev/null +++ b/crates/rari-doc/src/html/rewriter.rs @@ -0,0 +1,221 @@ +use std::borrow::Cow; + +use lol_html::html_content::ContentType; +use lol_html::{element, text, HtmlRewriter, Settings}; +use rari_md::bq::NoteCard; +use rari_types::fm_types::PageType; +use rari_types::locale::Locale; + +use crate::docs::curriculum::relative_file_to_curriculum_page; +use crate::docs::page::{Page, PageLike}; +use crate::error::DocError; +use crate::redirects::resolve_redirect; +use crate::resolve::strip_locale_from_url; + +pub fn post_process_html( + input: &str, + page: &T, + sidebar: bool, +) -> Result { + let mut output = vec![]; + + let mut element_content_handlers = vec![ + element!("img:not([loading])", |el| { + el.set_attribute("loading", "lazy")?; + Ok(()) + }), + element!("iframe:not([loading])", |el| { + el.set_attribute("loading", "lazy")?; + Ok(()) + }), + element!("li > p", |el| { + el.remove_and_keep_content(); + Ok(()) + }), + element!("a[href]", |el| { + let href = el.get_attribute("href").expect("href was required"); + if href.starts_with('/') || href.starts_with("https://developer.mozilla.org") { + let href = href + .strip_prefix("https://developer.mozilla.org") + .map(|href| if href.is_empty() { "/" } else { href }) + .unwrap_or(&href); + let no_locale = strip_locale_from_url(href).0.is_none(); + let maybe_prefixed_href = if no_locale { + Cow::Owned(format!("/{}{href}", Locale::default().as_url_str())) + } else { + Cow::Borrowed(href) + }; + let resolved_href = resolve_redirect(&maybe_prefixed_href) + .unwrap_or(Cow::Borrowed(&maybe_prefixed_href)); + let resolved_href_no_hash = + &resolved_href[..resolved_href.find('#').unwrap_or(resolved_href.len())]; + if resolved_href_no_hash == page.url() { + el.set_attribute("aria-current", "page")?; + } + if !Page::exists(resolved_href_no_hash) && !Page::ignore(href) { + tracing::info!("{resolved_href_no_hash} {href}"); + let class = el.get_attribute("class").unwrap_or_default(); + el.set_attribute( + "class", + &format!( + "{class}{}page-not-created", + if class.is_empty() { "" } else { " " } + ), + )?; + el.set_attribute("title", "This is a link to an unwritten page")?; + } + el.set_attribute( + "href", + if no_locale { + strip_locale_from_url(&resolved_href).1 + } else { + &resolved_href + }, + )?; + } else if href.starts_with("http:") || href.starts_with("https:") { + let class = el.get_attribute("class").unwrap_or_default(); + if !class.split(' ').any(|s| s == "external") { + el.set_attribute( + "class", + &format!("{class}{}external", if class.is_empty() { "" } else { " " }), + )?; + } + if !el.has_attribute("target") { + el.set_attribute("target", "_blank")?; + } + } + + Ok(()) + }), + element!("dt:first-child:not(a)", |el| { + if let Some(id) = el.get_attribute("id") { + el.prepend(&format!(""), ContentType::Html); + el.append("", ContentType::Html); + } + Ok(()) + }), + element!("pre[class*=brush]:not(.hidden)", |el| { + let class = el.get_attribute("class"); + let class = class.as_deref().unwrap_or_default(); + let name = class + .split_ascii_whitespace() + .skip_while(|s| *s != "brush:") + .nth(1) + .unwrap_or_default(); + if !name.is_empty() && name != "plain" { + el.before( + &format!( + r#"
{name}
"#, + ), + ContentType::Html + ); + el.after("
", ContentType::Html); + } + Ok(()) + }), + element!("pre[class*=brush].hidden", |el| { + el.before(r#"
"#, ContentType::Html); + el.after("
", ContentType::Html); + Ok(()) + }), + element!("div.notecard.callout > p:first-child", |el| { + el.prepend( + &format!( + "{}", + NoteCard::Callout.prefix_for_locale(page.locale()) + ), + ContentType::Html, + ); + Ok(()) + }), + element!("div.notecard.warning > p:first-child", |el| { + el.prepend( + &format!( + "{}", + NoteCard::Warning.prefix_for_locale(page.locale()) + ), + ContentType::Html, + ); + Ok(()) + }), + element!("div.notecard.note > p:first-child", |el| { + el.prepend( + &format!( + "{}", + NoteCard::Note.prefix_for_locale(page.locale()) + ), + ContentType::Html, + ); + Ok(()) + }), + element!("table", |el| { + el.before("
", ContentType::Html); + el.after("
", ContentType::Html); + Ok(()) + }), + ]; + if sidebar { + element_content_handlers.push(element!(&format!("a[href=\"{}\"]", page.url()), |el| { + el.before("", ContentType::Html); + el.after("", ContentType::Html); + Ok(()) + })); + element_content_handlers.push(element!("html", |el| { + el.remove_and_keep_content(); + Ok(()) + })); + } + if page.page_type() == PageType::Curriculum { + element_content_handlers = { + let mut curriculum_links = vec![ + element!("a[href^=\".\"]", |el| { + let href = el.get_attribute("href").unwrap_or_default(); + let split_href = href.split_once('#'); + if let Ok(page) = relative_file_to_curriculum_page( + page.full_path(), + split_href.map(|s| s.0).unwrap_or(&href), + ) { + el.set_attribute( + "href", + &split_href + .map(|s| Cow::Owned(format!("{}#{}", page.url(), s.1))) + .unwrap_or(Cow::Borrowed(page.url())), + )?; + } + Ok(()) + }), + text!("p", |t| { + if t.as_str() == "Learning outcomes:" { + t.before( + "", + ContentType::Html, + ) + } + if t.as_str() == "Resources:" || t.as_str() == "General resources:" { + t.before( + "", + ContentType::Html, + ) + } + Ok(()) + }), + ]; + + curriculum_links.append(&mut element_content_handlers); + curriculum_links + } + } + + let mut rewriter = HtmlRewriter::new( + Settings { + element_content_handlers, + ..Settings::default() + }, + |c: &[u8]| output.extend_from_slice(c), + ); + + rewriter.write(input.as_bytes())?; + rewriter.end()?; + + Ok(String::from_utf8(output)?) +} diff --git a/crates/rari-doc/src/html/sidebar.rs b/crates/rari-doc/src/html/sidebar.rs new file mode 100644 index 00000000..792c4209 --- /dev/null +++ b/crates/rari-doc/src/html/sidebar.rs @@ -0,0 +1,325 @@ +use std::collections::HashMap; +pub use std::ops::Deref; +use std::sync::{Arc, RwLock}; + +use html5ever::{namespace_url, ns, QualName}; +use once_cell::sync::Lazy; +use rari_types::fm_types::PageType; +use rari_types::globals::cache_content; +use rari_types::locale::Locale; +use scraper::{Html, Node, Selector}; +use serde::{Deserialize, Serialize}; + +use super::links::render_link; +use super::rewriter::post_process_html; +use crate::cached_readers::read_sidebar; +use crate::docs::doc::Doc; +use crate::error::DocError; +use crate::templ::macros::listsubpages::{ + list_sub_pages_grouped_internal, list_sub_pages_internal, +}; +use crate::utils::t_or_vec; + +fn cache_side_bar(sidebar: &str) -> bool { + cache_content() + && match sidebar { + "cssref" => true, + "jsref" => false, + "glossarysidebar" => true, + _ => false, + } +} + +type SidebarCache = Arc>>>; + +static SIDEBAR_CACHE: Lazy = Lazy::new(|| Arc::new(RwLock::new(HashMap::new()))); + +fn highlight_current(mut html: Html, url: &str) -> Result { + let a_selector = Selector::parse(&format!("a[href=\"{url}\"]")).unwrap(); + let mut details = vec![]; + if let Some(a) = html.select(&a_selector).next() { + let mut next = a.parent(); + while let Some(parent) = next { + if let Node::Element(el) = parent.value() { + if el.name() == "details" { + details.push(parent.id()) + } + } + next = parent.parent(); + } + } + for details in details { + if let Some(mut details) = html.tree.get_mut(details) { + if let Node::Element(ref mut el) = details.value() { + el.attrs.insert( + QualName { + prefix: None, + ns: ns!(), + local: "open".into(), + }, + "".into(), + ); + } + } + } + + Ok(html.html()) +} +pub fn render_sidebar(doc: &Doc) -> Result, DocError> { + let locale = doc.meta.locale; + let out = doc + .meta + .sidebar + .iter() + .map(|s| { + let cache = cache_side_bar(s); + if cache { + if let Some(sb) = SIDEBAR_CACHE + .read() + .map_err(|_| DocError::SidebarCachePoisoned)? + .get(&locale) + .and_then(|map| map.get(s)) + { + return Ok::<_, DocError>(sb.to_owned()); + } + } + let sidebar = read_sidebar(s, locale)?; + let rendered_sidebar = sidebar.render(&locale)?; + if cache { + SIDEBAR_CACHE + .write() + .map_err(|_| DocError::SidebarCachePoisoned)? + .entry(locale) + .or_default() + .entry(s.clone()) + .or_insert(rendered_sidebar.clone()); + } + Ok::<_, DocError>(rendered_sidebar) + }) + .map(|ks_rendered_sidebar| { + let fragment = Html::parse_fragment(&ks_rendered_sidebar?); + let pre_processed_html = highlight_current(fragment, &doc.meta.url)?; + let post_processed_html = post_process_html(&pre_processed_html, doc, true)?; + Ok::<_, DocError>(post_processed_html) + }) + .collect::>()?; + Ok(if out.is_empty() { None } else { Some(out) }) +} + +#[derive(Serialize, Deserialize, Default)] +#[serde(transparent)] +pub struct Sidebar { + pub entries: Vec, +} + +pub struct MetaSidebar { + pub entries: Vec, +} +impl From for MetaSidebar { + fn from(value: Sidebar) -> Self { + MetaSidebar { + entries: value.entries.into_iter().map(Into::into).collect(), + } + } +} + +impl MetaSidebar { + pub fn render(&self, locale: &Locale) -> Result { + let mut out = String::new(); + out.push_str("
    "); + for entry in &self.entries { + entry.render(&mut out, locale)?; + } + out.push_str("
"); + Ok(out) + } +} + +#[derive(Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", tag = "type")] +pub struct BasicEntry { + pub link: Option, + pub title: Option, + #[serde(default)] + pub children: Vec, +} + +#[derive(Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase", tag = "type")] +pub struct SubPageEntry { + pub path: String, + pub title: Option, + pub link: Option, + #[serde(deserialize_with = "t_or_vec", default)] + pub tags: Vec, + #[serde(default)] + pub details: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum SidebarEntry { + Section(BasicEntry), + Details(BasicEntry), + ListSubPages(SubPageEntry), + ListSubPagesGrouped(SubPageEntry), + #[serde(untagged)] + Default(BasicEntry), + #[serde(untagged)] + Link(String), +} + +pub enum MetaChildren { + Children(Vec), + ListSubPages(String, Vec), + ListSubPagesGrouped(String, Vec), + None, +} + +pub struct SidebarMetaEntry { + pub details: bool, + pub section: bool, + pub link: Option, + pub title: Option, + pub children: MetaChildren, +} + +impl From for SidebarMetaEntry { + fn from(value: SidebarEntry) -> Self { + match value { + SidebarEntry::Section(BasicEntry { + link, + title, + children, + }) => SidebarMetaEntry { + section: true, + details: false, + link, + title, + children: if children.is_empty() { + MetaChildren::None + } else { + MetaChildren::Children(children.into_iter().map(Into::into).collect()) + }, + }, + SidebarEntry::Details(BasicEntry { + link, + title, + children, + }) => SidebarMetaEntry { + section: false, + details: true, + link, + title, + children: if children.is_empty() { + MetaChildren::None + } else { + MetaChildren::Children(children.into_iter().map(Into::into).collect()) + }, + }, + SidebarEntry::ListSubPages(SubPageEntry { + details, + tags, + link, + title, + path, + }) => SidebarMetaEntry { + section: false, + details, + link, + title, + children: MetaChildren::ListSubPages(path, tags), + }, + SidebarEntry::ListSubPagesGrouped(SubPageEntry { + details, + tags, + link, + title, + path, + }) => SidebarMetaEntry { + section: false, + details, + link, + title, + children: MetaChildren::ListSubPagesGrouped(path, tags), + }, + SidebarEntry::Default(BasicEntry { + link, + title, + children, + }) => SidebarMetaEntry { + section: false, + details: false, + link, + title, + children: MetaChildren::Children(children.into_iter().map(Into::into).collect()), + }, + SidebarEntry::Link(link) => SidebarMetaEntry { + section: false, + details: false, + link: Some(link), + title: None, + children: MetaChildren::None, + }, + } + } +} + +impl SidebarMetaEntry { + pub fn render(&self, out: &mut String, locale: &Locale) -> Result<(), DocError> { + out.push_str("
'); + if let Some(link) = &self.link { + render_link( + out, + link, + Some(locale), + self.title.as_deref(), + false, + None, + true, + )?; + } else { + out.push_str(self.title.as_deref().unwrap_or_default()); + } + + if self.details { + out.push_str(""); + } + + if !matches!(self.children, MetaChildren::None) { + out.push_str("
    "); + } + match &self.children { + MetaChildren::Children(children) => { + for child in children { + child.render(out, locale)?; + } + } + MetaChildren::ListSubPages(url, page_types) => { + list_sub_pages_internal(out, url, locale, page_types)? + } + MetaChildren::ListSubPagesGrouped(url, page_types) => { + list_sub_pages_grouped_internal(out, url, locale, page_types)? + } + MetaChildren::None => {} + } + if !matches!(self.children, MetaChildren::None) { + out.push_str("
"); + } + if self.details { + out.push_str("
"); + } + out.push_str(""); + Ok(()) + } +} diff --git a/crates/rari-doc/src/lib.rs b/crates/rari-doc/src/lib.rs new file mode 100644 index 00000000..62aa5662 --- /dev/null +++ b/crates/rari-doc/src/lib.rs @@ -0,0 +1,13 @@ +pub mod baseline; +pub mod build; +pub mod cached_readers; +pub mod docs; +pub mod error; +pub mod html; +pub mod percent; +pub mod redirects; +pub mod resolve; +pub mod specs; +pub mod templ; +pub mod utils; +pub mod walker; diff --git a/crates/rari-doc/src/percent.rs b/crates/rari-doc/src/percent.rs new file mode 100644 index 00000000..020bbc00 --- /dev/null +++ b/crates/rari-doc/src/percent.rs @@ -0,0 +1,30 @@ +use percent_encoding::{AsciiSet, CONTROLS}; + +/// https://url.spec.whatwg.org/#fragment-percent-encode-set +const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`'); + +/// https://url.spec.whatwg.org/#path-percent-encode-set +const PATH: &AsciiSet = &FRAGMENT.add(b'#').add(b'?').add(b'{').add(b'}'); + +/// https://url.spec.whatwg.org/#userinfo-percent-encode-set +pub const USERINFO: &AsciiSet = &PATH + .add(b'/') + .add(b':') + .add(b';') + .add(b'=') + .add(b'@') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'|'); + +pub const PATH_SEGMENT: &AsciiSet = &PATH.add(b'/').add(b'%'); + +// The backslash (\) character is treated as a path separator in special URLs +// so it needs to be additionally escaped in that case. +pub const SPECIAL_PATH_SEGMENT: &AsciiSet = &PATH_SEGMENT.add(b'\\'); + +// https://url.spec.whatwg.org/#query-state +pub const QUERY: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'#').add(b'<').add(b'>'); +pub const SPECIAL_QUERY: &AsciiSet = &QUERY.add(b'\''); diff --git a/crates/rari-doc/src/redirects.rs b/crates/rari-doc/src/redirects.rs new file mode 100644 index 00000000..0fa6f5ed --- /dev/null +++ b/crates/rari-doc/src/redirects.rs @@ -0,0 +1,94 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::fs::File; +use std::io::{self, BufRead}; +use std::path::Path; +use std::str::FromStr; + +use once_cell::sync::Lazy; +use rari_types::globals::{content_root, content_translated_root}; +use rari_types::locale::Locale; +use tracing::error; + +use crate::error::DocError; + +static REDIRECTS: Lazy> = Lazy::new(|| { + let mut map = HashMap::new(); + if let Some(ctr) = content_translated_root() { + for locale in ctr + .read_dir() + .expect("unable to read translated content root") + .filter_map(|dir| { + dir.map_err(|e| { + error!("Error: reading translated content root: {e}"); + }) + .ok() + .and_then(|dir| { + Locale::from_str( + dir.file_name() + .as_os_str() + .to_str() + .expect("invalid folder"), + ) + .map_err(|e| error!("Invalid folder {:?}: {e}", dir.file_name())) + .ok() + }) + }) + { + if let Err(e) = read_redirects( + &ctr.to_path_buf() + .join(locale.as_folder_str()) + .join("_redirects.txt"), + &mut map, + ) { + error!("Error reading redirects: {e}"); + } + } + } + if let Err(e) = read_redirects( + &content_root() + .to_path_buf() + .join("en-us") + .join("_redirects.txt"), + &mut map, + ) { + error!("Error reading redirects: {e}"); + } + map +}); + +fn read_redirects(path: &Path, map: &mut HashMap) -> Result<(), DocError> { + let lines = read_lines(path)?; + map.extend(lines.map_while(Result::ok).filter_map(|line| { + let mut from_to = line.splitn(2, '\t'); + if let (Some(from), Some(to)) = (from_to.next(), from_to.next()) { + Some((from.to_lowercase(), to.into())) + } else { + None + } + })); + Ok(()) +} + +fn read_lines

(filename: P) -> io::Result>> +where + P: AsRef, +{ + let file = File::open(filename)?; + Ok(io::BufReader::new(file).lines()) +} + +pub fn resolve_redirect(url: &str) -> Option> { + let hash_index = url.find('#').unwrap_or(url.len()); + let (url_no_hash, hash) = (&url[..hash_index], &url[hash_index..]); + match ( + REDIRECTS + .get(&url_no_hash.to_lowercase()) + .map(|s| s.as_str()), + hash, + ) { + (None, _) => None, + (Some(url), hash) if url.contains('#') || hash.is_empty() => Some(Cow::Borrowed(url)), + (Some(url), hash) => Some(Cow::Owned(format!("{url}{hash}"))), + } +} diff --git a/crates/rari-doc/src/resolve.rs b/crates/rari-doc/src/resolve.rs new file mode 100644 index 00000000..3ff35b12 --- /dev/null +++ b/crates/rari-doc/src/resolve.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; +use std::str::FromStr; + +use rari_types::locale::Locale; + +use crate::docs::dummy::Dummy; +use crate::docs::page::{PageCategory, PageLike}; +use crate::error::UrlError; + +pub fn url_to_path_buf(slug: &str) -> PathBuf { + PathBuf::from( + slug.replace('*', "_star_") + .replace("::", "_doublecolon_") + .replace(':', "_colon_") + .replace('?', "_question_") + .to_lowercase(), + ) +} + +pub fn strip_locale_from_url(url: &str) -> (Option, &str) { + if url.len() < 2 || !url.starts_with('/') { + return (None, url); + } + let i = url[1..].find('/').map(|i| i + 1).unwrap_or(url.len()); + let locale = Locale::from_str(&url[1..i]).ok(); + (locale, &url[i..]) +} + +pub fn url_path_to_path_buf(url_path: &str) -> Result<(PathBuf, Locale, PageCategory), UrlError> { + let mut split = url_path[..url_path.find('#').unwrap_or(url_path.len())] + .splitn(4, '/') + .skip(1); + let locale: Locale = Locale::from_str(split.next().unwrap_or_default())?; + let typ = match split.next() { + Some("docs") => PageCategory::Doc, + Some("blog") => PageCategory::BlogPost, + Some("curriculum") => PageCategory::Curriculum, + _ => return Err(UrlError::InvalidUrl), + }; + let path = url_to_path_buf(split.last().unwrap_or_default()); + Ok((path, locale, typ)) +} + +pub fn build_url(slug: &str, locale: &Locale, typ: PageCategory) -> String { + match typ { + PageCategory::Doc => format!("/{}/docs/{}", locale.as_url_str(), slug), + PageCategory::BlogPost => format!("/{}/blog/{}/", locale.as_url_str(), slug), + PageCategory::Dummy => Dummy::from_sulg(slug, *locale).url().to_owned(), + PageCategory::Curriculum => format!("/{}/curriculum/{}/", locale.as_url_str(), slug), + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_url_to_path() -> Result<(), UrlError> { + let url = "/en-US/docs/Web/HTML"; + let (path, locale, _typ) = url_path_to_path_buf(url)?; + assert_eq!(locale, Locale::EnUs); + assert_eq!(path, PathBuf::from("web/html")); + Ok(()) + } + + #[test] + fn test_from_url() { + let url = "/en-US/docs/Web"; + let (locale, url) = strip_locale_from_url(url); + assert_eq!(Some(Locale::EnUs), locale); + assert_eq!("/docs/Web", url); + } +} diff --git a/crates/rari-doc/src/specs.rs b/crates/rari-doc/src/specs.rs new file mode 100644 index 00000000..d4952e86 --- /dev/null +++ b/crates/rari-doc/src/specs.rs @@ -0,0 +1,91 @@ +use once_cell::sync::Lazy; +use rari_data::specs::{BCDSpecUrls, WebSpecs}; +use rari_types::globals::{data_dir, json_spec_data_lookup}; +use serde::Serialize; +use tracing::warn; + +use crate::utils::deduplicate; + +#[derive(Debug, Clone, Default)] +pub struct SpecsData { + pub web_specs: WebSpecs, + pub bcd_spec_urls: BCDSpecUrls, +} + +pub static SPECS: Lazy = Lazy::new(|| { + let web_specs = WebSpecs::from_file(&data_dir().join("web-specs/package/index.json")) + .map_err(|e| { + warn!("{e:?}"); + e + }) + .ok(); + let bcd_spec_urls = + match BCDSpecUrls::from_file(&data_dir().join("@mdn/browser-compat-data/spec_urls.json")) { + Ok(ok) => Some(ok), + Err(e) => { + warn!("{e:?}"); + None + } + }; + match (web_specs, bcd_spec_urls) { + (Some(web_specs), Some(bcd_spec_urls)) => SpecsData { + web_specs, + bcd_spec_urls, + }, + _ => Default::default(), + } +}); + +#[derive(Debug, Clone, Serialize, Default)] +pub struct Specification { + #[serde(rename = "bcdSpecificationURL")] + pub bcd_specification_url: String, + pub title: &'static str, +} + +pub fn extract_specifications(query: &[String], spec_urls: &[String]) -> Vec { + let mut all_spec_urls: Vec<&String> = vec![]; + if !query.is_empty() && spec_urls.is_empty() { + for q in query { + if let Some(urls) = SPECS.bcd_spec_urls.specs_urls_by_key.get(q) { + all_spec_urls.extend(urls.iter()) + } else { + // no spec_urls found for the full key os we check if q is a prefix of some keys. + // e.g. javascript.operators + all_spec_urls.extend( + SPECS + .bcd_spec_urls + .specs_urls_by_key + .iter() + .filter_map(|(k, v)| if k.starts_with(q) { Some(v) } else { None }) + .flatten(), + ) + } + } + } + all_spec_urls.extend(spec_urls.iter()); + all_spec_urls = deduplicate(all_spec_urls); + all_spec_urls + .into_iter() + .map(|url| { + let url_no_hash = &url[..url.find('#').unwrap_or(url.len())]; + if let Some(spec) = SPECS.web_specs.get_spec(url_no_hash) { + Specification { + bcd_specification_url: url.to_string(), + title: spec.title.as_str(), + } + } else { + match json_spec_data_lookup().get(url_no_hash) { + Some(title) => Specification { + bcd_specification_url: url.to_string(), + title: title.as_str(), + }, + None => Specification { + bcd_specification_url: url.to_string(), + title: "Unknown specification", + }, + } + } + }) + .collect() +} diff --git a/crates/rari-doc/src/templ/api.rs b/crates/rari-doc/src/templ/api.rs new file mode 100644 index 00000000..17855266 --- /dev/null +++ b/crates/rari-doc/src/templ/api.rs @@ -0,0 +1,92 @@ +use std::path::PathBuf; + +use percent_encoding::utf8_percent_encode; +use rari_md::anchor::anchorize; +use rari_types::globals::{deny_warnings, settings}; +use rari_types::locale::Locale; + +use crate::docs::page::{Page, PageLike, PageReader}; +use crate::error::DocError; +use crate::html::links::render_link; +use crate::percent::PATH_SEGMENT; +use crate::redirects::resolve_redirect; +use crate::utils::COLLATOR; +use crate::walker::walk_builder; + +pub struct RariApi {} +impl RariApi { + pub fn anchorize(content: &str) -> String { + anchorize(content) + } + + pub fn live_sample_base_url() -> &'static str { + &settings().live_samples_base_url + } + pub fn get_page(url: &str) -> Result { + let redirect = resolve_redirect(url); + let url = match redirect.as_ref() { + Some(redirect) if deny_warnings() => { + return Err(DocError::RedirectedLink { + from: url.to_string(), + to: redirect.to_string(), + }) + } + Some(redirect) => redirect, + None => url, + }; + Page::page_from_url_path(url).map_err(Into::into) + } + + pub fn get_sub_pages(url: &str, depth: Option) -> Result, DocError> { + let redirect = resolve_redirect(url); + let url = match redirect.as_ref() { + Some(redirect) if deny_warnings() => { + return Err(DocError::RedirectedLink { + from: url.to_string(), + to: redirect.to_string(), + }) + } + Some(redirect) => redirect, + None => url, + }; + let doc = Page::page_from_url_path(url)?; + if let Some(folder) = doc.full_path().parent() { + let sub_folders = walk_builder(&[folder], None)? + .max_depth(depth) + .build() + .filter_map(|f| f.ok()) + .filter(|f| f.file_type().map(|ft| ft.is_file()).unwrap_or(false)) + .map(|f| f.into_path()) + .collect::>(); + + let mut sub_pages = sub_folders + .iter() + .map(Page::read) + .collect::, DocError>>()?; + sub_pages.sort_by(|a, b| COLLATOR.with(|c| c.compare(a.title(), b.title()))); + return Ok(sub_pages); + } + Ok(vec![]) + } + + pub fn decode_uri_component(component: &str) -> String { + utf8_percent_encode(component, PATH_SEGMENT).to_string() + } + + pub fn interactive_examples_base_url() -> &'static str { + "https://interactive-examples.mdn.mozilla.net/" + } + + pub fn link( + link: &str, + locale: Option<&Locale>, + content: Option<&str>, + code: bool, + title: Option<&str>, + with_badge: bool, + ) -> Result { + let mut out = String::new(); + render_link(&mut out, link, locale, content, code, title, with_badge)?; + Ok(out) + } +} diff --git a/crates/rari-doc/src/templ/macros/badges.rs b/crates/rari-doc/src/templ/macros/badges.rs new file mode 100644 index 00000000..c19440d7 --- /dev/null +++ b/crates/rari-doc/src/templ/macros/badges.rs @@ -0,0 +1,60 @@ +use rari_templ_func::rari_f; +use rari_types::locale::Locale; + +use crate::error::DocError; + +#[rari_f] +pub fn experimental() -> Result { + let mut out = String::new(); + write_experimental(&mut out, &env.locale)?; + Ok(out) +} + +#[rari_f] +pub fn non_standard() -> Result { + let mut out = String::new(); + write_non_standard(&mut out, &env.locale)?; + Ok(out) +} + +#[rari_f] +pub fn deprecated() -> Result { + let mut out = String::new(); + write_deprecated(&mut out, &env.locale)?; + Ok(out) +} + +pub fn write_experimental(out: &mut impl std::fmt::Write, locale: &Locale) -> std::fmt::Result { + let title = rari_l10n::l10n("experimental_badge_title", locale); + let abbreviation = rari_l10n::l10n("experimental_badge_abbreviation", locale); + + write_badge(out, title, abbreviation, "experimental") +} + +pub fn write_non_standard(out: &mut impl std::fmt::Write, locale: &Locale) -> std::fmt::Result { + let title = rari_l10n::l10n("non_standard_badge_title", locale); + let abbreviation = rari_l10n::l10n("non_standard_badge_abbreviation", locale); + + write_badge(out, title, abbreviation, "nonstandard") +} + +pub fn write_deprecated(out: &mut impl std::fmt::Write, locale: &Locale) -> std::fmt::Result { + let title = rari_l10n::l10n("deprecated_badge_title", locale); + let abbreviation = rari_l10n::l10n("deprecated_badge_abbreviation", locale); + + write_badge(out, title, abbreviation, "deprecated") +} + +pub fn write_badge( + out: &mut impl std::fmt::Write, + title: &str, + abbreviation: &str, + typ: &str, +) -> std::fmt::Result { + write!( + out, + r#" +{abbreviation} +"# + ) +} diff --git a/crates/rari-doc/src/templ/macros/compat.rs b/crates/rari-doc/src/templ/macros/compat.rs new file mode 100644 index 00000000..8073e003 --- /dev/null +++ b/crates/rari-doc/src/templ/macros/compat.rs @@ -0,0 +1,64 @@ +use rari_templ_func::rari_f; + +use crate::error::DocError; + +#[rari_f] +pub fn compat() -> Result { + let multiple = env.browser_compat.len() > 1; + Ok(env.browser_compat.iter().map(|query| format!( + r#"

+If you're able to see this, something went wrong on this page. +
"#)).collect::>().join("\n")) +} + +#[cfg(test)] +mod test { + use rari_types::RariEnv; + + use crate::error::DocError; + use crate::templ::render::render; + + #[test] + fn test_compat_none() -> Result<(), DocError> { + let env = RariEnv { + ..Default::default() + }; + let out = render(&env, r#"{{ compat }}"#)?; + assert_eq!(out, r#""#); + Ok(()) + } + + #[test] + fn test_compat() -> Result<(), DocError> { + let env = RariEnv { + browser_compat: &["javascript.builtins.Array.concat".into()], + ..Default::default() + }; + let exp = r#"
+If you're able to see this, something went wrong on this page. +
"#; + let out = render(&env, r#"{{ compat }}"#)?; + assert_eq!(out, exp); + Ok(()) + } + + #[test] + fn test_compat_multiple() -> Result<(), DocError> { + let env = RariEnv { + browser_compat: &[ + "javascript.builtins.Array.concat".into(), + "javascript.builtins.Array.filter".into(), + ], + ..Default::default() + }; + let exp = r#"
+If you're able to see this, something went wrong on this page. +
+
+If you're able to see this, something went wrong on this page. +
"#; + let out = render(&env, r#"{{ compat }}"#)?; + assert_eq!(out, exp); + Ok(()) + } +} diff --git a/crates/rari-doc/src/templ/macros/csssyntax.rs b/crates/rari-doc/src/templ/macros/csssyntax.rs new file mode 100644 index 00000000..40edbf6f --- /dev/null +++ b/crates/rari-doc/src/templ/macros/csssyntax.rs @@ -0,0 +1,59 @@ +use std::collections::HashMap; + +use css_syntax::syntax::{write_formal_syntax, CssType, LinkedToken}; +use once_cell::sync::Lazy; +use rari_templ_func::rari_f; +use tracing::error; + +use crate::error::DocError; + +static TOOLTIPS: Lazy> = Lazy::new(|| { + [(LinkedToken::Asterisk, "Asterisk: the entity may occur zero, one or several times".to_string()), + (LinkedToken::Plus, "Plus: the entity may occur one or several times".to_string()), + (LinkedToken::QuestionMark, "Question mark: the entity is optional".to_string()), + (LinkedToken::CurlyBraces, "Curly braces: encloses two integers defining the minimal and maximal numbers of occurrences of the entity, or a single integer defining the exact number required".to_string()), + (LinkedToken::HashMark, "Hash mark: the entity is repeated one or several times, each occurence separated by a comma".to_string()), + (LinkedToken::ExclamationPoint,"Exclamation point: the group must produce at least one value".to_string()), + (LinkedToken::Brackets, "Brackets: enclose several entities, combinators, and multipliers to transform them as a single component".to_string()), + (LinkedToken::SingleBar, "Single bar: exactly one of the entities must be present".to_string()), + (LinkedToken::DoubleBar, "Double bar: one or several of the entities must be present, in any order".to_string()), + (LinkedToken::DoubleAmpersand, "Double ampersand: all of the entities must be present, in any order".to_string())].into_iter().collect() +}); + +#[rari_f] +pub fn csssyntax() -> Result { + let page_type = env.page_type; + let mut slug_rev_iter = env.slug.rsplitn(3, '/'); + let name = slug_rev_iter.next().unwrap(); + let typ = match page_type { + rari_types::fm_types::PageType::CssAtRule => CssType::AtRule(name), + rari_types::fm_types::PageType::CssAtRuleDescriptor => { + CssType::AtRuleDescriptor(name, slug_rev_iter.next().unwrap()) + } + rari_types::fm_types::PageType::CssCombinator => todo!(), + rari_types::fm_types::PageType::CssFunction => CssType::Function(name), + rari_types::fm_types::PageType::CssKeyword => todo!(), + rari_types::fm_types::PageType::CssMediaFeature => todo!(), + rari_types::fm_types::PageType::CssModule => todo!(), + rari_types::fm_types::PageType::CssProperty => CssType::Property(name), + rari_types::fm_types::PageType::CssPseudoClass => todo!(), + rari_types::fm_types::PageType::CssPseudoElement => todo!(), + rari_types::fm_types::PageType::CssSelector => todo!(), + rari_types::fm_types::PageType::CssShorthandProperty => CssType::ShorthandProperty(name), + rari_types::fm_types::PageType::CssType => CssType::Type(name), + _ => { + error!("No Css Page: {}", env.slug); + return Err(DocError::CssPageTypeRequired); + } + }; + + Ok(write_formal_syntax( + typ, + env.locale.as_url_str(), + &format!( + "/{}/docs/Web/CSS/Value_definition_syntax", + env.locale.as_url_str() + ), + &TOOLTIPS, + )?) +} diff --git a/crates/rari-doc/src/templ/macros/cssxref.rs b/crates/rari-doc/src/templ/macros/cssxref.rs new file mode 100644 index 00000000..846164ec --- /dev/null +++ b/crates/rari-doc/src/templ/macros/cssxref.rs @@ -0,0 +1,137 @@ +use rari_templ_func::rari_f; +use rari_types::fm_types::PageType; + +use crate::docs::page::PageLike; +use crate::error::DocError; +use crate::templ::api::RariApi; + +#[rari_f] +pub fn cssxref( + name: String, + display_name: Option, + anchor: Option, +) -> Result { + let maybe_display_name = display_name + .as_deref() + .or_else(|| name.rsplit_once('/').map(|(_, s)| s)) + .unwrap_or(name.as_str()); + let mut slug = name + .strip_prefix("<") + .unwrap_or(name.strip_prefix('<').unwrap_or(name.as_str())); + slug = slug + .strip_suffix(">") + .unwrap_or(slug.strip_suffix('>').unwrap_or(slug)); + slug = slug.strip_suffix("()").unwrap_or(slug); + + let slug = match name.as_str() { + "<color>" => "color_value", + "<flex>" => "flex_value", + "<overflow>" => "overflow_value", + "<position>" => "position_value", + ":host()" => ":host_function", + "fit-content()" => "fit_content_function", + _ => slug, + }; + + let url = format!( + "/{}/docs/Web/CSS/{slug}{}", + env.locale.as_url_str(), + anchor.as_deref().unwrap_or_default() + ); + + let display_name = if display_name.is_some() { + maybe_display_name.to_string() + } else if let Ok(doc) = RariApi::get_page(&url) { + match doc.page_type() { + PageType::CssFunction if !maybe_display_name.ends_with("()") => { + format!("{maybe_display_name}()") + } + PageType::CssType + if !(maybe_display_name.starts_with("<") + && maybe_display_name.ends_with(">")) => + { + format!("<{maybe_display_name}>") + } + _ => maybe_display_name.to_string(), + } + } else { + maybe_display_name.to_string() + }; + + Ok(format!(r#"{display_name}"#)) +} +/* +<% +/* + Inserts a link to a CSS entity documentation + Appropriate styling is applied. + + This template handles CSS data types and CSS functions gracefully by + automatically adding arrow brackets or round brackets, respectively. + + For the ease of linking to CSS descriptors and functions, if only one + parameter is specified and it contains a slash, the displayed link name + will strip the last slash and any content before it. + + Parameters: + $0 - API name to refer to + $1 - name of the link to display (optional) + $2 - anchor within the article to jump to of the form '#xyz' (optional) + + Examples: + {{cssxref("background")}} => + background + {{cssxref("length")}} => + <length> + {{cssxref("gradient/linear-gradient")}} => + linear-gradient() + {{cssxref("calc()")}} => + calc() + {{cssxref("margin-top", "top margin")}} => + top margin + {{cssxref("attr()", "", "#values")}} => + attr() +*/ + +const lang = env.locale; +let url = ""; +let urlWithoutAnchor = ""; +let displayName = ($1 || $0.slice($0.lastIndexOf("/") + 1)); + +// Deal with CSS data types and functions by removing <> and () +let slug = $0.replace(/<(.*)>/g, "$1") + .replace(/\(\)/g, ""); + +// Special case , , , and +if (/^<(color|flex|overflow|position)>$/.test($0)) { + slug += "_value"; +} + +// Special case :host() and fit-content() +if (/^(:host|fit-content)\(\)$/.test($0)) { + slug += "_function"; +} + +const basePath = `/${lang}/docs/Web/CSS/`; +urlWithoutAnchor = basePath + slug; +url = urlWithoutAnchor + $2; + +const thisPage = (!$1 || !$2) ? + wiki.getPage(`/en-US/docs/Web/CSS/${slug}`) : + null; + +if (!$1) { + // Append parameter brackets to CSS functions + if ((thisPage.pageType === "css-function") && !displayName.endsWith("()")) { + displayName += "()"; + } + // Enclose CSS data types in arrow brackets + if ((thisPage.pageType === "css-type") && !/^<.+>$/.test(displayName)) { + displayName = "<" + displayName + ">"; + } +} + +const entry = web.smartLink(url, "", `${displayName}`, $0, basePath); + +%><%- entry %> +*/ diff --git a/crates/rari-doc/src/templ/macros/embedinteractiveexample.rs b/crates/rari-doc/src/templ/macros/embedinteractiveexample.rs new file mode 100644 index 00000000..73cbdd96 --- /dev/null +++ b/crates/rari-doc/src/templ/macros/embedinteractiveexample.rs @@ -0,0 +1,30 @@ +use rari_l10n::l10n; +use rari_templ_func::rari_f; + +use crate::error::DocError; +use crate::templ::api::RariApi; + +/// Embeds a live sample from a interactive-examples.mdn.mozilla.net GitHub page +/// +/// Parameters: +/// $0 - The URL of interactive-examples.mdn.mozilla.net page (relative) +/// $1 - Optional custom height class to set on iframe element +/// +/// Example call {{EmbedInteractiveExample("pages/css/animation.html", "taller")}} +#[rari_f] +pub fn embed_interactive_example(path: String, height: Option) -> Result { + let title = l10n("interactive_example_cta", &env.locale); + let url = format!("{}{path}", RariApi::interactive_examples_base_url()); + let height_class = match height.as_deref() { + h @ Some("shorter" | "taller" | "tabbed-shorter" | "tabbed-standard" | "tabbed-taller") => { + h.unwrap() + } + None if path.contains("/js/") => "js", + None | Some(_) => "default", + }; + let id = RariApi::anchorize(title); + Ok(format!( + r#"

{title}

+"# + )) +} diff --git a/crates/rari-doc/src/templ/macros/glossary.rs b/crates/rari-doc/src/templ/macros/glossary.rs new file mode 100644 index 00000000..46e850fb --- /dev/null +++ b/crates/rari-doc/src/templ/macros/glossary.rs @@ -0,0 +1,18 @@ +use rari_templ_func::rari_f; + +use crate::error::DocError; +use crate::templ::api::RariApi; +use crate::utils::trim_ws; + +#[rari_f] +pub fn glossary(term_name: String, display_name: Option) -> Result { + let url = format!("/Glossary/{}", trim_ws(&term_name).replace(' ', "_")); + RariApi::link( + &url, + Some(&env.locale), + Some(&display_name.unwrap_or(term_name)), + false, + None, + false, + ) +} diff --git a/crates/rari-doc/src/templ/macros/jsxref.rs b/crates/rari-doc/src/templ/macros/jsxref.rs new file mode 100644 index 00000000..7ef69cd4 --- /dev/null +++ b/crates/rari-doc/src/templ/macros/jsxref.rs @@ -0,0 +1,47 @@ +use rari_templ_func::rari_f; + +use crate::error::DocError; +use crate::templ::api::RariApi; + +#[rari_f] +pub fn jsxref( + api_name: String, + display: Option, + anchor: Option, + no_code: Option, +) -> Result { + let global_objects = "Global_Objects"; + let display = display.as_deref().unwrap_or(api_name.as_str()); + let mut url = format!("/{}/docs/Web/JavaScript/Reference/", &env.locale); + let mut base_path = url.clone(); + + let mut slug = api_name.replace("()", "").replace(".prototype.", "."); + if !slug.contains("..") && slug.contains('.') { + // Handle try...catch case + slug = slug.replace('.', "/"); + } + + let page_url = format!("{url}{slug}"); + let object_page_url = format!("{url}{global_objects}/{slug}"); + + let page = RariApi::get_page(&page_url); + let object_page = RariApi::get_page(&object_page_url); + if let Ok(_page) = page { + url.push_str(&slug) + } else if let Ok(_object_page) = object_page { + base_path.push_str(&[global_objects, "/"].join("")); + url.push_str(&[global_objects, "/", &slug].join("")); + } else { + url.push_str(&RariApi::decode_uri_component(&api_name)); + } + + if let Some(anchor) = anchor { + if !anchor.starts_with('#') { + url.push('#'); + } + url.push_str(&anchor); + } + + let code = no_code.unwrap_or_default() == 0; + RariApi::link(&url, None, Some(display), code, None, false) +} diff --git a/crates/rari-doc/src/templ/macros/links.rs b/crates/rari-doc/src/templ/macros/links.rs new file mode 100644 index 00000000..9c21b11e --- /dev/null +++ b/crates/rari-doc/src/templ/macros/links.rs @@ -0,0 +1,126 @@ +use rari_l10n::l10n_json_data; +use rari_templ_func::rari_f; + +use crate::docs::page::PageLike; +use crate::error::DocError; +use crate::templ::api::RariApi; + +/// Creates a link to a page. +/// +/// Parameters: +/// $0 Page link +#[rari_f] +pub fn doc_link( + url: Option, + content: Option, + code: Option, +) -> Result { + let url = url.map(|url| format!("/{}/docs{url}", env.locale.as_url_str())); + let url = url.as_deref().unwrap_or(env.url); + let page = RariApi::get_page(url)?; + link_internal( + page.url(), + &page, + content.as_deref(), + code.unwrap_or_default(), + ) +} +/// Creates a link to a page. +/// +/// Parameters: +/// $0 Page link +#[rari_f] +pub fn link( + url: Option, + content: Option, + code: Option, +) -> Result { + let url = url.as_deref().unwrap_or(env.url); + let page = RariApi::get_page(url)?; + link_internal( + page.url(), + &page, + content.as_deref(), + code.unwrap_or_default(), + ) +} + +/// Crates a link to a CSP header page. +#[rari_f] +pub fn csp(directive: String) -> Result { + let url = format!( + "/{}/docs/Web/HTTP/Headers/Content-Security-Policy/{directive}", + env.locale.as_url_str() + ); + let page = RariApi::get_page(&url)?; + link_internal(page.url(), &page, Some(&directive), true) +} + +/// Crates a link to a HTTP header page. +#[rari_f] +pub fn http_header(slug: String, content: Option) -> Result { + let url = format!("/{}/docs/Web/HTTP/Headers/{slug}", env.locale.as_url_str()); + let page = RariApi::get_page(&url)?; + link_internal(page.url(), &page, content.as_deref(), true) +} + +#[rari_f] +pub fn rfc( + number: i64, + content: Option, + anchor: Option, +) -> Result { + let content = content.and_then(|c| if c.is_empty() { None } else { Some(c) }); + let anchor = anchor.and_then(|a| if a.is_empty() { None } else { Some(a) }); + let (content, anchor): (String, String) = match (content, anchor) { + (None, None) => Default::default(), + (None, Some(anchor)) => ( + format!( + ", {} {anchor}", + l10n_json_data("Common", "section", &env.locale).unwrap_or("Section") + ), + format!("#section-{anchor}"), + ), + (Some(content), None) => (format!(": {content}"), Default::default()), + (Some(content), Some(anchor)) => ( + format!( + ": {content}, {} {anchor}", + l10n_json_data("Common", "section", &env.locale).unwrap_or("Section") + ), + format!("#section-{anchor}"), + ), + }; + Ok(format!( + r#"RFC {number}{content}"# + )) +} + +pub fn link_internal( + url: &str, + page: &impl PageLike, + content: Option<&str>, + code: bool, +) -> Result { + let content = content.unwrap_or(page.short_title().unwrap_or(page.title())); + Ok(if code { + format!(r#"{content}"#) + } else { + format!(r#"{content}"#) + }) +} +#[cfg(test)] +mod test { + + use crate::error::DocError; + use crate::templ::render::render; + + #[test] + fn test_link() -> Result<(), DocError> { + let env = rari_types::RariEnv { + ..Default::default() + }; + let out = render(&env, r#"{{ link("/en-US/docs/basic") }}"#)?; + assert_eq!(out, r#"The Basic Page"#); + Ok(()) + } +} diff --git a/crates/rari-doc/src/templ/macros/listsubpages.rs b/crates/rari-doc/src/templ/macros/listsubpages.rs new file mode 100644 index 00000000..28fe2bc0 --- /dev/null +++ b/crates/rari-doc/src/templ/macros/listsubpages.rs @@ -0,0 +1,162 @@ +use std::collections::BTreeMap; +use std::fmt::Write; +use std::str::FromStr; + +use rari_templ_func::rari_f; +use rari_types::fm_types::{FeatureStatus, PageType}; +use rari_types::locale::Locale; + +use crate::docs::page::PageLike; +use crate::error::DocError; +use crate::templ::api::RariApi; +use crate::templ::macros::badges::{write_deprecated, write_experimental, write_non_standard}; + +/// List sub pages +/// +/// Parameters: +/// $0 Base url +/// $1 Title +/// $3 Page types +#[rari_f] +pub fn list_sub_pages( + url: Option, + title: Option, + page_types: Option, +) -> Result { + let url = url.as_deref().unwrap_or(env.url); + let title = title.as_deref().unwrap_or(env.title); + let mut out = String::new(); + write!(out, "
{}
    ", title)?; + list_sub_pages_internal( + &mut out, + url, + &env.locale, + page_types + .map(|pt| { + pt.split(',') + .filter_map(|pt| PageType::from_str(pt.trim()).ok()) + .collect::>() + }) + .as_deref() + .unwrap_or_default(), + )?; + out.push_str("
"); + + Ok(out) +} + +#[rari_f] +pub fn list_sub_pages_grouped( + url: Option, + title: Option, + page_types: Option, +) -> Result { + let url = url.as_deref().unwrap_or(env.url); + let title = title.as_deref().unwrap_or(env.title); + let mut out = String::new(); + out.push_str("
"); + out.push_str(&html_escape::encode_safe(title)); + out.push_str("
    "); + list_sub_pages_grouped_internal( + &mut out, + url, + &env.locale, + page_types + .map(|pt| { + pt.split(',') + .filter_map(|pt| PageType::from_str(pt.trim()).ok()) + .collect::>() + }) + .as_deref() + .unwrap_or_default(), + )?; + out.push_str("
"); + Ok(out) +} + +fn write_li_with_badges( + out: &mut impl Write, + page: &impl PageLike, + locale: &Locale, +) -> std::fmt::Result { + write!( + out, + "
  • {}", + page.url(), + html_escape::encode_safe(page.short_title().unwrap_or(page.title())) + )?; + if page.status().contains(&FeatureStatus::Experimental) { + write_experimental(out, locale)?; + } + if page.status().contains(&FeatureStatus::NonStandard) { + write_non_standard(out, locale)?; + } + if page.status().contains(&FeatureStatus::Deprecated) { + write_deprecated(out, locale)?; + } + write!(out, "
  • ") +} + +pub fn list_sub_pages_internal( + out: &mut impl Write, + url: &str, + locale: &Locale, + page_types: &[PageType], +) -> Result<(), DocError> { + let sub_pages = RariApi::get_sub_pages(url, None)?; + + for sub_page in sub_pages { + if !page_types.is_empty() && !page_types.contains(&sub_page.page_type()) { + continue; + } + write_li_with_badges(out, &sub_page, locale)?; + } + Ok(()) +} + +pub fn list_sub_pages_grouped_internal( + out: &mut String, + url: &str, + locale: &Locale, + page_types: &[PageType], +) -> Result<(), DocError> { + let sub_pages = RariApi::get_sub_pages(url, None)?; + + let mut grouped = BTreeMap::new(); + for sub_page in sub_pages.iter() { + if !page_types.is_empty() && !page_types.contains(&sub_page.page_type()) { + continue; + } + let title = sub_page.title(); + let prefix_index = if !title.is_empty() { + title[1..].find('-').map(|i| i + 1) + } else { + None + }; + if let Some(prefix) = prefix_index.map(|i| &title[..i]) { + grouped + .entry(prefix) + .and_modify(|l: &mut Vec<_>| l.push(sub_page)) + .or_insert(vec![sub_page]); + } else { + grouped.insert(sub_page.title(), vec![sub_page]); + } + } + for (prefix, group) in grouped { + let keep_group = group.len() > 3; + if keep_group { + out.push_str("
  • "); + out.push_str(prefix); + out.push_str("}-*
      "); + } + for sub_page in group { + write_li_with_badges(out, sub_page, locale)?; + } + if keep_group { + out.push_str("
  • "); + } + } + Ok(()) +} +#[cfg(test)] +mod test {} diff --git a/crates/rari-doc/src/templ/macros/livesample.rs b/crates/rari-doc/src/templ/macros/livesample.rs new file mode 100644 index 00000000..d527dc8f --- /dev/null +++ b/crates/rari-doc/src/templ/macros/livesample.rs @@ -0,0 +1,58 @@ +use std::fmt::Write; + +use rari_templ_func::rari_f; +use rari_types::AnyArg; + +use crate::error::DocError; +use crate::templ::api::RariApi; +use crate::utils::trim_ws; + +#[allow(clippy::too_many_arguments)] +#[rari_f] +pub fn live_sample( + id: String, + width: Option, + height: Option, + _deprecated_3: Option, + _deprecated_4: Option, + _deprecated_5: Option, + allowed_features: Option, +) -> Result { + let title = trim_ws(&id.replace('_', " ")); + let id = RariApi::anchorize(&id); + let mut out = String::new(); + out.push_str(r#"
    "#); + Ok(out) +} diff --git a/crates/rari-doc/src/templ/macros/mod.rs b/crates/rari-doc/src/templ/macros/mod.rs new file mode 100644 index 00000000..a15982d4 --- /dev/null +++ b/crates/rari-doc/src/templ/macros/mod.rs @@ -0,0 +1,49 @@ +pub mod badges; +pub mod compat; +pub mod csssyntax; +pub mod cssxref; +pub mod embedinteractiveexample; +pub mod glossary; +pub mod jsxref; +pub mod links; +pub mod listsubpages; +pub mod livesample; +pub mod specification; + +use rari_types::globals::deny_warnings; +use rari_types::{Arg, RariEnv}; + +use crate::error::DocError; + +pub fn invoke(env: &RariEnv, ident: &str, args: Vec>) -> Result { + (match ident.to_lowercase().as_str() { + "compat" => compat::compat_any, + "specifications" => specification::specification_any, + "glossary" => glossary::glossary_any, + "cssxref" => cssxref::cssxref_any, + "csssyntax" => csssyntax::csssyntax_any, + "embedinteractiveexample" => embedinteractiveexample::embed_interactive_example_any, + "listsubpages" => listsubpages::list_sub_pages_any, + "listsubpagesgrouped" => listsubpages::list_sub_pages_grouped_any, + "embedlivesample" => livesample::live_sample_any, + + // badges + "experimentalbadge" | "experimental_inline" => badges::experimental_any, + "nonstandardbadge" | "non-standard_inline" => badges::non_standard_any, + "deprecated_inline" => badges::deprecated_any, + + // links + "jsxref" => jsxref::jsxref_any, + "link" => links::link_any, + "doclink" => links::doc_link_any, + "csp" => links::csp_any, + "rfc" => links::rfc_any, + + // ignore + "ccsref" | "glossarysidebar" => return Ok(Default::default()), + + // unknown + _ if deny_warnings() => return Err(DocError::UnknownMacro(ident.to_string())), + _ => return Ok(format!("unsupported templ: {ident}")), // + })(env, args) +} diff --git a/crates/rari-doc/src/templ/macros/specification.rs b/crates/rari-doc/src/templ/macros/specification.rs new file mode 100644 index 00000000..6ddb90e7 --- /dev/null +++ b/crates/rari-doc/src/templ/macros/specification.rs @@ -0,0 +1,14 @@ +use rari_templ_func::rari_f; + +use crate::error::DocError; + +#[rari_f] +pub fn specification() -> Result { + let queries = env.browser_compat.join(","); + let specs = env.spec_urls.join(","); + Ok(format!( + r#"
    +If you're able to see this, something went wrong on this page. +
    "# + )) +} diff --git a/crates/rari-doc/src/templ/mod.rs b/crates/rari-doc/src/templ/mod.rs new file mode 100644 index 00000000..a3f24acf --- /dev/null +++ b/crates/rari-doc/src/templ/mod.rs @@ -0,0 +1,4 @@ +pub mod api; +pub mod macros; +pub mod parser; +pub mod render; diff --git a/crates/rari-doc/src/templ/parser.rs b/crates/rari-doc/src/templ/parser.rs new file mode 100644 index 00000000..9a54e051 --- /dev/null +++ b/crates/rari-doc/src/templ/parser.rs @@ -0,0 +1,266 @@ +use std::fmt::Write; + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use pest::iterators::Pair; +use pest::Parser; +use rari_types::{Arg, Quotes}; + +use crate::error::DocError; + +#[derive(pest_derive::Parser)] +#[grammar = "templ/rari-templ.pest"] +pub struct RariTempl; + +#[derive(Debug)] +pub struct TextToken { + pub start: usize, + pub end: usize, +} + +impl From> for TextToken { + fn from(pair: Pair<'_, Rule>) -> Self { + Self { + start: pair.as_span().start(), + end: pair.as_span().end(), + } + } +} + +#[derive(Debug)] +pub struct MacroToken { + pub start: usize, + pub end: usize, + pub ident: String, + pub args: Vec>, +} + +impl From> for MacroToken { + fn from(pair: Pair<'_, Rule>) -> Self { + let start = pair.as_span().start(); + let end = pair.as_span().end(); + let m = pair.into_inner().next().unwrap(); + let (ident, args) = match m.as_rule() { + Rule::fn_call => { + let mut inner = m.into_inner(); + let ident = inner.next().unwrap().as_span().as_str().to_string(); + let args = inner + .next() + .map(|args| args.into_inner().map(to_arg).collect::>()) + .unwrap_or_default(); + (ident, args) + } + Rule::ident => { + let ident = m.as_span().as_str().to_string(); + (ident, vec![]) + } + _ => ("noop".to_string(), vec![]), + }; + + Self { + start, + end, + ident, + args, + } + } +} + +#[derive(Debug)] +pub enum Token { + Text(TextToken), + Macro(MacroToken), +} + +fn to_arg(pair: Pair<'_, Rule>) -> Option { + match pair.as_rule() { + Rule::sq_string => Some(Arg::String( + pair.as_span().as_str().to_string(), + Quotes::Single, + )), + Rule::dq_string => Some(Arg::String( + pair.as_span().as_str().to_string(), + Quotes::Double, + )), + Rule::bq_string => Some(Arg::String( + pair.as_span().as_str().to_string(), + Quotes::Back, + )), + Rule::int => Some(Arg::Int( + pair.as_span().as_str().parse().unwrap_or_default(), + )), + Rule::float => Some(Arg::Float( + pair.as_span().as_str().parse().unwrap_or_default(), + )), + Rule::boolean => Some(Arg::Bool( + pair.as_span().as_str().parse().unwrap_or_default(), + )), + _ => None, + } +} + +pub(crate) fn parse(input: &str) -> Result, DocError> { + let mut p = + RariTempl::parse(Rule::doc, input).map_err(|e| DocError::PestError(e.to_string()))?; + let tokens = p + .next() + .unwrap() + .into_inner() + .filter_map(|t| match t.as_rule() { + Rule::text => Some(Token::Text(t.into())), + Rule::macro_tag => Some(Token::Macro(t.into())), + _ => None, + }) + .collect(); + Ok(tokens) +} + +fn encode_macro(s: &str, out: &mut String) -> Result<(), DocError> { + Ok(write!( + out, + "!::::{}::::!", + STANDARD.encode(&s[2..(s.len() - 2)]) + )?) +} + +fn decode_macro(s: &str, out: &mut String) -> Result<(), DocError> { + Ok(write!( + out, + "{{{{{}}}}}", + std::str::from_utf8(&STANDARD.decode(s)?)? + )?) +} + +pub(crate) fn encode_ks(input: &str) -> Result<(String, i32), DocError> { + let tokens = parse(input)?; + let mut encoded = String::with_capacity(input.len()); + let mut num_macros = 0; + for token in tokens { + match token { + Token::Text(t) => { + encoded.push_str(&input[t.start..t.end]); + } + Token::Macro(t) => { + num_macros += 1; + encode_macro(&input[t.start..t.end], &mut encoded)? + } + } + } + Ok((encoded, num_macros)) +} + +fn _strip_escape_residues(s: &str) -> &str { + let s = s.strip_prefix(">").or(s.strip_prefix('>')).unwrap_or(s); + let s = s + .strip_suffix("!<") + .or(s.strip_suffix("!<")) + .unwrap_or(s); + s +} + +pub(crate) fn decode_ks(input: &str) -> Result<(String, i32), DocError> { + let mut decoded = String::with_capacity(input.len()); + let mut num_macros = 0; + // We're splitting only by `!-- ks___` because e.g. ks in a
     will be escaped.
    +    if !input.contains("!::::") {
    +        return Ok((input.to_string(), 0));
    +    }
    +    let mut frags = vec![];
    +    for frag in input.split("!::::") {
    +        let has_ks = frag.contains("::::!");
    +        for (i, sub_frag) in frag.splitn(2, "::::!").enumerate() {
    +            if i == 0 && has_ks {
    +                num_macros += 1;
    +                frags.push(sub_frag);
    +                //decode_macro(sub_frag, &mut decoded)?;
    +            } else {
    +                //decoded.push_str(strip_escape_residues(sub_frag))
    +                frags.push(sub_frag)
    +            }
    +        }
    +    }
    +    for i in 0..frags.len() {
    +        if i % 2 == 1
    +            && i < frags.len() + 1
    +            && frags[i - 1].ends_with("

    ") + && frags[i + 1].starts_with("

    ") + { + frags[i - 1] = frags[i - 1].strip_suffix("

    ").unwrap(); + frags[i + 1] = frags[i + 1].strip_prefix("

    ").unwrap(); + } + } + + for (i, frag) in frags.iter().enumerate() { + if i % 2 == 1 { + decode_macro(frag, &mut decoded)?; + } else { + decoded.push_str(frag) + } + } + + Ok((decoded, num_macros)) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn basic() { + let p = RariTempl::parse(Rule::doc, r#"Foo {{jsxref("Array") }}bar {{ foo }}"#); + println!("{:#?}", p); + } + + #[test] + fn custom() { + let p = parse(r#"Foo {{jsxref("Array",,1,true) }}bar {{ foo }}"#); + println!("{:#?}", p); + } + + #[test] + fn weird() { + let p = parse( + r#"attribute of an `{{HTMLElement("input","<input type=\"file\">")}}` element"#, + ); + println!("{:#?}", p); + } + + #[test] + fn weird2() { + let p = parse(r#"dasd \{{foo}} 200 {{bar}}"#); + println!("{:#?}", p); + } + + #[test] + fn weird3() { + let p = parse(r#"foo {{foo(0.1)}} bar"#); + println!("{:#?}", p); + } + + #[test] + fn ks_escape() -> Result<(), DocError> { + let ks = r#"Foo {{jsxref("Array",,1,true) }}bar {{ foo }}"#; + let enc = encode_ks(ks)?; + let dec = decode_ks(&enc.0)?; + assert_eq!(ks, dec.0); + Ok(()) + } + + #[test] + fn ks_escape_2() -> Result<(), DocError> { + let ks = r#"<{{jsxref("Array",,1,true) }}>bar {{ foo }}"#; + let enc = encode_ks(ks)?; + let dec = decode_ks(&enc.0)?; + assert_eq!(ks, dec.0); + Ok(()) + } + + #[test] + fn ks_escape_3() -> Result<(), DocError> { + let ks = r#"{{foo}}{{foo-bar}}"#; + let enc = encode_ks(ks)?; + let dec = decode_ks(&enc.0)?; + assert_eq!(ks, dec.0); + Ok(()) + } +} diff --git a/crates/rari-doc/src/templ/rari-templ.pest b/crates/rari-doc/src/templ/rari-templ.pest new file mode 100644 index 00000000..0e276592 --- /dev/null +++ b/crates/rari-doc/src/templ/rari-templ.pest @@ -0,0 +1,71 @@ + +WHITESPACE = _{ " " | "\t" | "\r" | "\n" } + +int = @{ "-" ? ~ ("0" | '1'..'9' ~ '0'..'9' * ) } +float = @{ + "-" ? ~ + ( + "0" ~ "." ~ '0'..'9' + | + '1'..'9' ~ '0'..'9' * ~ "." ~ '0'..'9' + + ) +} +dq_char = { + !("\"" | "\\") ~ ANY + | "\\" ~ ("\"" | "\\" | "/" | "b" | "f" | "n" | "r" | "t") + | "\\" ~ ("u" ~ ASCII_HEX_DIGIT{4}) +} +sq_char = { + !("'" | "\\") ~ ANY + | "\\" ~ ("'" | "\\" | "/" | "b" | "f" | "n" | "r" | "t") + | "\\" ~ ("u" ~ ASCII_HEX_DIGIT{4}) +} +bq_char = { + !("`" | "\\") ~ ANY + | "\\" ~ ( "`" | "\\" | "/" | "b" | "f" | "n" | "r" | "t") + | "\\" ~ ("u" ~ ASCII_HEX_DIGIT{4}) +} +dq_string = @{ (!("\"") ~ dq_char )* } +sq_string = @{ (!("\'") ~ sq_char )* } +bq_string = @{ (!("`") ~ bq_char )* } +double_quoted_string = _{ "\"" ~ dq_string ~ "\""} +single_quoted_string = _{ "\'" ~ sq_string ~ "\'"} +backquoted_quoted_string = _{ "`" ~ bq_string ~ "`"} + +string = _{ + double_quoted_string | + single_quoted_string | + backquoted_quoted_string +} + +boolean = { "true" | "false" } +none = { "" } + + +all_chars = _{'a'..'z' | 'A'..'Z' | "_" | "-" | '0'..'9'} +ident = ${ + ('a'..'z' | 'A'..'Z' | "_") ~ + all_chars* +} + +arg = _{ string | float | int | boolean | none } +kwargs = !{ arg ~ ("," ~ arg )* ~ ","? } +fn_call = !{ ident ~ "(" ~ kwargs? ~ ")" } + +tag_start = _{ "{{" } +tag_end = _{ "}}" } + +macro_tag = ${ + tag_start ~ WHITESPACE* ~ (fn_call | ident) + ~ WHITESPACE* ~ tag_end +} + +dropped_escape = _{ "\\" } +text = ${ ((dropped_escape | !(tag_start)) ~ ANY)+ } +content = @{ + text | + macro_tag +} + +doc = @{ + content* +} diff --git a/crates/rari-doc/src/templ/render.rs b/crates/rari-doc/src/templ/render.rs new file mode 100644 index 00000000..ae4fc15c --- /dev/null +++ b/crates/rari-doc/src/templ/render.rs @@ -0,0 +1,45 @@ +use std::fmt::Write; + +use rari_types::globals::deny_warnings; +use rari_types::RariEnv; +use tracing::{error, span, Level}; + +use super::macros::invoke; +use super::parser::{parse, Token}; +use crate::error::DocError; + +pub fn render(env: &RariEnv, input: &str) -> Result { + let tokens = parse(input)?; + render_tokens(env, tokens, input) +} + +pub fn render_tokens(env: &RariEnv, tokens: Vec, input: &str) -> Result { + let mut out = String::with_capacity(input.len()); + for token in tokens { + match token { + Token::Text(text) => { + let slice = &input[text.start..text.end]; + let mut last = 0; + for (i, _) in slice.match_indices("\\{{") { + out.push_str(&slice[last..i]); + last = i + 1; + } + out.push_str(&slice[last..]) + } + Token::Macro(mac) => { + let ident = &mac.ident; + let span = span!(Level::ERROR, "templ", "{}", &ident); + let _enter = span.enter(); + match invoke(env, &mac.ident, mac.args) { + Ok(rendered) => out.push_str(&rendered), + Err(e) if deny_warnings() => return Err(e), + Err(e) => { + error!("{e}"); + writeln!(&mut out, "{e}")?; + } + }; + } + } + } + Ok(out) +} diff --git a/crates/rari-doc/src/utils.rs b/crates/rari-doc/src/utils.rs new file mode 100644 index 00000000..dc85dca5 --- /dev/null +++ b/crates/rari-doc/src/utils.rs @@ -0,0 +1,225 @@ +use std::cmp::max; +use std::collections::HashSet; +use std::fmt; +use std::marker::PhantomData; +use std::path::Path; +use std::str::FromStr; + +use chrono::NaiveDateTime; +use icu_collator::{Collator, CollatorOptions, Strength}; +use icu_locid::locale; +use rari_types::error::EnvError; +use rari_types::globals::{blog_root, content_root, content_translated_root}; +use rari_types::locale::{Locale, LocaleError}; +use serde::de::{self, value, SeqAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serializer}; + +use crate::docs::page::PageCategory; +use crate::error::DocError; + +const FM_START_DELIM: &str = "---\n"; +const FM_START_DELIM_LEN: usize = FM_START_DELIM.len(); +const FM_END_DELIM: &str = "\n---\n"; +const FM_END_DELIM_LEN: usize = FM_END_DELIM.len(); + +pub fn split_fm(content: &str) -> (Option<&str>, usize) { + let start = content.find(FM_START_DELIM); + let end = content.find(FM_END_DELIM); + match (start, end) { + (Some(s), Some(e)) => ( + Some(&content[s + FM_START_DELIM_LEN..e]), + e + FM_END_DELIM_LEN, + ), + _ => (None, 0), + } +} + +pub fn as_null(serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_none() +} + +pub fn modified_dt(ndt: &NaiveDateTime, serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_str(&ndt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) +} + +pub fn t_or_vec<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + struct TOrVec(PhantomData); + + impl<'de, T> Visitor<'de> for TOrVec + where + T: Deserialize<'de>, + { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("string or list of strings") + } + + fn visit_str(self, s: &str) -> Result + where + E: de::Error, + { + Ok(vec![Deserialize::deserialize( + value::StrDeserializer::new(s), + )?]) + } + + fn visit_seq(self, seq: S) -> Result + where + S: SeqAccess<'de>, + { + Deserialize::deserialize(value::SeqAccessDeserializer::new(seq)) + } + } + + deserializer.deserialize_any(TOrVec::(PhantomData)) +} + +pub fn root_for_locale(locale: Locale) -> Result<&'static Path, EnvError> { + match locale { + Locale::EnUs => Ok(content_root()), + _ => content_translated_root().ok_or(EnvError::NoTranslatedContent), + } +} + +pub fn locale_and_typ_from_path(path: &Path) -> Result<(Locale, PageCategory), DocError> { + if path.starts_with(content_root()) { + return Ok((Locale::EnUs, PageCategory::Doc)); + } + + if let Some(root) = blog_root() { + if path.starts_with(root) { + return Ok((Locale::EnUs, PageCategory::BlogPost)); + } + } + if let Some(root) = content_translated_root() { + if let Ok(relative) = path.strip_prefix(root) { + if let Some(locale_str) = relative.components().next() { + let locale_str = locale_str + .as_os_str() + .to_str() + .ok_or(LocaleError::NoLocaleInPath)?; + let locale = Locale::from_str(locale_str)?; + return Ok((locale, PageCategory::Doc)); + } + } + } + Err(DocError::LocaleError(LocaleError::NoLocaleInPath)) +} + +pub fn trim_ws(s: &str) -> String { + let mut out = s.trim().to_owned(); + let mut prev = ' '; + out.retain(|c| { + let result = c != ' ' || prev != ' '; + prev = c; + result + }); + out +} + +pub fn readtime(s: &str) -> usize { + /* + const READ_TIME_FILTER = /[\w<>.,!?]+/; + const HIDDEN_CODE_BLOCK_MATCH = /```.*hidden[\s\S]*?```/g; + + function calculateReadTime(copy: string): number { + return Math.max( + 1, + Math.round( + copy + .replace(HIDDEN_CODE_BLOCK_MATCH, "") + .split(/\s+/) + .filter((w) => READ_TIME_FILTER.test(w)).length / 220 + ) + ); + } + */ + let mut words = 0; + let mut in_fence = false; + let mut skipping = false; + for line in s.lines() { + if line.starts_with("```") { + if !in_fence && line.contains("hidden") { + skipping = true; + } + in_fence = !in_fence; + if !in_fence && skipping { + skipping = false; + } + } + if skipping { + continue; + } + words += line + .split_whitespace() + .filter(|c| { + c.chars().all(|c| { + c.is_alphabetic() + || c.is_numeric() + || ['<', '>', '_', '.', ',', '!', '?'].contains(&c) + }) + }) + .count(); + } + max(1, words).div_ceil(220) +} + +pub fn deduplicate(vec: Vec) -> Vec { + let mut seen = vec.iter().cloned().collect::>(); + vec.into_iter().filter(|item| seen.remove(item)).collect() +} + +#[cfg(test)] +mod text { + use super::*; + + #[test] + fn test_trim_ws() { + assert_eq!(trim_ws(" foo bar 20 00 "), "foo bar 20 00"); + assert_eq!(trim_ws(" "), ""); + } + + #[test] + fn test_locale_from_path() { + let en_us = content_root(); + + let path = en_us.to_path_buf().join("en-us/web/html/index.md"); + assert_eq!( + locale_and_typ_from_path(&path).unwrap(), + (Locale::EnUs, PageCategory::Doc) + ); + } + + #[test] + fn test_readtime() { + let s = format!( + r#"Foo +```hidden +{} +``` +"#, + "a lot of words.".repeat(100) + ); + assert_eq!(readtime(&s), 1); + } +} + +thread_local! { + pub static COLLATOR: Collator = { + let locale = locale!("en-US").into(); + let mut options = CollatorOptions::new(); + options.strength = Some(Strength::Primary); + Collator::try_new(&locale, options).unwrap() + }; +} diff --git a/crates/rari-doc/src/walker/mod.rs b/crates/rari-doc/src/walker/mod.rs new file mode 100644 index 00000000..8b17d91c --- /dev/null +++ b/crates/rari-doc/src/walker/mod.rs @@ -0,0 +1,65 @@ +use std::path::Path; + +use ignore::types::TypesBuilder; +use ignore::WalkBuilder; +use rari_types::globals::{content_root, content_translated_root}; +use tracing::error; + +use crate::docs::page::{Page, PageReader}; +use crate::error::DocError; + +pub fn walk_builder( + paths: &[impl AsRef], + glob: Option<&str>, +) -> Result { + let mut types = TypesBuilder::new(); + types.add_def(&format!("markdown:{}", glob.unwrap_or("index.md")))?; + types.select("markdown"); + let mut paths_iter = paths.iter(); + let mut builder = if let Some(path) = paths_iter.next() { + let mut builder = ignore::WalkBuilder::new(path); + for path in paths_iter { + builder.add(path); + } + builder + } else { + let mut builder = ignore::WalkBuilder::new(content_root()); + if let Some(root) = content_translated_root() { + builder.add(root); + } + builder + }; + builder.types(types.build()?); + Ok(builder) +} + +pub fn read_docs_parallel( + paths: &[impl AsRef], + glob: Option<&str>, +) -> Result, DocError> { + let (tx, rx) = crossbeam_channel::bounded::>(100); + let stdout_thread = std::thread::spawn(move || rx.into_iter().collect()); + walk_builder(paths, glob)?.build_parallel().run(|| { + let tx = tx.clone(); + Box::new(move |result| { + if let Ok(f) = result { + if f.file_type().map(|ft| ft.is_file()).unwrap_or(false) { + let p = f.into_path(); + match T::read(p) { + Ok(doc) => { + tx.send(Ok(doc)).unwrap(); + } + Err(e) => { + error!("{e}"); + //tx.send(Err(e.into())).unwrap(); + } + } + } + } + ignore::WalkState::Continue + }) + }); + + drop(tx); + stdout_thread.join().unwrap() +} diff --git a/crates/rari-l10n/Cargo.toml b/crates/rari-l10n/Cargo.toml new file mode 100644 index 00000000..d966aea9 --- /dev/null +++ b/crates/rari-l10n/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "rari-l10n" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +rari-types = { path = "../rari-types" } + diff --git a/crates/rari-l10n/src/lib.rs b/crates/rari-l10n/src/lib.rs new file mode 100644 index 00000000..f529456a --- /dev/null +++ b/crates/rari-l10n/src/lib.rs @@ -0,0 +1,84 @@ +use rari_types::globals::json_l10n_files; +use rari_types::locale::Locale; + +#[allow(clippy::wildcard_in_or_patterns)] +pub fn l10n(key: &str, locale: &Locale) -> &'static str { + match key { + "experimental_badge_title" => match locale { + Locale::EnUs => "Experimental. Expect behavior to change in the future.", + Locale::Es => "Experimental. Espere que el comportamiento cambie en el futuro.", + Locale::Fr => "Expérimental. Le comportement attendu pourrait évoluer à l'avenir.", + Locale::Ja => "Experimental. Expect behavior to change in the future.", + Locale::Ko => "Experimental. 예상되는 동작은 향후 변경될 수 있습니다.", + Locale::PtBr => "Experimental. Expect behavior to change in the future.", + Locale::Ru => "Экспериментальная возможность. Её поведение в будущем может измениться", + Locale::ZhCn => "实验性。预期行为可能会在未来发生变更。", + Locale::ZhTw => "實驗性質。行為可能會在未來發生變動。", + }, + + "experimental_badge_abbreviation" => match locale { + Locale::EnUs => "Experimental", + Locale::Es => "Experimental", + Locale::Fr => "Expérimental", + Locale::Ja => "Experimental", + Locale::Ko => "Experimental", + Locale::PtBr => "Experimental", + Locale::Ru => "Экспериментальная возможность", + Locale::ZhCn => "实验性", + Locale::ZhTw => "實驗性質", + }, + + "deprecated_badge_title" => match locale { + Locale::Ko => "지원이 중단되었습니다. 새로운 웹사이트에서 사용하지 마세요.", + Locale::ZhCn => "已弃用。请不要在新的网站中使用。", + Locale::ZhTw => "已棄用。請不要在新的網站中使用。", + Locale::EnUs | _ => "Deprecated. Not for use in new websites.", + }, + + "deprecated_badge_abbreviation" => match locale { + Locale::Es => "Obsoleto", + Locale::Fr => "Obsolète", + Locale::Ja => "非推奨;", + Locale::Ko => "지원이 중단되었습니다", + Locale::Ru => "Устарело", + Locale::ZhCn => "已弃用", + Locale::ZhTw => "已棄用", + Locale::EnUs | _ => "Deprecated", + }, + + "non_standard_badge_title" => match locale { + Locale::Ko => "비표준. 사용하기전에 다른 브라우저에서도 사용 가능한지 확인 해주세요.", + Locale::ZhCn => "非标准。请在使用前检查跨浏览器支持。", + Locale::ZhTw => "非標準。請在使用前檢查跨瀏覽器支援。", + Locale::EnUs | _ => "Non-standard. Check cross-browser support before using.", + }, + + "non_standard_badge_abbreviation" => match locale { + Locale::Ko => "비표준", + Locale::ZhCn => "非标准", + Locale::ZhTw => "非標準", + Locale::EnUs | _ => "Non-standard", + }, + + "interactive_example_cta" => match locale { + Locale::EnUs => "Try it", + Locale::Fr => "Exemple interactif", + Locale::Ja => "試してみましょう", + Locale::Ko => "시도해보기", + Locale::Ru => "Интерактивный пример", + Locale::PtBr => "Experimente", + Locale::Es => "Pruébalo", + Locale::ZhCn => "尝试一下", + Locale::ZhTw => "嘗試一下", + }, + + _ => "l10n missing", + } +} + +pub fn l10n_json_data(typ: &str, key: &str, locale: &Locale) -> Option<&'static str> { + json_l10n_files() + .get(typ) + .and_then(|file| file.get(key)) + .and_then(|part| part.get(locale.as_url_str()).map(|s| s.as_str())) +} diff --git a/crates/rari-linter/Cargo.toml b/crates/rari-linter/Cargo.toml new file mode 100644 index 00000000..f5529a02 --- /dev/null +++ b/crates/rari-linter/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "rari-linter" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +thiserror = "1" diff --git a/crates/rari-linter/src/lib.rs b/crates/rari-linter/src/lib.rs new file mode 100644 index 00000000..9d1cf4fd --- /dev/null +++ b/crates/rari-linter/src/lib.rs @@ -0,0 +1,3 @@ +// TODO: +// - filepath checks +// - check *.md for merge conflicts diff --git a/crates/rari-md/Cargo.toml b/crates/rari-md/Cargo.toml new file mode 100644 index 00000000..ed906174 --- /dev/null +++ b/crates/rari-md/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rari-md" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true + +[dependencies] +anyhow = "1" +base64 = "0.22" +regex = "1" +once_cell = "1" +thiserror = "1" +comrak = { version = "0.24", default-features = false, features = ["syntect"] } +rari-types = { path = "../rari-types" } diff --git a/crates/rari-md/src/anchor.rs b/crates/rari-md/src/anchor.rs new file mode 100644 index 00000000..0045be96 --- /dev/null +++ b/crates/rari-md/src/anchor.rs @@ -0,0 +1,11 @@ +use once_cell::sync::Lazy; +use regex::Regex; + +pub fn anchorize(content: &str) -> String { + static REJECTED_CHARS: Lazy = + Lazy::new(|| Regex::new(r#"[<>"$#%&+,/:;=?@\[\]^`{|}~')(\\]"#).unwrap()); + + let mut id = content.to_lowercase(); + id = REJECTED_CHARS.replace_all(&id, "").replace(' ', "_"); + id +} diff --git a/crates/rari-md/src/bq.rs b/crates/rari-md/src/bq.rs new file mode 100644 index 00000000..b94b8378 --- /dev/null +++ b/crates/rari-md/src/bq.rs @@ -0,0 +1,134 @@ +use comrak::nodes::{AstNode, NodeValue}; +use rari_types::locale::Locale; + +pub enum NoteCard { + Callout, + Warning, + Note, +} + +impl NoteCard { + pub fn prefix_for_locale(&self, locale: Locale) -> &str { + match (self, locale) { + (Self::Callout, Locale::EnUs) => "Callout:", + (Self::Warning, Locale::EnUs) => "Warning:", + (Self::Note, Locale::EnUs) => "Note:", + (Self::Callout, Locale::Es) => "Observación:", + (Self::Warning, Locale::Es) => "Advertencia:", + (Self::Note, Locale::Es) => "Nota:", + (Self::Callout, Locale::Fr) => "Remarque :", + (Self::Warning, Locale::Fr) => "Attention :", + (Self::Note, Locale::Fr) => "Note :", + (Self::Callout, Locale::Ja) => "注目:", + (Self::Warning, Locale::Ja) => "警告:", + (Self::Note, Locale::Ja) => "メモ:", + (Self::Callout, Locale::Ko) => "알림 :", + (Self::Warning, Locale::Ko) => "경고 :", + (Self::Note, Locale::Ko) => "참고 :", + (Self::Callout, Locale::PtBr) => "Observação:", + (Self::Warning, Locale::PtBr) => "Aviso:", + (Self::Note, Locale::PtBr) => "Nota:", + (Self::Callout, Locale::Ru) => "Сноска:", + (Self::Warning, Locale::Ru) => "Предупреждение:", + (Self::Note, Locale::Ru) => "Примечание:", + (Self::Callout, Locale::ZhCn) => "标注:", + (Self::Warning, Locale::ZhCn) => "警告:", + (Self::Note, Locale::ZhCn) => "备注:", + (Self::Callout, Locale::ZhTw) => "标注:", + (Self::Warning, Locale::ZhTw) => "警告:", + (Self::Note, Locale::ZhTw) => "备注:", + } + } + pub fn new_prefix_for_locale(&self, locale: Locale) -> &str { + match (self, locale) { + (Self::Callout, Locale::EnUs) => "[!CALLOUT]", + (Self::Warning, Locale::EnUs) => "[!WARNING]", + (Self::Note, Locale::EnUs) => "[!NOTE]", + (Self::Callout, Locale::Es) => "[!Observación]", + (Self::Warning, Locale::Es) => "[!Advertencia]", + (Self::Note, Locale::Es) => "[!Nota]", + (Self::Callout, Locale::Fr) => "[!Remarque]", + (Self::Warning, Locale::Fr) => "[!Attention]", + (Self::Note, Locale::Fr) => "[!Note]", + (Self::Callout, Locale::Ja) => "[!注目]", + (Self::Warning, Locale::Ja) => "[!警告]", + (Self::Note, Locale::Ja) => "[!メモ]", + (Self::Callout, Locale::Ko) => "[!알림]", + (Self::Warning, Locale::Ko) => "[!경고]", + (Self::Note, Locale::Ko) => "[!참고]", + (Self::Callout, Locale::PtBr) => "[!Observação]", + (Self::Warning, Locale::PtBr) => "[!Aviso]", + (Self::Note, Locale::PtBr) => "[!Nota]", + (Self::Callout, Locale::Ru) => "[!Сноска]", + (Self::Warning, Locale::Ru) => "[!Предупреждение]", + (Self::Note, Locale::Ru) => "[!Примечание]", + (Self::Callout, Locale::ZhCn) => "[!标注]", + (Self::Warning, Locale::ZhCn) => "[!警告]", + (Self::Note, Locale::ZhCn) => "[!备注]", + (Self::Callout, Locale::ZhTw) => "[!标注]", + (Self::Warning, Locale::ZhTw) => "[!警告]", + (Self::Note, Locale::ZhTw) => "[!备注]", + } + } +} + +pub(crate) fn is_callout<'a>(block_quote: &'a AstNode<'a>, locale: Locale) -> Option { + if let Some(grand_child) = block_quote.first_child().and_then(|c| c.first_child()) { + if matches!(grand_child.data.borrow().value, NodeValue::Strong) { + if let Some(marker) = grand_child.first_child() { + if let NodeValue::Text(ref text) = marker.data.borrow().value { + let callout = NoteCard::Callout.prefix_for_locale(locale); + if text.starts_with(callout) { + if let Some(_sib) = grand_child.next_sibling() { + /* + if let NodeValue::Text(ref mut text) = sib.data.borrow_mut().value { + if text.get(0) == Some(&b' ') { + if text.len() == 1 { + sib.detach(); + } else { + text.remove(0); + } + } + } + */ + } + grand_child.detach(); + return Some(NoteCard::Callout); + } + + if text.starts_with(NoteCard::Warning.prefix_for_locale(locale)) { + return Some(NoteCard::Warning); + } + if text.starts_with(NoteCard::Note.prefix_for_locale(locale)) { + grand_child.detach(); + return Some(NoteCard::Note); + } + } + } + } + } + if let Some(child) = block_quote.first_child() { + if let Some(marker) = child.first_child() { + if let NodeValue::Text(ref text) = marker.data.borrow().value { + if text.starts_with(NoteCard::Callout.new_prefix_for_locale(locale)) { + //if let Some(p) = marker.next_sibling() { + // if matches!(p.data.borrow().value, NodeValue::Paragraph) && p.first_child().is_none() { + // p.detach(); + // } + //} + marker.detach(); + return Some(NoteCard::Callout); + } + if text.starts_with(NoteCard::Warning.new_prefix_for_locale(locale)) { + marker.detach(); + return Some(NoteCard::Warning); + } + if text.starts_with(NoteCard::Note.new_prefix_for_locale(locale)) { + marker.detach(); + return Some(NoteCard::Note); + } + } + } + } + None +} diff --git a/crates/rari-md/src/ctype.rs b/crates/rari-md/src/ctype.rs new file mode 100644 index 00000000..7c0a206a --- /dev/null +++ b/crates/rari-md/src/ctype.rs @@ -0,0 +1,29 @@ +// Copyright (c) 2017–2024, Asherah Connor and Comrak contributors +// This code is part of Comrak and is licensed under the BSD 2-Clause License. +// See LICENSE file for more information. +// Modified by Florian Dieminger in 2024 + +#[rustfmt::skip] +const CMARK_CTYPE_CLASS: [u8; 256] = [ + /* 0 1 2 3 4 5 6 7 8 9 a b c d e f */ + /* 0 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0, 0, + /* 1 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + /* 2 */ 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + /* 3 */ 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, + /* 4 */ 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + /* 5 */ 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 2, + /* 6 */ 2, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, + /* 7 */ 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 2, 2, 2, 2, 0, + /* 8 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + /* 9 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + /* a */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + /* b */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + /* c */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + /* d */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + /* e */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + /* f */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, +]; + +pub fn isspace(ch: u8) -> bool { + CMARK_CTYPE_CLASS[ch as usize] == 1 +} diff --git a/crates/rari-md/src/dl.rs b/crates/rari-md/src/dl.rs new file mode 100644 index 00000000..503868e0 --- /dev/null +++ b/crates/rari-md/src/dl.rs @@ -0,0 +1,62 @@ +use comrak::nodes::{AstNode, NodeValue}; + +pub(crate) fn is_dl<'a>(list: &'a AstNode<'a>) -> bool { + return list.children().all(|child| { + if child.children().count() < 2 { + return false; + } + let last_child = child.last_child().unwrap(); + if !matches!(last_child.data.borrow().value, NodeValue::List(_)) { + return false; + } + last_child.children().all(|item| { + if let Some(i) = item.first_child() { + if !matches!(i.data.borrow().value, NodeValue::Paragraph) { + return false; + } + if let Some(j) = i.first_child() { + if let NodeValue::Text(ref t) = j.data.borrow().value { + //println!("{:?}", std::str::from_utf8(t)); + return t.starts_with(": "); + } + } + } + false + }) + }); +} + +pub(crate) fn convert_dl<'a>(list: &'a AstNode<'a>) { + list.data.borrow_mut().value = NodeValue::DescriptionList; + for child in list.children() { + child.data.borrow_mut().value = NodeValue::DescriptionTerm; + let last_child = child.last_child().unwrap(); + if !matches!(last_child.data.borrow().value, NodeValue::List(_)) { + continue; + } + last_child.detach(); + for item in last_child.children() { + if let Some(i) = item.first_child() { + if !matches!(i.data.borrow().value, NodeValue::Paragraph) { + break; + } + if let Some(j) = i.first_child() { + if let NodeValue::Text(ref mut t) = j.data.borrow_mut().value { + match t.len() { + 0 => {} + 1 => { + t.drain(0..1); + } + _ => { + t.drain(0..2); + } + } + } + } + } + item.data.borrow_mut().value = NodeValue::DescriptionDetails; + item.detach(); + child.insert_after(item); + } + } +} diff --git a/crates/rari-md/src/error.rs b/crates/rari-md/src/error.rs new file mode 100644 index 00000000..6554ffe5 --- /dev/null +++ b/crates/rari-md/src/error.rs @@ -0,0 +1,36 @@ +use rari_types::ArgError; +use thiserror::Error; + +#[derive(Debug, Clone, Error)] +pub enum DocError { + #[error("pest error: {0}")] + PestError(String), + #[error("failed to decode ks: {0}")] + DecodeError(#[from] base64::DecodeError), + #[error("failed to convert ks: {0}")] + Utf8Error(#[from] std::str::Utf8Error), + #[error("failed to de/serialize")] + SerializationError, +} + +#[derive(Debug, Error)] +pub enum RariFError { + #[error(transparent)] + DocError(#[from] DocError), + #[error(transparent)] + ArgError(#[from] ArgError), + #[error("macro not implemented")] + MacroNotImplemented, + #[error("unknown macro")] + UnknownMacro, +} + +#[derive(Debug, Error)] +pub enum MarkdownError { + #[error("unable to output html for markdown")] + HTMLFormatError, + #[error(transparent)] + IOError(#[from] std::io::Error), + #[error(transparent)] + DocError(#[from] DocError), +} diff --git a/crates/rari-md/src/ext.rs b/crates/rari-md/src/ext.rs new file mode 100644 index 00000000..23683dc5 --- /dev/null +++ b/crates/rari-md/src/ext.rs @@ -0,0 +1,8 @@ +use crate::bq::NoteCard; + +pub(crate) enum Flag { + // TODO: fix this + #[allow(dead_code)] + Card(NoteCard), + None, +} diff --git a/crates/rari-md/src/html.rs b/crates/rari-md/src/html.rs new file mode 100644 index 00000000..49a3b944 --- /dev/null +++ b/crates/rari-md/src/html.rs @@ -0,0 +1,1236 @@ +// Copyright (c) 2017–2024, Asherah Connor and Comrak contributors +// This code is part of Comrak and is licensed under the BSD 2-Clause License. +// See LICENSE file for more information. +// Modified by Florian Dieminger in 2024 + +//! The HTML renderer for the CommonMark AST, as well as helper functions. +use std::borrow::Cow; +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; +use std::io::{self, Write}; +use std::str; + +use comrak::adapters::HeadingMeta; +use comrak::nodes::{ + AstNode, ListType, NodeCode, NodeFootnoteDefinition, NodeMath, NodeTable, NodeValue, + TableAlignment, +}; +use comrak::{ComrakOptions, ComrakPlugins, Options}; +use once_cell::sync::Lazy; +use rari_types::locale::Locale; + +use crate::anchor; +use crate::bq::{is_callout, NoteCard}; +use crate::ctype::isspace; +use crate::ext::Flag; + +/// Formats an AST as HTML, modified by the given options. +pub fn format_document<'a>( + root: &'a AstNode<'a>, + options: &ComrakOptions, + output: &mut dyn Write, + locale: Locale, +) -> io::Result<()> { + format_document_with_plugins(root, options, output, &ComrakPlugins::default(), locale) +} + +/// Formats an AST as HTML, modified by the given options. Accepts custom plugins. +pub fn format_document_with_plugins<'a>( + root: &'a AstNode<'a>, + options: &ComrakOptions, + output: &mut dyn Write, + plugins: &ComrakPlugins, + locale: Locale, +) -> io::Result<()> { + let mut writer = WriteWithLast { + output, + last_was_lf: Cell::new(true), + }; + let mut f = HtmlFormatter::new(options, &mut writer, plugins); + f.format(root, false, locale)?; + if f.footnote_ix > 0 { + f.output.write_all(b"\n
    \n")?; + } + Ok(()) +} + +struct WriteWithLast<'w> { + output: &'w mut dyn Write, + last_was_lf: Cell, +} + +impl<'w> Write for WriteWithLast<'w> { + fn flush(&mut self) -> io::Result<()> { + self.output.flush() + } + + fn write(&mut self, buf: &[u8]) -> io::Result { + let l = buf.len(); + if l > 0 { + self.last_was_lf.set(buf[l - 1] == 10); + } + self.output.write(buf) + } +} + +/// Converts header Strings to canonical, unique, but still human-readable, anchors. +/// +/// To guarantee uniqueness, an anchorizer keeps track of the anchors +/// it has returned. So, for example, to parse several MarkDown +/// files, use a new anchorizer per file. +/// +/// ## Example +/// +/// ``` +/// use comrak::Anchorizer; +/// +/// let mut anchorizer = Anchorizer::new(); +/// +/// // First "stuff" is unsuffixed. +/// assert_eq!("stuff".to_string(), anchorizer.anchorize("Stuff".to_string())); +/// // Second "stuff" has "-1" appended to make it unique. +/// assert_eq!("stuff-1".to_string(), anchorizer.anchorize("Stuff".to_string())); +/// ``` +#[derive(Debug, Default)] +pub struct Anchorizer(HashSet); + +impl Anchorizer { + /// Construct a new anchorizer. + pub fn new() -> Self { + Anchorizer(HashSet::new()) + } + + /// Returns a String that has been converted into an anchor using the + /// GFM algorithm, which involves changing spaces to dashes, removing + /// problem characters and, if needed, adding a suffix to make the + /// resultant anchor unique. + /// + /// ``` + /// use comrak::Anchorizer; + /// + /// let mut anchorizer = Anchorizer::new(); + /// + /// let source = "Ticks aren't in"; + /// + /// assert_eq!("ticks-arent-in".to_string(), anchorizer.anchorize(source.to_string())); + /// ``` + pub fn anchorize(&mut self, header: String) -> String { + let mut id = anchor::anchorize(&header); + + let mut uniq = 0; + id = loop { + let anchor = if uniq == 0 { + Cow::from(&id) + } else { + Cow::from(format!("{}_{}", id, uniq)) + }; + + if !self.0.contains(&*anchor) { + break anchor.into_owned(); + } + + uniq += 1; + }; + self.0.insert(id.clone()); + id + } +} + +struct HtmlFormatter<'o> { + output: &'o mut WriteWithLast<'o>, + options: &'o Options, + anchorizer: Anchorizer, + footnote_ix: u32, + written_footnote_ix: u32, + plugins: &'o ComrakPlugins<'o>, +} + +#[rustfmt::skip] +const NEEDS_ESCAPED : [bool; 256] = [ + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, true, false, false, false, true, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, true, false, true, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, +]; + +fn tagfilter(literal: &[u8]) -> bool { + static TAGFILTER_BLACKLIST: [&str; 9] = [ + "title", + "textarea", + "style", + "xmp", + "iframe", + "noembed", + "noframes", + "script", + "plaintext", + ]; + + if literal.len() < 3 || literal[0] != b'<' { + return false; + } + + let mut i = 1; + if literal[i] == b'/' { + i += 1; + } + + let lc = unsafe { String::from_utf8_unchecked(literal[i..].to_vec()) }.to_lowercase(); + for t in TAGFILTER_BLACKLIST.iter() { + if lc.starts_with(t) { + let j = i + t.len(); + return isspace(literal[j]) + || literal[j] == b'>' + || (literal[j] == b'/' && literal.len() >= j + 2 && literal[j + 1] == b'>'); + } + } + + false +} + +fn tagfilter_block(input: &[u8], o: &mut dyn Write) -> io::Result<()> { + let size = input.len(); + let mut i = 0; + + while i < size { + let org = i; + while i < size && input[i] != b'<' { + i += 1; + } + + if i > org { + o.write_all(&input[org..i])?; + } + + if i >= size { + break; + } + + if tagfilter(&input[i..]) { + o.write_all(b"<")?; + } else { + o.write_all(b"<")?; + } + + i += 1; + } + + Ok(()) +} + +fn dangerous_url(_: &[u8]) -> bool { + false +} + +/// Writes buffer to output, escaping anything that could be interpreted as an +/// HTML tag. +/// +/// Namely: +/// +/// * U+0022 QUOTATION MARK " is rendered as " +/// * U+0026 AMPERSAND & is rendered as & +/// * U+003C LESS-THAN SIGN < is rendered as < +/// * U+003E GREATER-THAN SIGN > is rendered as > +/// * Everything else is passed through unchanged. +/// +/// Note that this is appropriate and sufficient for free text, but not for +/// URLs in attributes. See escape_href. +pub fn escape(output: &mut dyn Write, buffer: &[u8]) -> io::Result<()> { + let mut offset = 0; + for (i, &byte) in buffer.iter().enumerate() { + if NEEDS_ESCAPED[byte as usize] { + let esc: &[u8] = match byte { + b'"' => b""", + b'&' => b"&", + b'<' => b"<", + b'>' => b">", + _ => unreachable!(), + }; + output.write_all(&buffer[offset..i])?; + output.write_all(esc)?; + offset = i + 1; + } + } + output.write_all(&buffer[offset..])?; + Ok(()) +} + +/// Writes buffer to output, escaping in a manner appropriate for URLs in HTML +/// attributes. +/// +/// Namely: +/// +/// * U+0026 AMPERSAND & is rendered as & +/// * U+0027 APOSTROPHE ' is rendered as ' +/// * Alphanumeric and a range of non-URL safe characters. +/// +/// The inclusion of characters like "%" in those which are not escaped is +/// explained somewhat here: +/// +/// https://github.com/github/cmark-gfm/blob/c32ef78bae851cb83b7ad52d0fbff880acdcd44a/src/houdini_href_e.c#L7-L31 +/// +/// In other words, if a CommonMark user enters: +/// +/// ```markdown +/// [hi](https://ddg.gg/?q=a%20b) +/// ``` +/// +/// We assume they actually want the query string "?q=a%20b", a search for +/// the string "a b", rather than "?q=a%2520b", a search for the literal +/// string "a%20b". +pub fn escape_href(output: &mut dyn Write, buffer: &[u8]) -> io::Result<()> { + static HREF_SAFE: Lazy<[bool; 256]> = Lazy::new(|| { + let mut a = [false; 256]; + for &c in b"-_.+!*(),%#@?=;:/,+$~abcdefghijklmnopqrstuvwxyz".iter() { + a[c as usize] = true; + } + for &c in b"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".iter() { + a[c as usize] = true; + } + a + }); + + let size = buffer.len(); + let mut i = 0; + + while i < size { + let org = i; + while i < size && HREF_SAFE[buffer[i] as usize] { + i += 1; + } + + if i > org { + output.write_all(&buffer[org..i])?; + } + + if i >= size { + break; + } + + match buffer[i] as char { + '&' => { + output.write_all(b"&")?; + } + '\'' => { + output.write_all(b"'")?; + } + _ => write!(output, "%{:02X}", buffer[i])?, + } + + i += 1; + } + + Ok(()) +} + +/// Writes an opening HTML tag, using an iterator to enumerate the attributes. +/// Note that attribute values are automatically escaped. +pub fn write_opening_tag( + output: &mut dyn Write, + tag: &str, + attributes: impl IntoIterator, +) -> io::Result<()> +where + Str: AsRef, +{ + write!(output, "<{}", tag)?; + for (attr, val) in attributes { + write!(output, " {}=\"", attr.as_ref())?; + escape(output, val.as_ref().as_bytes())?; + output.write_all(b"\"")?; + } + output.write_all(b">")?; + Ok(()) +} + +impl<'o> HtmlFormatter<'o> { + fn new( + options: &'o ComrakOptions, + output: &'o mut WriteWithLast<'o>, + plugins: &'o ComrakPlugins, + ) -> Self { + HtmlFormatter { + options, + output, + anchorizer: Anchorizer::new(), + footnote_ix: 0, + written_footnote_ix: 0, + plugins, + } + } + + fn cr(&mut self) -> io::Result<()> { + if !self.output.last_was_lf.get() { + self.output.write_all(b"\n")?; + } + Ok(()) + } + + fn escape(&mut self, buffer: &[u8]) -> io::Result<()> { + escape(&mut self.output, buffer) + } + + fn escape_href(&mut self, buffer: &[u8]) -> io::Result<()> { + escape_href(&mut self.output, buffer) + } + + fn format<'a>(&mut self, node: &'a AstNode<'a>, plain: bool, locale: Locale) -> io::Result<()> { + // Traverse the AST iteratively using a work stack, with pre- and + // post-child-traversal phases. During pre-order traversal render the + // opening tags, then push the node back onto the stack for the + // post-order traversal phase, then push the children in reverse order + // onto the stack and begin rendering first child. + + enum Phase { + Pre, + Post, + } + let mut stack = vec![(node, plain, Phase::Pre, Flag::None)]; + + while let Some((node, plain, phase, flag)) = stack.pop() { + match phase { + Phase::Pre => { + let new_plain = if plain { + match node.data.borrow().value { + NodeValue::Text(ref literal) + | NodeValue::Code(NodeCode { ref literal, .. }) + | NodeValue::HtmlInline(ref literal) => { + self.escape(literal.as_bytes())?; + } + NodeValue::LineBreak | NodeValue::SoftBreak => { + self.output.write_all(b" ")?; + } + NodeValue::Math(NodeMath { ref literal, .. }) => { + self.escape(literal.as_bytes())?; + } + _ => (), + } + plain + } else { + let (new_plain, new_flag) = self.format_node(node, true, flag, locale)?; + + stack.push((node, false, Phase::Post, new_flag)); + new_plain + }; + + for ch in node.reverse_children() { + stack.push((ch, new_plain, Phase::Pre, Flag::None)); + } + } + Phase::Post => { + debug_assert!(!plain); + self.format_node(node, false, flag, locale)?; + } + } + } + + Ok(()) + } + + fn collect_text<'a>(node: &'a AstNode<'a>, output: &mut Vec) { + match node.data.borrow().value { + NodeValue::Text(ref literal) | NodeValue::Code(NodeCode { ref literal, .. }) => { + output.extend_from_slice(literal.as_bytes()) + } + NodeValue::LineBreak | NodeValue::SoftBreak => output.push(b' '), + NodeValue::Math(NodeMath { ref literal, .. }) => { + output.extend_from_slice(literal.as_bytes()) + } + _ => { + for n in node.children() { + Self::collect_text(n, output); + } + } + } + } + + fn format_node<'a>( + &mut self, + node: &'a AstNode<'a>, + entering: bool, + flag: Flag, + locale: Locale, + ) -> io::Result<(bool, Flag)> { + match node.data.borrow().value { + NodeValue::Document => (), + NodeValue::FrontMatter(_) => (), + NodeValue::BlockQuote => { + self.cr()?; + if entering { + let note_card = is_callout(node, locale); + match note_card { + Some(NoteCard::Callout) => { + self.output.write_all(b"
    \n")?; + return Ok((false, Flag::Card(NoteCard::Callout))); + } + Some(NoteCard::Note) => { + self.output.write_all(b"
    \n")?; + return Ok((false, Flag::Card(NoteCard::Note))); + } + Some(NoteCard::Warning) => { + self.output.write_all(b"
    \n")?; + return Ok((false, Flag::Card(NoteCard::Warning))); + } + None => { + self.output.write_all(b"\n")?; + } + }; + } else if let Flag::Card(_) = flag { + self.output.write_all(b"
    \n")?; + } else { + self.output.write_all(b"\n")?; + } + } + NodeValue::List(ref nl) => { + if entering { + self.cr()?; + if nl.list_type == ListType::Bullet { + self.output.write_all(b"\n")?; + } else if nl.start == 1 { + self.output.write_all(b"\n")?; + } else { + self.output.write_all(b"", nl.start)?; + } + } else if nl.list_type == ListType::Bullet { + self.output.write_all(b"\n")?; + } else { + self.output.write_all(b"\n")?; + } + } + NodeValue::Item(..) => { + if entering { + self.cr()?; + self.output.write_all(b"")?; + } else { + self.output.write_all(b"\n")?; + } + } + NodeValue::DescriptionList => { + if entering { + self.cr()?; + self.output.write_all(b"")?; + } else { + self.output.write_all(b"\n")?; + } + } + NodeValue::DescriptionItem(..) => (), + NodeValue::DescriptionTerm => { + if entering { + self.cr()?; + let mut text_content = Vec::with_capacity(20); + Self::collect_text(node, &mut text_content); + let mut id = String::from_utf8(text_content).unwrap(); + id = self.anchorizer.anchorize(id); + write!(self.output, "
    ")?; + } else { + self.output.write_all(b"
    \n")?; + } + } + NodeValue::DescriptionDetails => { + if entering { + self.cr()?; + self.output.write_all(b"")?; + } else { + self.output.write_all(b"\n")?; + } + } + NodeValue::Heading(ref nch) => match self.plugins.render.heading_adapter { + None => { + if entering { + self.cr()?; + write!(self.output, "")?; + } else { + writeln!(self.output, "", nch.level)?; + } + } + Some(adapter) => { + let mut text_content = Vec::with_capacity(20); + Self::collect_text(node, &mut text_content); + let content = String::from_utf8(text_content).unwrap(); + let heading = HeadingMeta { + level: nch.level, + content, + }; + + if entering { + self.cr()?; + adapter.enter( + self.output, + &heading, + if self.options.render.sourcepos { + Some(node.data.borrow().sourcepos) + } else { + None + }, + )?; + } else { + adapter.exit(self.output, &heading)?; + } + } + }, + NodeValue::CodeBlock(ref ncb) => { + if entering { + if ncb.info.eq("math") { + self.render_math_code_block(node, &ncb.literal)?; + } else { + self.cr()?; + + let mut first_tag = 0; + let mut pre_attributes: HashMap = HashMap::new(); + let mut code_attributes: HashMap = HashMap::new(); + let code_attr: String; + + let literal = &ncb.literal.as_bytes(); + let info = &ncb.info.as_bytes(); + + if !info.is_empty() { + while first_tag < info.len() && !isspace(info[first_tag]) { + first_tag += 1; + } + + let lang_str = str::from_utf8(&info[..first_tag]).unwrap(); + let info_str = str::from_utf8(&info[first_tag..]).unwrap().trim(); + + if self.options.render.github_pre_lang { + pre_attributes.insert(String::from("lang"), lang_str.to_string()); + + if self.options.render.full_info_string && !info_str.is_empty() { + pre_attributes.insert( + String::from("data-meta"), + info_str.trim().to_string(), + ); + } + } else { + code_attr = format!("language-{}", lang_str); + code_attributes.insert(String::from("class"), code_attr); + + if self.options.render.full_info_string && !info_str.is_empty() { + code_attributes + .insert(String::from("data-meta"), info_str.to_string()); + } + } + } + + if self.options.render.sourcepos { + let ast = node.data.borrow(); + pre_attributes + .insert("data-sourcepos".to_string(), ast.sourcepos.to_string()); + } + + match self.plugins.render.codefence_syntax_highlighter { + None => { + pre_attributes.extend(code_attributes); + let with_code = if let Some(cls) = pre_attributes.get_mut("class") { + if !ncb.info.is_empty() { + *cls = format!( + "brush: {} notranslate", + ncb.info.strip_suffix("-nolint").unwrap_or(&ncb.info) + ); + &ncb.info != "plain" + } else { + *cls = "notranslate".to_string(); + false + } + } else { + pre_attributes.insert("class".into(), "notranslate".into()); + false + }; + write_opening_tag(self.output, "pre", pre_attributes)?; + if with_code { + self.output.write_all(b"")?; + } + + self.escape(literal)?; + + if with_code { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"\n")? + } + } + Some(highlighter) => { + highlighter.write_pre_tag(self.output, pre_attributes)?; + highlighter.write_code_tag(self.output, code_attributes)?; + + highlighter.write_highlighted( + self.output, + match str::from_utf8(&info[..first_tag]) { + Ok(lang) => Some(lang), + Err(_) => None, + }, + &ncb.literal, + )?; + + self.output.write_all(b"\n")? + } + } + } + } + } + NodeValue::HtmlBlock(ref nhb) => { + // No sourcepos. + if entering { + let is_marco = nhb.literal.starts_with("")?; + } else if self.options.extension.tagfilter { + tagfilter_block(literal, &mut self.output)?; + } else { + self.output.write_all(literal)?; + } + if !is_marco { + self.cr()?; + } + } + } + NodeValue::ThematicBreak => { + if entering { + self.cr()?; + self.output.write_all(b"\n")?; + } + } + NodeValue::Paragraph => { + let tight = match node + .parent() + .and_then(|n| n.parent()) + .map(|n| n.data.borrow().value.clone()) + { + Some(NodeValue::List(nl)) => nl.tight, + _ => false, + }; + + let tight = tight + || matches!( + node.parent().map(|n| n.data.borrow().value.clone()), + Some(NodeValue::DescriptionTerm) + ); + + if !tight { + if entering { + self.cr()?; + self.output.write_all(b"")?; + } else { + if let NodeValue::FootnoteDefinition(nfd) = + &node.parent().unwrap().data.borrow().value + { + if node.next_sibling().is_none() { + self.output.write_all(b" ")?; + self.put_footnote_backref(nfd)?; + } + } + self.output.write_all(b"

    \n")?; + } + } + } + NodeValue::Text(ref literal) => { + if entering { + self.escape(literal.as_bytes())?; + } + } + NodeValue::LineBreak => { + if entering { + self.output.write_all(b"\n")?; + } + } + NodeValue::SoftBreak => { + if entering { + if self.options.render.hardbreaks { + self.output.write_all(b"\n")?; + } else { + self.output.write_all(b"\n")?; + } + } + } + NodeValue::Code(NodeCode { ref literal, .. }) => { + if entering { + self.output.write_all(b"")?; + self.escape(literal.as_bytes())?; + self.output.write_all(b"")?; + } + } + NodeValue::HtmlInline(ref literal) => { + // No sourcepos. + if entering { + let literal = literal.as_bytes(); + if self.options.render.escape { + self.escape(literal)?; + } else if !self.options.render.unsafe_ { + self.output.write_all(b"")?; + } else if self.options.extension.tagfilter && tagfilter(literal) { + self.output.write_all(b"<")?; + self.output.write_all(&literal[1..])?; + } else { + self.output.write_all(literal)?; + } + } + } + NodeValue::Strong => { + let parent_node = node.parent(); + if parent_node.is_none() + || !matches!(parent_node.unwrap().data.borrow().value, NodeValue::Strong) + { + if entering { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } + } + NodeValue::Emph => { + if entering { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } + NodeValue::Strikethrough => { + if entering { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } + NodeValue::Superscript => { + if entering { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } + NodeValue::Link(ref nl) => { + if entering { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } + NodeValue::Image(ref nl) => { + if entering { + self.output.write_all(b"")?; + } + } + #[cfg(feature = "shortcodes")] + NodeValue::ShortCode(ref nsc) => { + if entering { + self.output.write_all(nsc.emoji().as_bytes())?; + } + } + NodeValue::Table(..) => { + if entering { + self.cr()?; + self.output.write_all(b"\n")?; + } else { + if !node + .last_child() + .unwrap() + .same_node(node.first_child().unwrap()) + { + self.cr()?; + self.output.write_all(b"\n")?; + } + self.cr()?; + self.output.write_all(b"\n")?; + } + } + NodeValue::TableRow(header) => { + if entering { + self.cr()?; + if header { + self.output.write_all(b"\n")?; + } else if let Some(n) = node.previous_sibling() { + if let NodeValue::TableRow(true) = n.data.borrow().value { + self.output.write_all(b"\n")?; + } + } + self.output.write_all(b"")?; + } else { + self.cr()?; + self.output.write_all(b"")?; + if header { + self.cr()?; + self.output.write_all(b"")?; + } + } + } + NodeValue::TableCell => { + let row = &node.parent().unwrap().data.borrow().value; + let in_header = match *row { + NodeValue::TableRow(header) => header, + _ => panic!(), + }; + + let table = &node.parent().unwrap().parent().unwrap().data.borrow().value; + let alignments = match *table { + NodeValue::Table(NodeTable { ref alignments, .. }) => alignments, + _ => panic!(), + }; + + if entering { + self.cr()?; + if in_header { + self.output.write_all(b" { + self.output.write_all(b" align=\"left\"")?; + } + TableAlignment::Right => { + self.output.write_all(b" align=\"right\"")?; + } + TableAlignment::Center => { + self.output.write_all(b" align=\"center\"")?; + } + TableAlignment::None => (), + } + + self.output.write_all(b">")?; + } else if in_header { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } + NodeValue::FootnoteDefinition(ref nfd) => { + if entering { + if self.footnote_ix == 0 { + self.output.write_all(b"\n
      \n")?; + } + self.footnote_ix += 1; + self.output.write_all(b"")?; + } else { + if self.put_footnote_backref(nfd)? { + self.output.write_all(b"\n")?; + } + self.output.write_all(b"\n")?; + } + } + NodeValue::FootnoteReference(ref nfr) => { + if entering { + let mut ref_id = format!("fnref-{}", nfr.name); + + self.output.write_all(b" 1 { + ref_id = format!("{}-{}", ref_id, nfr.ref_num); + } + + self.output + .write_all(b" class=\"footnote-ref\">{}", nfr.ix)?; + } + } + NodeValue::TaskItem(symbol) => { + if entering { + self.cr()?; + self.output.write_all(b"")?; + write!( + self.output, + " ", + if symbol.is_some() { + "checked=\"\" " + } else { + "" + } + )?; + } else { + self.output.write_all(b"\n")?; + } + } + NodeValue::MultilineBlockQuote(_) => { + if entering { + self.cr()?; + self.output.write_all(b"\n")?; + } else { + self.cr()?; + self.output.write_all(b"\n")?; + } + } + NodeValue::Escaped => { + if self.options.render.escaped_char_spans { + if entering { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } + } + NodeValue::Math(NodeMath { + ref literal, + display_math, + dollar_math, + .. + }) => { + if entering { + self.render_math_inline(node, literal, display_math, dollar_math)?; + } + } + NodeValue::WikiLink(ref nl) => { + if entering { + self.output.write_all(b"")?; + } else { + self.output.write_all(b"")?; + } + } + } + Ok((false, Flag::None)) + } + + fn render_sourcepos<'a>(&mut self, node: &'a AstNode<'a>) -> io::Result<()> { + if self.options.render.sourcepos { + let ast = node.data.borrow(); + if ast.sourcepos.start.line > 0 { + write!(self.output, " data-sourcepos=\"{}\"", ast.sourcepos)?; + } + } + Ok(()) + } + + fn put_footnote_backref(&mut self, nfd: &NodeFootnoteDefinition) -> io::Result { + if self.written_footnote_ix >= self.footnote_ix { + return Ok(false); + } + + self.written_footnote_ix = self.footnote_ix; + + let mut ref_suffix = String::new(); + let mut superscript = String::new(); + + for ref_num in 1..=nfd.total_references { + if ref_num > 1 { + ref_suffix = format!("-{}", ref_num); + superscript = format!("{}", ref_num); + write!(self.output, " ")?; + } + + self.output.write_all(b"↩{}", + ref_suffix, self.footnote_ix, ref_suffix, self.footnote_ix, ref_suffix, superscript + )?; + } + Ok(true) + } + + // Renders a math dollar inline, `$...$` and `$$...$$` using `` to be similar + // to other renderers. + fn render_math_inline<'a>( + &mut self, + node: &'a AstNode<'a>, + literal: &String, + display_math: bool, + dollar_math: bool, + ) -> io::Result<()> { + let mut tag_attributes: Vec<(String, String)> = Vec::new(); + let style_attr = if display_math { "display" } else { "inline" }; + let tag: &str = if dollar_math { "span" } else { "code" }; + + tag_attributes.push((String::from("data-math-style"), String::from(style_attr))); + + if self.options.render.sourcepos { + let ast = node.data.borrow(); + tag_attributes.push(("data-sourcepos".to_string(), ast.sourcepos.to_string())); + } + + write_opening_tag(self.output, tag, tag_attributes)?; + self.escape(literal.as_bytes())?; + write!(self.output, "", tag)?; + + Ok(()) + } + + // Renders a math code block, ```` ```math ```` using `
      `
      +    fn render_math_code_block<'a>(
      +        &mut self,
      +        node: &'a AstNode<'a>,
      +        literal: &String,
      +    ) -> io::Result<()> {
      +        self.cr()?;
      +
      +        // use vectors to ensure attributes always written in the same order,
      +        // for testing stability
      +        let mut pre_attributes: Vec<(String, String)> = Vec::new();
      +        let mut code_attributes: Vec<(String, String)> = Vec::new();
      +        let lang_str = "math";
      +
      +        if self.options.render.github_pre_lang {
      +            pre_attributes.push((String::from("lang"), lang_str.to_string()));
      +            pre_attributes.push((String::from("data-math-style"), String::from("display")));
      +        } else {
      +            let code_attr = format!("language-{}", lang_str);
      +            code_attributes.push((String::from("class"), code_attr));
      +            code_attributes.push((String::from("data-math-style"), String::from("display")));
      +        }
      +
      +        if self.options.render.sourcepos {
      +            let ast = node.data.borrow();
      +            pre_attributes.push(("data-sourcepos".to_string(), ast.sourcepos.to_string()));
      +        }
      +
      +        write_opening_tag(self.output, "pre", pre_attributes)?;
      +        write_opening_tag(self.output, "code", code_attributes)?;
      +
      +        self.escape(literal.as_bytes())?;
      +        self.output.write_all(b"
      \n")?; + + Ok(()) + } +} diff --git a/crates/rari-md/src/li.rs b/crates/rari-md/src/li.rs new file mode 100644 index 00000000..23b7a118 --- /dev/null +++ b/crates/rari-md/src/li.rs @@ -0,0 +1,21 @@ +use comrak::nodes::{AstNode, NodeValue}; + +pub(crate) fn remove_p<'a>(list: &'a AstNode<'a>) { + for child in list.children() { + if let Some(i) = child.first_child() { + if !matches!(i.data.borrow().value, NodeValue::Paragraph) { + continue; + } + i.detach(); + if let Some(new_first) = child.first_child() { + for sub in i.children() { + new_first.insert_before(sub); + } + } else { + for sub in i.children() { + child.append(sub); + } + } + } + } +} diff --git a/crates/rari-md/src/lib.rs b/crates/rari-md/src/lib.rs new file mode 100644 index 00000000..b2251c52 --- /dev/null +++ b/crates/rari-md/src/lib.rs @@ -0,0 +1,144 @@ +use comrak::nodes::{AstNode, NodeValue}; +use comrak::{parse_document, Arena, ComrakOptions}; +use rari_types::locale::Locale; + +use crate::error::MarkdownError; +use crate::p::{fix_p, is_empty_p, is_ksp}; + +pub mod anchor; +pub mod bq; +pub(crate) mod ctype; +pub(crate) mod dl; +pub mod error; +pub(crate) mod ext; +pub(crate) mod html; +pub(crate) mod li; +pub(crate) mod p; + +use dl::{convert_dl, is_dl}; +use html::format_document; + +use self::li::remove_p; + +fn iter_nodes<'a, F>(node: &'a AstNode<'a>, f: &F) +where + F: Fn(&'a AstNode<'a>), +{ + f(node); + for c in node.children() { + iter_nodes(c, f); + } +} + +/// rari's custom markdown parser. This implements the MDN markdown extensions. +/// See [MDN Markdown](https://developer.mozilla.org/en-US/docs/MDN/Writing_guidelines/Howto/Markdown_in_MDN) +pub fn m2h(input: &str, locale: Locale) -> Result { + let arena = Arena::new(); + let mut options = ComrakOptions::default(); + options.extension.tagfilter = true; + options.render.unsafe_ = true; + options.extension.table = true; + options.extension.autolink = true; + options.extension.header_ids = Some(Default::default()); + let root = parse_document(&arena, input, &options); + + iter_nodes(root, &|node| { + let (dl, li, ksp, empty_p) = match node.data.borrow().value { + NodeValue::List(_) => (is_dl(node), true, false, false), + NodeValue::Paragraph => (false, false, is_ksp(node), is_empty_p(node)), + _ => (false, false, false, false), + }; + if dl { + convert_dl(node); + } else if li { + remove_p(node); + } + if ksp || empty_p { + fix_p(node) + } + }); + + let mut html = vec![]; + format_document(root, &options, &mut html, locale) + .map_err(|_| MarkdownError::HTMLFormatError)?; + let encoded_html = String::from_utf8(html).map_err(|_| MarkdownError::HTMLFormatError)?; + Ok(encoded_html) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn render_code_tags() -> Result<(), anyhow::Error> { + let out = m2h("`