[
  {
    "path": ".dockerignore",
    "content": ".git\n*.pyc\ndocat/env\ndocat/__pycache__\ndocat/upload\ndocat/.tox\ndocat/tests\nweb/node_modules\nweb/build\nweb/.env*\n"
  },
  {
    "path": ".github/workflows/docat.yml",
    "content": "name: docat ci\n\non: [push, pull_request]\n\njobs:\n  python-test:\n    runs-on: ubuntu-latest\n    strategy:\n      max-parallel: 4\n      matrix:\n        python-version: [\"3.14\"]\n\n    steps:\n      - uses: actions/checkout@v5\n      - name: Set up Python ${{ matrix.python-version }}\n        uses: actions/setup-python@v6\n        with:\n          python-version: ${{ matrix.python-version }}\n\n      - name: Install uv\n        uses: astral-sh/setup-uv@v7\n        with:\n          python-version: ${{ matrix.python-version }}\n\n      - name: install dependencies\n        working-directory: docat\n        run: uv sync --locked --all-extras --dev\n\n      - name: run backend linter\n        working-directory: docat\n        run: |\n          uv run ruff check\n          uv run ruff format --check\n\n      - name: run backend static code analysis\n        working-directory: docat\n        run: |\n          uv run mypy .\n\n      - name: run backend tests\n        working-directory: docat\n        run: |\n          uv run pytest\n\n  javascript-test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v5\n      - uses: actions/setup-node@v6\n        with:\n          node-version: '24'\n      - name: install JavaScript dependencies\n        working-directory: web\n        run: yarn install\n\n      - name: building frontend\n        working-directory: web\n        run: yarn build\n\n      - name: run linter against code\n        working-directory: web\n        run: yarn lint\n\n      - name: run test suite\n        working-directory: web\n        run: yarn test\n\n  container-image:\n    runs-on: ubuntu-latest\n    needs: [python-test, javascript-test]\n\n    strategy:\n      max-parallel: 2\n      matrix:\n        registry:\n          - name: ghcr.io\n            org: ${{ github.repository_owner }}\n            token: GITHUB_TOKEN\n          - name: docker.io\n            org: randombenj\n            token: DOCKERHUB\n\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - name: Build Image\n        run: |\n          docker build . --build-arg DOCAT_VERSION=$(git describe --tags --always) --tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }}\n          docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:unstable\n\n      - name: tag latest and version on release\n        run: |\n          docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:$(git describe --tags)\n          docker tag ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:${{ github.sha }} ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat:latest\n        if: startsWith(github.event.ref, 'refs/tags')\n\n      - name: Registry Login\n        uses: docker/login-action@v3\n        with:\n          registry: ${{ matrix.registry.name }}\n          username: ${{ matrix.registry.org }}\n          password: ${{ secrets[matrix.registry.token] }}\n        # Note(Fliiiix): Only login and push on main repo where the secrets are available\n        if: \"!(github.event.pull_request.head.repo.fork || github.actor == 'dependabot[bot]')\"\n\n      - name: Publish Image\n        run: |\n          docker push --all-tags ${{ matrix.registry.name }}/${{ matrix.registry.org }}/docat\n        if: \"!(github.event.pull_request.head.repo.fork || github.actor == 'dependabot[bot]')\"\n"
  },
  {
    "path": ".prettierrc",
    "content": "{\n    \"semi\": false,\n    \"singleQuote\": true,\n    \"trailingComma\": \"none\"\n}"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to docat\n\nThanks for contributing to docat!\nIn order to keep the quality of the source-code high,\nplease follow those rules when submitting a change.\n\nIf you just want to fix a bug or make a small improvement\nfeel free to just send a pull request.\n\nPlease first discuss any big new features you wish to make via issue, email,\nor any other method with the owners of this repository before making a change.\n\n## Pull Request Process\n\nCommits should be the following format\n\n```\ntype(scope): commit title\n\ncommit body (if any)\nthis should document api breaks\n\nfixes # (if any)\n```\n\nType could be one of *feat, docs, fix, ...* and scope could be *docat, web, ...*\nyou don't have to provide a scope when the change is for the whole repository like README updates.\n\nExecute linters by running `make lint` in the back-end or `yarn lint`.\n\nA pull request will only be merged when the pipeline runs through.\n"
  },
  {
    "path": "Dockerfile",
    "content": "# building frontend\nFROM node:24-slim AS frontend\nWORKDIR /app/frontend\n\nCOPY web/package.json web/yarn.lock ./\nRUN yarn install --frozen-lockfile\n\n# fix docker not following symlinks\nCOPY web ./\nCOPY doc/getting-started.md ./src/assets/\n\nARG DOCAT_VERSION=unknown\nENV VITE_DOCAT_VERSION=$DOCAT_VERSION\n\nRUN yarn build\n\n# setup Python\nFROM python:3.14-slim AS backend\n\n# configure docker container\nENV PYTHONDONTWRITEBYTECODE=1\n\nCOPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /uvx /bin/\n\nCOPY /docat/pyproject.toml /docat/uv.lock /app/\n\n# Install the application\nWORKDIR /app/docat\nRUN uv sync --no-install-project --no-dev --color never\n\n# production\nFROM python:3.14-slim\n\n# defaults\nENV MAX_UPLOAD_SIZE=100M\n\n# set up the system\nRUN apt-get update && \\\n    apt-get install --yes nginx dumb-init libmagic1 gettext && \\\n    rm -rf /var/lib/apt/lists/*\n\nRUN mkdir -p /var/docat/doc\n\n# install the application\nRUN mkdir -p /var/www/html\nCOPY --from=frontend /app/frontend/dist /var/www/html\nCOPY docat /app/docat\nWORKDIR /app/docat\n\n# Copy the build artifact (.venv)\nCOPY --from=backend /app /app/docat\n\nENTRYPOINT [\"/usr/bin/dumb-init\", \"--\"]\nCMD [\"sh\", \"-c\", \"envsubst '$MAX_UPLOAD_SIZE' < /app/docat/docat/nginx/default > /etc/nginx/sites-enabled/default && nginx && .venv/bin/python -m uvicorn --host 0.0.0.0 --port 5000 docat.app:app\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2019 https://github.com/docat-org/docat\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "![docat](doc/assets/docat-teaser.png)\n\n**Host your docs. Simple. Versioned. Fancy.**\n\n[![build](https://github.com/docat-org/docat/workflows/docat%20ci/badge.svg)](https://github.com/docat-org/docat/actions)\n[![Gitter](https://badges.gitter.im/docat-docs-hosting/community.svg)](https://gitter.im/docat-docs-hosting/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)\n\n## Why DOCAT?\n\nWhen generating static documentation using\n[mkdocs](https://www.mkdocs.org/), [sphinx](http://www.sphinx-doc.org/en/master/), ...\nhosting just one version of the docs might not be enough.\nMany users might still use older versions and might need to read\nthose versions of the documentation.\n\nDocat solves this problem by providing a simple tool that\nhosts multiple documentation projects with multiple versions.\n\n*The main design decision with docat was to keep the tool as simple as possible.*\n\n## Getting started\n\nThe simplest way to get started is to run the docker container,\nyou can optionally use volumes to persist state:\n\n```sh\n# run container in background and persist data (docs, nginx configs and tokens database)\n# use 'ghcr.io/docat-org/docat:unstable' to get the latest changes\nmkdir -p docat-run/doc\ndocker run \\\n  --detach \\\n  --volume $PWD/docat-run:/var/docat/ \\\n  --publish 8000:80 \\\n  ghcr.io/docat-org/docat\n```\n\nGo to [localhost:8000](http://localhost:8000) to view your docat instance:\n\n<img src=\"doc/assets/docat.gif\" width=\"100%\" />\n\n### Using DOCAT\n\n> 🛈 Please note that docat does not provide any way to write documentation.\n> It's sole responsibility is to host documentation.\n>\n> There are many awesome tools to write documenation:\n> - [mkdocs](https://www.mkdocs.org/)\n> - [sphinx](http://www.sphinx-doc.org/en/master/)\n> - [mdbook](https://rust-lang.github.io/mdBook/)\n> - ...\n\n\nA CLI tool called [docatl](https://github.com/docat-org/docatl) is available\nfor easy interaction with the docat server.\nHowever, interacting with docat can also be done through [`curl`](doc/getting-started.md).\n\nTo push documentation (and tag as `latest`) in the folder `docs/` simply run:\n\n```sh\ndocatl push --host http://localhost:8000 ./docs PROJECT VERSION --tag latest\n```\n\nMore detailed instructions can be found in the [**getting started guide**](doc/getting-started.md).\n\n## Authentication\n\nBy default, anyone can upload new documentation or add a new version to documentation.\nA project can be claimed. A claim returns a token that then must be used\nto add or delete versions.\n\nWhen hosting docat publicly, it is recommended to use\n[http basic auth](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/)\nfor all `POST`/`PUT` and `DELETE` http calls.\n\n<details>\n  <summary>docat http basic authentication example</summary>\n\nThis example shows how to configure the NGINX inside the docker image\nto be password protected using http basic auth.\n\n1) Create your [`.htpasswd` file](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/#creating-a-password-file).\n2) And a custom `default` NGINX config:\n\n  ```\n  upstream python_backend {\n      server 127.0.0.1:5000;\n  }\n\n  server {\n      listen 80 default_server;\n      listen [::]:80 default_server;\n\n      root /var/www/html;\n\n      add_header Content-Security-Policy \"frame-ancestors 'self';\";\n      index index.html index.htm index.pdf /index.html;\n\n      server_name _;\n\n      location /doc {\n          root /var/docat;\n      }\n\n      location /api {\n          limit_except GET HEAD {\n              auth_basic 'Restricted';\n              auth_basic_user_file /etc/nginx/.htpasswd;\n          }\n\n          client_max_body_size $MAX_UPLOAD_SIZE;\n          proxy_pass http://python_backend;\n      }\n\n      location / {\n          try_files $uri $uri/ =404;\n      }\n  }\n  ```\n\n1) Mounted to the correct location inside the container:\n\n  ```\n  docker run \\\n    --detach \\\n    --volume $PWD/docat-run:/var/docat/ \\\n    --volume $PWD/nginx/default:/app/docat/docat/nginx/default \\\n    --volume $PWD/nginx/.htpasswd:/etc/nginx/.htpasswd \\\n    --publish 8000:80 \\\n    ghcr.io/docat-org/docat\n  ```\n</details>\n\n## Configuring DOCAT\n\n#### Frontend Config\n\nIt is possible to configure some things after the fact.\n\n1. Create a `config.json` file\n2. Mount it inside your docker container `--volume $PWD/config.json:/var/docat/doc/config.json`\n\nSupported config options:\n\n```json\n{\n  \"headerHTML\": \"<h1 style='color: #115fbf;'>Custom Header HTML!</h1>\",\n  \"footerHTML\": \"CONTACT: <a href='mailto:maintainer@mail.invalid'>Maintainers</a>\"\n}\n```\n\n#### System Config\n\nFurther proxy configurations can be done through the following environmental variables:\n\n| Variable | Default | Description |\n|---|---|---|\n| `MAX_UPLOAD_SIZE` | [100M](./Dockerfile) | Limits the size of individual archives posted to the API |\n\n\n## Local Development\n\nFor local development, first configure and start the backend (inside the `docat/` folder):\n\n```sh\n# create a folder for local development (uploading docs)\nDEV_DOCAT_PATH=\"$(mktemp -d)\"\n\n# install dependencies\nuv venv .venv\nuv sync\n\n# run the local development version\nDOCAT_SERVE_FILES=1 DOCAT_STORAGE_PATH=\"$DEV_DOCAT_PATH\" uv run python -m docat\n```\n\nAfter this you need to start the frontend (inside the `web/` folder):\n\n```sh\n# install dependencies\nyarn install --frozen-lockfile\n\n# run the web app\nyarn start\n```\n\nFor more advanced options, have a look at the\n[backend](docat/README.md) and [web](web/README.md) docs.\n"
  },
  {
    "path": "doc/getting-started.md",
    "content": "## Getting Started with DOCAT\n\n\n\n\n### Using `docatl`, the docat CLI 🙀\n\nThe most convenient way to interact with docat is with it's official CLI tool,\n[docatl](https://github.com/docat-org/docatl).\n\nYou can download a standalone binary of the latest release for your platform\n[here](https://github.com/docat-org/docatl/releases/latest) or\n[use go](https://github.com/docat-org/docatl#using-go) or\n[docker](https://github.com/docat-org/docatl#using-docker) to install it.\n\nThe commands below contain examples both using `curl` and `docatl`.\nDo note that the host address and api-key can be omitted if specified in a `.docatl.yml` file.\nSee the [docatl documentation](https://github.com/docat-org/docatl/blob/main/README.md) for more information.\n\nUse `docatl --help` to discover all commands available to manage your `docat` documentation!\n\n### API endpoints\n\nThe following sections document the RAW API endpoints you can `curl`.\n\nThe API specification is exposed as an [OpenAPI Documentation](/api/v1/openapi.json),\nvia Swagger UI at [/api/docs](/api/docs) and\nas a pure documentation with redoc at [/api/redoc](/api/redoc).\n\n#### Upload your documentation\n\nYou can upload any static HTML page by zipping it and uploading the zip file.\n\n> Note: if an `index.html` file is present in the root of the zip file\n  it will be served automatically.\n\nFor example to upload the file `docs.zip` as version `1.0.0` for `awesome-project` using `curl`:\n\n```sh\ncurl -X POST -F \"file=@docs.zip\" http://localhost:8000/api/awesome-project/1.0.0\n```\n\nUsing `docatl`:\n\n```sh\ndocatl push docs.zip awesome-project 1.0.0 --host http://localhost:8000\n```\n\nAny file type can be uploaded. To view an uploaded pdf, specify it's full path:\n\n`http://localhost:8000/awesome-project/1.0.0/my_awesome.pdf`\n\nYou can also manually upload your documentation.\nA very simple web form can be found under [upload](/upload).\n\n#### Tag documentation\n\nAfter uploading you can tag a specific version. This can be useful when\nthe latest version should be available as `http://localhost:8000/docs/awesome-project/latest`\n\nTo tag the version `1.0.0` as `latest` for `awesome-project`:\n\n```sh\ncurl -X PUT http://localhost:8000/api/awesome-project/1.0.0/tags/latest\n```\n\nUsing `docatl`:\n\n```sh\ndocatl tag awesome-project 1.0.0 latest --host http://localhost:8000\n```\n\n#### Claim Project\n\nClaiming a Project returns a `token` which can be used for actions\nwhich require authentication (for example for deleting a version).\nEach Project can be claimed **exactly once**, so best store the token safely.\n\n```sh\ncurl -X GET http://localhost:8000/api/awesome-project/claim\n```\n\nUsing `docatl`:\n\n```sh\ndocatl claim awesome-project --host http://localhost:8000\n```\n\n#### Authentication\n\nTo make an authenticated call, specify a header with the key `Docat-Api-Key` and your token as the value:\n\n```sh\ncurl -X DELETE --header \"Docat-Api-Key: <token>\" http://localhost:8000/api/awesome-project/1.0.0\n```\n\nUsing `docatl`:\n\n```sh\ndocatl delete awesome-project 1.0.0 --host http://localhost:8000 --api-key <token>\n```\n\n#### Delete Version\n\nTo delete a Project version you need to be authenticated.\n\nTo remove the version `1.0.0` from `awesome-project`:\n\n```sh\ncurl -X DELETE --header \"Docat-Api-Key: <token>\" http://localhost:8000/api/awesome-project/1.0.0\n```\n\nUsing `docatl`:\n\n```sh\ndocatl delete awesome-project 1.0.0 --host http://localhost:8000 --api-key <token>\n```\n\n#### Upload Project Icon\n\nTo upload a icon, you don't need a token, except if you want to replace an existing icon.\n\nTo set `example-image.png` as the icon for `awesome-project`, which already has an icon:\n\n```sh\ncurl -X POST -F \"file=@example-image.png\" --header \"Docat-Api-Key: <token>\" http://localhost:8000/api/awesome-project/icon\n```\n\nUsing `docatl`:\n\n```sh\ndocatl push-icon awesome-project example-image.png --host http://localhost:8000 --api-key <token>\n```\n\n#### Rename a Project\n\nTo rename a Project, you need a token.\n\nTo rename `awesome-project` to `new-awesome-project`:\n\n```sh\ncurl -X PUT --header \"Docat-Api-Key: <token>\" http://localhost:8000/api/awesome-project/rename/new-awesome-project\n```\n\nUsing `docatl`:\n\n```sh\ndocatl rename awesome-project new-awesome-project --host http://localhost:8000 --api-key <token>\n```\n\n#### Hide a Version\n\nIf you want to hide a version from the version select as well as the search results,\nyou can hide it. You need to be authenticated to do this.\n\nTo hide version `0.0.1` of `awesome-project`:\n\n```sh\ncurl -X POST --header \"Docat-Api-Key: <token>\" http://localhost:8000/api/awesome-project/0.0.1/hide\n```\n\nUsing `docatl`:\n\n```sh\ndocatl hide awesome-project 0.0.1 --host http://localhost:8000 --api-key <token>\n```\n\n#### Show a Version\n\nThis is the reverse of `hide`, and also requires a token.\n\nTo show version `0.0.1` of `awesome-project` again:\n\n```sh\ncurl -X POST --header \"Docat-Api-Key: <token>\" http://localhost:8000/api/awesome-project/0.0.1/show\n```\n\nUsing `docatl`:\n\n```sh\ndocatl show awesome-project 0.0.1 --host http://localhost:8000 --api-key <token>\n```\n"
  },
  {
    "path": "docat/.gitignore",
    "content": "*.pyc\nenv\n__pycache__\nupload\n.tox\n.coverage\ndb.json\n.python-version\ndocat.egg-info/\n"
  },
  {
    "path": "docat/Makefile",
    "content": ".PHONY: all\nall: format lint typing pytest\n\nformat:\n\tuv run ruff check --fix\n\tuv run ruff format\nlint:\n\tuv run ruff check\ntyping:\n\tuv run mypy .\npytest:\n\tuv run pytest\n"
  },
  {
    "path": "docat/README.md",
    "content": "# docat backend\n\nThe backend hosts the documentation and an api to push documentation and\ntag versions of the documentation.\n\n## development enviroment\n\nYou will need to install [uv](https://docs.astral.sh/uv/#installation) `curl -LsSf https://astral.sh/uv/install.sh | sh\n`.\n\nInstall the dependencies and run the application:\n\n```sh\n# install dependencies\nuv venv .venv\nuv sync\n\n# run the app\n[DOCAT_SERVE_FILES=1] [DOCAT_STORAGE_PATH=/tmp] [PORT=8888] uv run python -m docat\n```\n\n### Config Options\n\n* **DOCAT_SERVE_FILES**: Serve static documentation instead of a nginx (for testing)\n* **DOCAT_STORAGE_PATH**: Upload directory for static files (needs to match nginx config)\n* **PORT**: Port for the Python backend (needs to match nginx config for production)\n\n## Usage\n\nSee [getting-started.md](../doc/getting-started.md)\n"
  },
  {
    "path": "docat/docat/__init__.py",
    "content": ""
  },
  {
    "path": "docat/docat/__main__.py",
    "content": "import os\n\nimport uvicorn\n\nfrom docat.app import app\n\nif __name__ == \"__main__\":\n    try:\n        port = int(os.environ.get(\"PORT\", \"5000\"))\n    except ValueError:\n        port = 5000\n\n    uvicorn.run(app, host=\"0.0.0.0\", port=port)\n"
  },
  {
    "path": "docat/docat/app.py",
    "content": "\"\"\"\ndocat\n~~~~~\n\nHost your docs. Simple. Versioned. Fancy.\n\n:copyright: (c) 2019 by docat, https://github.com/docat-org/docat\n:license: MIT, see LICENSE for more details.\n\"\"\"\n\nimport logging\nimport os\nimport secrets\nimport shutil\nfrom contextlib import asynccontextmanager\nfrom pathlib import Path\n\nimport magic\nfrom fastapi import APIRouter, Depends, FastAPI, File, Header, Response, UploadFile, status\nfrom fastapi.staticfiles import StaticFiles\nfrom starlette.responses import JSONResponse\nfrom tinydb import Query, TinyDB\n\nfrom docat.models import ApiResponse, ClaimResponse, ProjectDetail, Projects, Stats, TokenStatus\nfrom docat.utils import (\n    DB_PATH,\n    UPLOAD_FOLDER,\n    calculate_token,\n    create_symlink,\n    extract_archive,\n    get_all_projects,\n    get_dir_size,\n    get_project_details,\n    get_system_stats,\n    is_forbidden_project_name,\n    remove_docs,\n)\n\nDOCAT_STORAGE_PATH = Path(os.getenv(\"DOCAT_STORAGE_PATH\", Path(\"/var/docat\")))\nDOCAT_DB_PATH = DOCAT_STORAGE_PATH / DB_PATH\nDOCAT_UPLOAD_FOLDER = DOCAT_STORAGE_PATH / UPLOAD_FOLDER\n\nlogger = logging.getLogger(__name__)\n\n\n@asynccontextmanager\nasync def lifespan(_: FastAPI):\n    # Create the folders if they don't exist\n    DOCAT_UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)\n    yield\n\n\ndef get_db() -> TinyDB:\n    \"\"\"Return the cached TinyDB instance.\"\"\"\n    return TinyDB(DOCAT_DB_PATH)\n\n\n#: Holds the FastAPI application\napp = FastAPI(\n    title=\"docat\",\n    description=\"API for docat, https://github.com/docat-org/docat\",\n    openapi_url=\"/api/v1/openapi.json\",\n    docs_url=\"/api/docs\",\n    redoc_url=\"/api/redoc\",\n    lifespan=lifespan,\n)\nrouter = APIRouter()\n\n\n@router.get(\"/api/stats\", response_model=Stats, status_code=status.HTTP_200_OK)\ndef get_stats():\n    if not DOCAT_UPLOAD_FOLDER.exists():\n        return Projects(projects=[])\n    return get_system_stats(DOCAT_UPLOAD_FOLDER)\n\n\n@router.get(\"/api/projects\", response_model=Projects, status_code=status.HTTP_200_OK)\ndef get_projects(include_hidden: bool = False):\n    if not DOCAT_UPLOAD_FOLDER.exists():\n        return Projects(projects=[])\n    return get_all_projects(DOCAT_UPLOAD_FOLDER, include_hidden)\n\n\n@router.get(\n    \"/api/projects/{project}\",\n    response_model=ProjectDetail,\n    status_code=status.HTTP_200_OK,\n    responses={status.HTTP_404_NOT_FOUND: {\"model\": ApiResponse}},\n)\ndef get_project(project, include_hidden: bool = False):\n    details = get_project_details(DOCAT_UPLOAD_FOLDER, project, include_hidden)\n\n    if not details:\n        return JSONResponse(status_code=status.HTTP_404_NOT_FOUND, content={\"message\": f\"Project {project} does not exist\"})\n\n    return details\n\n\n@router.post(\"/api/{project}/icon\", response_model=ApiResponse, status_code=status.HTTP_200_OK)\ndef upload_icon(\n    project: str,\n    response: Response,\n    file: UploadFile = File(...),\n    docat_api_key: str | None = Header(None),\n    db: TinyDB = Depends(get_db),\n):\n    project_base_path = DOCAT_UPLOAD_FOLDER / project\n    icon_path = project_base_path / \"logo\"\n\n    if not project_base_path.exists():\n        response.status_code = status.HTTP_404_NOT_FOUND\n        return ApiResponse(message=f\"Project {project} not found\")\n\n    mime_type_checker = magic.Magic(mime=True)\n    mime_type = mime_type_checker.from_buffer(file.file.read())\n\n    if not mime_type.startswith(\"image/\"):\n        response.status_code = status.HTTP_400_BAD_REQUEST\n        return ApiResponse(message=\"Icon must be an image\")\n\n    # require a token if the project already has an icon\n    if icon_path.is_file():\n        token_status = check_token_for_project(db, docat_api_key, project)\n        if not token_status.valid:\n            response.status_code = status.HTTP_401_UNAUTHORIZED\n            return ApiResponse(message=token_status.reason)\n\n        # remove the old icon\n        os.remove(icon_path)\n\n    # save the uploaded icon\n    file.file.seek(0)\n    with icon_path.open(\"wb\") as buffer:\n        shutil.copyfileobj(file.file, buffer)\n\n    # force cache revalidation\n    get_system_stats.cache_clear()\n    get_dir_size.cache_clear()\n\n    return ApiResponse(message=\"Icon successfully uploaded\")\n\n\n@router.post(\"/api/{project}/{version}/hide\", response_model=ApiResponse, status_code=status.HTTP_200_OK)\ndef hide_version(\n    project: str,\n    version: str,\n    response: Response,\n    docat_api_key: str | None = Header(None),\n    db: TinyDB = Depends(get_db),\n):\n    project_base_path = DOCAT_UPLOAD_FOLDER / project\n    version_path = project_base_path / version\n    hidden_file = version_path / \".hidden\"\n\n    if not project_base_path.exists():\n        response.status_code = status.HTTP_404_NOT_FOUND\n        return ApiResponse(message=f\"Project {project} not found\")\n\n    if not version_path.exists():\n        response.status_code = status.HTTP_404_NOT_FOUND\n        return ApiResponse(message=f\"Version {version} not found\")\n\n    if hidden_file.exists():\n        response.status_code = status.HTTP_400_BAD_REQUEST\n        return ApiResponse(message=f\"Version {version} is already hidden\")\n\n    token_status = check_token_for_project(db, docat_api_key, project)\n    if not token_status.valid:\n        response.status_code = status.HTTP_401_UNAUTHORIZED\n        return ApiResponse(message=token_status.reason)\n\n    with open(hidden_file, \"w\") as f:\n        f.close()\n\n    return ApiResponse(message=f\"Version {version} is now hidden\")\n\n\n@router.post(\"/api/{project}/{version}/show\", response_model=ApiResponse, status_code=status.HTTP_200_OK)\ndef show_version(\n    project: str,\n    version: str,\n    response: Response,\n    docat_api_key: str | None = Header(None),\n    db: TinyDB = Depends(get_db),\n):\n    project_base_path = DOCAT_UPLOAD_FOLDER / project\n    version_path = project_base_path / version\n    hidden_file = version_path / \".hidden\"\n\n    if not project_base_path.exists():\n        response.status_code = status.HTTP_404_NOT_FOUND\n        return ApiResponse(message=f\"Project {project} not found\")\n\n    if not version_path.exists():\n        response.status_code = status.HTTP_404_NOT_FOUND\n        return ApiResponse(message=f\"Version {version} not found\")\n\n    if not hidden_file.exists():\n        response.status_code = status.HTTP_400_BAD_REQUEST\n        return ApiResponse(message=f\"Version {version} is not hidden\")\n\n    token_status = check_token_for_project(db, docat_api_key, project)\n    if not token_status.valid:\n        response.status_code = status.HTTP_401_UNAUTHORIZED\n        return ApiResponse(message=token_status.reason)\n\n    os.remove(hidden_file)\n\n    return ApiResponse(message=f\"Version {version} is now shown\")\n\n\n@router.post(\"/api/{project}/{version}\", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)\ndef upload(\n    project: str,\n    version: str,\n    response: Response,\n    file: UploadFile = File(...),\n    docat_api_key: str | None = Header(None),\n    db: TinyDB = Depends(get_db),\n):\n    if is_forbidden_project_name(project):\n        response.status_code = status.HTTP_400_BAD_REQUEST\n        return ApiResponse(message=f'Project name \"{project}\" is forbidden, as it conflicts with pages in docat web.')\n\n    if file.filename is None:\n        response.status_code = status.HTTP_400_BAD_REQUEST\n        return ApiResponse(message=\"Uploaded file is None aborting upload.\")\n\n    project_base_path = DOCAT_UPLOAD_FOLDER / project\n    base_path = project_base_path / version\n    target_file = base_path / str(file.filename)\n\n    if base_path.is_symlink():\n        # disallow overwriting of tags (symlinks) with new uploads\n        response.status_code = status.HTTP_409_CONFLICT\n        return ApiResponse(message=\"Cannot overwrite existing tag with new version.\")\n\n    if base_path.exists():\n        token_status = check_token_for_project(db, docat_api_key, project)\n        if not token_status.valid:\n            response.status_code = status.HTTP_401_UNAUTHORIZED\n            return ApiResponse(message=token_status.reason)\n\n        remove_docs(project, version, DOCAT_UPLOAD_FOLDER)\n\n    # ensure directory for the uploaded doc exists\n    base_path.mkdir(parents=True, exist_ok=True)\n\n    # save the uploaded documentation\n    file.file.seek(0)\n    with target_file.open(\"wb\") as buffer:\n        shutil.copyfileobj(file.file, buffer)\n\n    try:\n        extract_archive(target_file, base_path)\n    except Exception:\n        logger.exception(\"Failed to unzip {target_file=}\")\n        response.status_code = status.HTTP_400_BAD_REQUEST\n        return ApiResponse(message=\"Cannot extract zip file.\")\n\n    # force cache revalidation\n    get_system_stats.cache_clear()\n    get_dir_size.cache_clear()\n\n    if not (base_path / \"index.html\").exists():\n        return ApiResponse(message=\"Documentation uploaded successfully, but no index.html found at root of archive.\")\n\n    return ApiResponse(message=\"Documentation uploaded successfully\")\n\n\n@router.put(\"/api/{project}/{version}/tags/{new_tag}\", response_model=ApiResponse, status_code=status.HTTP_201_CREATED)\ndef tag(project: str, version: str, new_tag: str, response: Response):\n    destination = DOCAT_UPLOAD_FOLDER / project / new_tag\n    source = DOCAT_UPLOAD_FOLDER / project / version\n\n    if not source.exists():\n        response.status_code = status.HTTP_404_NOT_FOUND\n        return ApiResponse(message=f\"Version {version} not found\")\n\n    if not create_symlink(version, destination):\n        response.status_code = status.HTTP_409_CONFLICT\n        return ApiResponse(message=f\"Tag {new_tag} would overwrite an existing version!\")\n\n    return ApiResponse(message=f\"Tag {new_tag} -> {version} successfully created\")\n\n\n@router.get(\n    \"/api/{project}/claim\",\n    response_model=ClaimResponse,\n    status_code=status.HTTP_201_CREATED,\n    responses={status.HTTP_409_CONFLICT: {\"model\": ApiResponse}},\n)\ndef claim(project: str, db: TinyDB = Depends(get_db)):\n    Project = Query()\n    table = db.table(\"claims\")\n    result = table.search(Project.name == project)\n    if result:\n        return JSONResponse(status_code=status.HTTP_409_CONFLICT, content={\"message\": f\"Project {project} is already claimed!\"})\n\n    token = secrets.token_hex(16)\n    salt = os.urandom(32)\n    token_hash = calculate_token(token, salt)\n    table.insert({\"name\": project, \"token\": token_hash, \"salt\": salt.hex()})\n\n    return ClaimResponse(message=f\"Project {project} successfully claimed\", token=token)\n\n\n@router.put(\"/api/{project}/rename/{new_project_name}\", response_model=ApiResponse, status_code=status.HTTP_200_OK)\ndef rename(\n    project: str,\n    new_project_name: str,\n    response: Response,\n    docat_api_key: str = Header(None),\n    db: TinyDB = Depends(get_db),\n):\n    if is_forbidden_project_name(new_project_name):\n        response.status_code = status.HTTP_400_BAD_REQUEST\n        return ApiResponse(message=f'New project name \"{new_project_name}\" is forbidden, as it conflicts with pages in docat web.')\n\n    project_base_path = DOCAT_UPLOAD_FOLDER / project\n    new_project_base_path = DOCAT_UPLOAD_FOLDER / new_project_name\n\n    if not project_base_path.exists():\n        response.status_code = status.HTTP_404_NOT_FOUND\n        return ApiResponse(message=f\"Project {project} not found\")\n\n    if new_project_base_path.exists():\n        response.status_code = status.HTTP_409_CONFLICT\n        return ApiResponse(message=f\"New project name {new_project_name} already in use\")\n\n    token_status = check_token_for_project(db, docat_api_key, project)\n    if not token_status.valid:\n        response.status_code = status.HTTP_401_UNAUTHORIZED\n        return ApiResponse(message=token_status.reason)\n\n    # update the claim to the new project name\n    Project = Query()\n    claims_table = db.table(\"claims\")\n    claims_table.update({\"name\": new_project_name}, Project.name == project)\n\n    os.rename(project_base_path, new_project_base_path)\n\n    response.status_code = status.HTTP_200_OK\n    return ApiResponse(message=f\"Successfully renamed project {project} to {new_project_name}\")\n\n\n@router.delete(\"/api/{project}/{version}\", response_model=ApiResponse, status_code=status.HTTP_200_OK)\ndef delete(\n    project: str,\n    version: str,\n    response: Response,\n    docat_api_key: str = Header(None),\n    db: TinyDB = Depends(get_db),\n):\n    token_status = check_token_for_project(db, docat_api_key, project)\n    if not token_status.valid:\n        response.status_code = status.HTTP_401_UNAUTHORIZED\n        return ApiResponse(message=token_status.reason)\n\n    message = remove_docs(project, version, DOCAT_UPLOAD_FOLDER)\n    if message:\n        response.status_code = status.HTTP_404_NOT_FOUND\n        return ApiResponse(message=message)\n\n    # force cache revalidation\n    get_system_stats.cache_clear()\n    get_dir_size.cache_clear()\n\n    return ApiResponse(message=f\"Successfully deleted version '{version}'\")\n\n\ndef check_token_for_project(db, token, project) -> TokenStatus:\n    Project = Query()\n    table = db.table(\"claims\")\n    result = table.search(Project.name == project)\n\n    if result and token:\n        token_hash = calculate_token(token, bytes.fromhex(result[0][\"salt\"]))\n        if result[0][\"token\"] == token_hash:\n            return TokenStatus(True, \"Docat-Api-Key token is valid\")\n        else:\n            return TokenStatus(False, f\"Docat-Api-Key token is not valid for {project}\")\n    else:\n        return TokenStatus(False, f\"Please provide a header with a valid Docat-Api-Key token for {project}\")\n\n\n# serve_local_docs for local testing without a nginx\nif os.environ.get(\"DOCAT_SERVE_FILES\"):\n    DOCAT_UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)\n    app.mount(\"/doc\", StaticFiles(directory=DOCAT_UPLOAD_FOLDER, html=True), name=\"docs\")\n\napp.include_router(router)\n"
  },
  {
    "path": "docat/docat/models.py",
    "content": "from dataclasses import dataclass\nfrom datetime import datetime\n\nfrom pydantic import BaseModel\n\n\n@dataclass(frozen=True)\nclass TokenStatus:\n    valid: bool\n    reason: str\n\n\nclass ApiResponse(BaseModel):\n    message: str\n\n\nclass ClaimResponse(ApiResponse):\n    token: str\n\n\nclass ProjectVersion(BaseModel):\n    name: str\n    timestamp: datetime\n    tags: list[str]\n    hidden: bool\n\n\nclass Project(BaseModel):\n    name: str\n    logo: bool\n    storage: str\n    versions: list[ProjectVersion]\n\n\nclass Projects(BaseModel):\n    projects: list[Project]\n\n\nclass Stats(BaseModel):\n    n_projects: int\n    n_versions: int\n    storage: str\n\n\nclass ProjectDetail(BaseModel):\n    name: str\n    storage: str\n    versions: list[ProjectVersion]\n"
  },
  {
    "path": "docat/docat/nginx/default",
    "content": "upstream python_backend {\n    server 127.0.0.1:5000;\n}\n\nserver {\n    listen 80 default_server;\n    listen [::]:80 default_server;\n\n    root /var/www/html;\n\n    add_header Content-Security-Policy \"frame-ancestors 'self';\";\n    index index.html index.htm index.pdf /index.html;\n\n    server_name _;\n\n    location /doc {\n        root /var/docat;\n        absolute_redirect off;\n    }\n\n    location /api {\n        client_max_body_size $MAX_UPLOAD_SIZE;\n        proxy_pass http://python_backend;\n    }\n\n    location / {\n        try_files $uri $uri/ /index.html =404;\n    }\n}\n"
  },
  {
    "path": "docat/docat/utils.py",
    "content": "\"\"\"\ndocat utilities\n\"\"\"\n\nimport hashlib\nimport os\nimport shutil\nfrom datetime import datetime\nfrom functools import cache\nfrom pathlib import Path\nfrom zipfile import ZipFile, ZipInfo\n\nfrom docat.models import Project, ProjectDetail, Projects, ProjectVersion, Stats\n\nNGINX_CONFIG_PATH = Path(\"/etc/nginx/locations.d\")\nUPLOAD_FOLDER = \"doc\"\nDB_PATH = \"db.json\"\n\n\ndef is_dir(self):\n    \"\"\"Return True if this archive member is a directory.\"\"\"\n    if self.filename.endswith(\"/\"):\n        return True\n    # The ZIP format specification requires to use forward slashes\n    # as the directory separator, but in practice some ZIP files\n    # created on Windows can use backward slashes.  For compatibility\n    # with the extraction code which already handles this:\n    if os.path.altsep:\n        return self.filename.endswith((os.path.sep, os.path.altsep))\n    return False\n\n\n# Patch is_dir to allow windows zip files to be\n# extracted correctly\n# see: https://github.com/python/cpython/issues/117084\nZipInfo.is_dir = is_dir  # type: ignore[method-assign]\n\n\ndef create_symlink(source, destination):\n    \"\"\"\n    Create a symlink from source to destination, if the\n    destination is already a symlink, it will be overwritten.\n\n    Args:\n        source (pathlib.Path): path to the source\n        destination (pathlib.Path): path to the destination\n    \"\"\"\n    if not destination.exists() or (destination.exists() and destination.is_symlink()):\n        if destination.is_symlink():\n            destination.unlink()  # overwrite existing tag\n        destination.symlink_to(source)\n        return True\n    else:\n        return False\n\n\ndef extract_archive(target_file, destination):\n    \"\"\"\n    Extracts the given archive to the directory\n    and deletes the source afterwards.\n\n    Args:\n        target_file (pathlib.Path): target archive\n        destination: (pathlib.Path): destination of the extracted archive\n    \"\"\"\n    if target_file.suffix == \".zip\":\n        # this is required to extract zip files created\n        # on windows machines (https://stackoverflow.com/a/52091659/12356463)\n        os.path.altsep = \"\\\\\"\n        with ZipFile(target_file, \"r\") as zipf:\n            zipf.extractall(path=destination)\n        target_file.unlink()  # remove the zip file\n\n\ndef remove_docs(project: str, version: str, upload_folder_path: Path):\n    \"\"\"\n    Delete documentation\n\n    Args:\n        project (str): name of the project\n        version (str): project version\n    \"\"\"\n    docs = upload_folder_path / project / version\n    if docs.exists():\n        # remove the requested version\n        # rmtree can not remove a symlink\n        if docs.is_symlink():\n            docs.unlink()\n        else:\n            shutil.rmtree(docs)\n\n        # remove dead symlinks\n        for link in (s for s in docs.parent.iterdir() if s.is_symlink()):\n            if not link.resolve().exists():\n                link.unlink()\n\n        # remove size info\n        (upload_folder_path / project / \".size\").unlink(missing_ok=True)\n\n        # remove empty projects\n        if not [d for d in docs.parent.iterdir() if d.is_dir()]:\n            docs.parent.rmdir()\n            nginx_config = NGINX_CONFIG_PATH / f\"{project}-doc.conf\"\n            if nginx_config.exists():\n                nginx_config.unlink()\n    else:\n        return f\"Could not find version '{docs}'\"\n\n\ndef calculate_token(password, salt):\n    \"\"\"\n    Wrapper function for pbkdf2_hmac to ensure consistent use of\n    hash digest algorithm and iteration count.\n\n    Args:\n        password (str): the password to hash\n        salt (byte): the salt used for the password\n    \"\"\"\n    return hashlib.pbkdf2_hmac(\"sha256\", password.encode(\"utf-8\"), salt, 100000).hex()\n\n\ndef is_forbidden_project_name(name: str) -> bool:\n    \"\"\"\n    Checks if the given project name is forbidden.\n    The project name is forbidden if it conflicts with\n    a page on the docat website.\n    \"\"\"\n    name = name.lower().strip()\n    return name in [\"upload\", \"claim\", \"delete\", \"help\", \"doc\", \"api\"]\n\n\nUNITS_MAPPING = [\n    (1 << 50, \" PB\"),\n    (1 << 40, \" TB\"),\n    (1 << 30, \" GB\"),\n    (1 << 20, \" MB\"),\n    (1 << 10, \" KB\"),\n    (1, \" byte\"),\n]\n\n\ndef readable_size(bytes: int) -> str:\n    \"\"\"\n    Get human-readable file sizes.\n    simplified version of https://pypi.python.org/pypi/hurry.filesize/\n\n    https://stackoverflow.com/a/12912296/12356463\n    \"\"\"\n    size_suffix = \"\"\n    for factor, suffix in UNITS_MAPPING:\n        if bytes >= factor:\n            size_suffix = suffix\n            break\n\n    amount = int(bytes / factor)\n    if size_suffix == \" byte\" and amount > 1:\n        size_suffix = size_suffix + \"s\"\n\n    if amount == 0:\n        size_suffix = \" bytes\"\n\n    return str(amount) + size_suffix\n\n\n@cache\ndef get_dir_size(path: Path) -> int:\n    \"\"\"\n    Calculate the total size of a directory.\n\n    Results are cached (memoizing) by path.\n    \"\"\"\n    total = 0\n    with os.scandir(path) as it:\n        for entry in it:\n            if entry.is_symlink():\n                # skip symlinks\n                pass\n            elif entry.is_file():\n                total += entry.stat().st_size\n            elif entry.is_dir():\n                total += get_dir_size(entry.path)\n    return total\n\n\n@cache\ndef get_system_stats(upload_folder_path: Path) -> Stats:\n    \"\"\"\n    Return all docat statistics.\n\n    Results are cached (memoizing) by path.\n    \"\"\"\n    dirs = 0\n    versions = 0\n    size = 0\n    # Note: Not great nesting with the deep nesting\n    # but it needs to run fast, consider speed when refactoring!\n    with os.scandir(upload_folder_path) as root:\n        for f in root:\n            if f.is_dir():\n                dirs += 1\n                with os.scandir(f.path) as project:\n                    for v in project:\n                        if v.is_dir() and not v.is_symlink():\n                            size += get_dir_size(v.path)\n                            versions += 1\n\n    return Stats(\n        n_projects=dirs,\n        n_versions=versions,\n        storage=readable_size(size),\n    )\n\n\ndef get_all_projects(upload_folder_path: Path, include_hidden: bool) -> Projects:\n    \"\"\"\n    Returns all projects in the upload folder.\n    \"\"\"\n    projects: list[Project] = []\n\n    for project in sorted(upload_folder_path.iterdir()):\n        if not project.is_dir():\n            continue\n\n        details = get_project_details(upload_folder_path, project.name, include_hidden)\n\n        if details is None:\n            continue\n\n        if len(details.versions) < 1:\n            continue\n\n        project_name = str(project.relative_to(upload_folder_path))\n        project_has_logo = (upload_folder_path / project / \"logo\").exists()\n        projects.append(\n            Project(\n                name=project_name,\n                logo=project_has_logo,\n                versions=details.versions,\n                storage=readable_size(get_dir_size(upload_folder_path / project)),\n            )\n        )\n\n    return Projects(projects=projects)\n\n\ndef get_version_timestamp(version_folder: Path) -> datetime:\n    \"\"\"\n    Returns the timestamp of a version\n    \"\"\"\n    return datetime.fromtimestamp(version_folder.stat().st_ctime)\n\n\ndef get_project_details(upload_folder_path: Path, project_name: str, include_hidden: bool) -> ProjectDetail | None:\n    \"\"\"\n    Returns all versions and tags for a project.\n    \"\"\"\n    docs_folder = upload_folder_path / project_name\n\n    if not docs_folder.exists():\n        return None\n\n    tags = [x for x in docs_folder.iterdir() if x.is_dir() and x.is_symlink()]\n\n    def should_include(name: str) -> bool:\n        if include_hidden:\n            return True\n\n        return not (docs_folder / name / \".hidden\").exists()\n\n    return ProjectDetail(\n        name=project_name,\n        storage=readable_size(get_dir_size(docs_folder)),\n        versions=sorted(\n            [\n                ProjectVersion(\n                    name=str(x.relative_to(docs_folder)),\n                    tags=[str(t.relative_to(docs_folder)) for t in tags if t.resolve() == x],\n                    timestamp=get_version_timestamp(x),\n                    hidden=(docs_folder / x.name / \".hidden\").exists(),\n                )\n                for x in docs_folder.iterdir()\n                if x.is_dir() and not x.is_symlink() and should_include(x.name)\n            ],\n            key=lambda k: k.name,\n            reverse=True,\n        ),\n    )\n"
  },
  {
    "path": "docat/pyproject.toml",
    "content": "[project]\nname = \"docat\"\nversion = \"0.0.0\"\ndescription = \"Host your docs. Simple. Versioned. Fancy.\"\nauthors = [\n    { name = \"Felix\", email = \"hi@l33t.name\" },\n    { name = \"Benj\", email = \"randombenj@gmail.com\" }\n]\nlicense = { text = \"MIT\" }\nrequires-python = \">=3.10\"\ndependencies = [\n    \"tinydb\",\n    \"fastapi[all]\",\n    \"uvicorn\",\n    \"python-multipart\",\n    \"python-magic\",\n]\n\n[dependency-groups]\ndev = [\n    \"ruff\",\n    \"pytest\",\n    \"pytest-cov\",\n    \"requests\",\n    \"mypy\",\n]\n\n[build-system]\nrequires = [\"setuptools>=61.0\"]\nbuild-backend = \"setuptools.build_meta\"\n\n[tool.pytest.ini_options]\nminversion = \"6.0\"\naddopts = \"--ff -ra -v\"\ntestpaths = [\n    \"tests\"\n]\n\n[[tool.mypy.overrides]]\nmodule = [\n    \"tinydb\",\n    \"tinydb.storages\",\n    \"uvicorn\"\n]\nignore_missing_imports = true\n\n[tool.ruff]\nline-length = 140\n# Rule descriptions: https://docs.astral.sh/ruff/rules/\nlint.select = [\"I\", \"E\", \"B\", \"F\", \"W\", \"N\", \"C4\", \"C90\", \"ARG\", \"PL\", \"RUF\", \"UP\"]\n# TODO: Should be reduct to no global exceptions\nlint.ignore = [\"B008\", \"N806\", \"PLR0911\", \"PLR0913\"]\n\n[tool.ruff.lint.per-file-ignores]\n# Ignore for all tests (Magic value used in comparison)\n# We use magic values in tests\n\"tests/*\" = [\"PLR2004\"]\n"
  },
  {
    "path": "docat/tests/__init__.py",
    "content": ""
  },
  {
    "path": "docat/tests/conftest.py",
    "content": "import tempfile\nfrom pathlib import Path\n\nimport pytest\nfrom fastapi.testclient import TestClient\nfrom tinydb import TinyDB\n\nimport docat.app as docat\nfrom docat.utils import create_symlink\n\n\n@pytest.fixture(autouse=True)\ndef setup_docat_paths():\n    \"\"\"\n    Set up the temporary paths for the docat app.\n    \"\"\"\n\n    temp_dir = tempfile.TemporaryDirectory()\n    docat.DOCAT_STORAGE_PATH = Path(temp_dir.name)\n    docat.DOCAT_DB_PATH = Path(temp_dir.name) / \"db.json\"\n    docat.DOCAT_UPLOAD_FOLDER = Path(temp_dir.name) / \"doc\"\n\n    yield\n\n    temp_dir.cleanup()\n\n\n@pytest.fixture\ndef client():\n    docat.db = TinyDB(docat.DOCAT_DB_PATH)\n\n    yield TestClient(docat.app)\n\n    docat.app.db = None\n\n\n@pytest.fixture\ndef client_with_claimed_project(client):\n    table = docat.db.table(\"claims\")\n    token_hash_1234 = b\"\\xe0\\x8cS\\xa3)\\xb4\\xb5\\xa5\\xda\\xc3K\\x96\\xf6).\\xdd-\\xacR\\x8e3Q\\x17\\x87\\xfb\\x94\\x0c-\\xc2h\\x1c\\xf3\"\n    table.insert({\"name\": \"some-project\", \"token\": token_hash_1234.hex(), \"salt\": \"\"})\n    yield client\n\n\n@pytest.fixture\ndef temp_project_version():\n    def __create(project, version):\n        version_docs = docat.DOCAT_UPLOAD_FOLDER / project / version\n        version_docs.mkdir(parents=True)\n        (version_docs / \"index.html\").touch()\n\n        create_symlink(version_docs, docat.DOCAT_UPLOAD_FOLDER / project / \"latest\")\n\n        return docat.DOCAT_UPLOAD_FOLDER\n\n    yield __create\n"
  },
  {
    "path": "docat/tests/test_claim.py",
    "content": "def test_successfully_claim_token(client):\n    response = client.get(\"/api/some-project/claim\")\n    response_data = response.json()\n    assert response.status_code == 201\n    assert response_data[\"message\"] == \"Project some-project successfully claimed\"\n    assert \"token\" in response_data\n\n\ndef test_already_claimed(client):\n    client.get(\"/api/some-project/claim\")\n    response = client.get(\"/api/some-project/claim\")\n    response_data = response.json()\n    assert response.status_code == 409\n    assert response_data[\"message\"] == \"Project some-project is already claimed!\"\n"
  },
  {
    "path": "docat/tests/test_delete.py",
    "content": "from unittest.mock import patch\n\n\ndef test_successfully_delete(client_with_claimed_project):\n    with patch(\"docat.app.remove_docs\", return_value=\"remove mock\"):\n        response = client_with_claimed_project.delete(\"/api/some-project/1.0.0\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert b\"remove mock\" in response.content\n\n\ndef test_no_valid_token_delete(client_with_claimed_project):\n    with patch(\"docat.app.remove_docs\", return_value=\"remove mock\"):\n        response = client_with_claimed_project.delete(\"/api/some-project/1.0.0\", headers={\"Docat-Api-Key\": \"abcd\"})\n        response_data = response.json()\n\n        assert response.status_code == 401\n        assert response_data[\"message\"] == \"Docat-Api-Key token is not valid for some-project\"\n\n\ndef test_no_token_delete(client_with_claimed_project):\n    with patch(\"docat.app.remove_docs\", return_value=\"remove mock\"):\n        response = client_with_claimed_project.delete(\"/api/some-project/1.0.0\")\n        response_data = response.json()\n\n        assert response.status_code == 401\n        assert response_data[\"message\"] == \"Please provide a header with a valid Docat-Api-Key token for some-project\"\n"
  },
  {
    "path": "docat/tests/test_hide_show.py",
    "content": "import io\nfrom datetime import datetime\nfrom unittest.mock import patch\n\nimport docat.app as docat\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_hide(_, client_with_claimed_project):\n    \"\"\"\n    Tests that the version is marked as hidden when getting the details after hiding\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # check detected before hiding\n    project_details_response = client_with_claimed_project.get(\"/api/projects/some-project\")\n    assert project_details_response.status_code == 200\n    assert project_details_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n    }\n\n    # hide the version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    # check hidden\n    project_details_response = client_with_claimed_project.get(\"/api/projects/some-project\")\n    assert project_details_response.status_code == 200\n    assert project_details_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [],\n    }\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_hide_only_version_not_listed_in_projects(_, client_with_claimed_project):\n    \"\"\"\n    Test that the project is not listed in the projects endpoint when the only version is hidden\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # check detected before hiding\n    projects_response = client_with_claimed_project.get(\"/api/projects\")\n    assert projects_response.status_code == 200\n    assert projects_response.json() == {\n        \"projects\": [\n            {\n                \"name\": \"some-project\",\n                \"logo\": False,\n                \"storage\": \"20 bytes\",\n                \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n            }\n        ],\n    }\n\n    # hide the only version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    # check hidden\n    projects_response = client_with_claimed_project.get(\"/api/projects\")\n    assert projects_response.status_code == 200\n    assert projects_response.json() == {\n        \"projects\": [],\n    }\n\n    # check versions hidden\n    project_details_response = client_with_claimed_project.get(\"/api/projects/some-project\")\n    assert project_details_response.status_code == 200\n    assert project_details_response.json() == {\"name\": \"some-project\", \"storage\": \"20 bytes\", \"versions\": []}\n\n\ndef test_hide_creates_hidden_file(client_with_claimed_project):\n    \"\"\"\n    Tests that the hidden file is created when hiding a version\n    \"\"\"\n    hidden_file_path = docat.DOCAT_UPLOAD_FOLDER / \"some-project\" / \"1.0.0\" / \".hidden\"\n\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # check open was called at least once with the correct path\n    with patch(\"docat.app.open\") as open_file_mock:\n        # hide\n        hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert hide_response.status_code == 200\n        assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n        open_file_mock.assert_called_once_with(hidden_file_path, \"w\")\n\n\ndef test_hide_fails_project_does_not_exist(client_with_claimed_project):\n    \"\"\"\n    Tests that hiding a version fails when the project does not exist\n    \"\"\"\n    with patch(\"docat.app.open\") as open_file_mock:\n        hide_response = client_with_claimed_project.post(\"/api/does-not-exist/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert hide_response.status_code == 404\n        assert hide_response.json() == {\"message\": \"Project does-not-exist not found\"}\n\n        open_file_mock.assert_not_called()\n\n\ndef test_hide_fails_version_does_not_exist(client_with_claimed_project):\n    \"\"\"\n    Tests that hiding a version fails when the version does not exist\n    \"\"\"\n    with patch(\"docat.app.open\") as open_file_mock:\n        # create a version\n        create_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert create_response.status_code == 201\n\n        # hide different version\n        hide_response = client_with_claimed_project.post(\"/api/some-project/2.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert hide_response.status_code == 404\n        assert hide_response.json() == {\"message\": \"Version 2.0.0 not found\"}\n\n        open_file_mock.assert_not_called()\n\n\ndef test_hide_fails_already_hidden(client_with_claimed_project):\n    \"\"\"\n    Tests that hiding a version fails when the version is already hidden\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # hide version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    with patch(\"docat.app.open\") as open_file_mock:\n        # hide version again\n        hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert hide_response.status_code == 400\n        assert hide_response.json() == {\"message\": \"Version 1.0.0 is already hidden\"}\n\n        open_file_mock.assert_not_called()\n\n\ndef test_hide_fails_no_token(client_with_claimed_project):\n    \"\"\"\n    Tests that hiding a version fails when no token is provided\n    \"\"\"\n    with patch(\"docat.app.open\") as open_file_mock:\n        # create a version\n        create_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert create_response.status_code == 201\n\n        # hide version\n        hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\")\n        assert hide_response.status_code == 401\n        assert hide_response.json() == {\"message\": \"Please provide a header with a valid Docat-Api-Key token for some-project\"}\n\n        open_file_mock.assert_not_called()\n\n\ndef test_hide_fails_invalid_token(client_with_claimed_project):\n    \"\"\"\n    Tests that hiding a version fails when an invalid token is provided\n    \"\"\"\n    with patch(\"docat.app.open\") as open_file_mock:\n        # create a version\n        create_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert create_response.status_code == 201\n\n        # hide version\n        hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"invalid\"})\n        assert hide_response.status_code == 401\n        assert hide_response.json() == {\"message\": \"Docat-Api-Key token is not valid for some-project\"}\n\n        open_file_mock.assert_not_called()\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_show(_, client_with_claimed_project):\n    \"\"\"\n    Tests that the version is no longer marked as hidden after requesting show.\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # hide the version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    # check hidden\n    project_details_response = client_with_claimed_project.get(\"/api/projects/some-project\")\n    assert project_details_response.status_code == 200\n    assert project_details_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [],\n    }\n\n    # show the version\n    show_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/show\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert show_response.status_code == 200\n    assert show_response.json() == {\"message\": \"Version 1.0.0 is now shown\"}\n\n    # check detected again\n    project_details_response = client_with_claimed_project.get(\"/api/projects/some-project\")\n    assert project_details_response.status_code == 200\n    assert project_details_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n    }\n\n\ndef test_show_deletes_hidden_file(client_with_claimed_project):\n    \"\"\"\n    Tests that the hidden file is deleted when requesting show.\n    \"\"\"\n    hidden_file_path = docat.DOCAT_UPLOAD_FOLDER / \"some-project\" / \"1.0.0\" / \".hidden\"\n\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # hide the version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    # check os.remove was called at least once with the correct path\n    with patch(\"os.remove\") as remove_file_mock:\n        # show again\n        show_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/show\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert show_response.status_code == 200\n        assert show_response.json() == {\"message\": \"Version 1.0.0 is now shown\"}\n\n        remove_file_mock.assert_called_once_with(hidden_file_path)\n\n\ndef test_show_fails_project_does_not_exist(client_with_claimed_project):\n    \"\"\"\n    Tests that showing a version fails when the project does not exist\n    \"\"\"\n    with patch(\"os.remove\") as delete_file_mock:\n        show_response = client_with_claimed_project.post(\"/api/does-not-exist/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert show_response.status_code == 404\n        assert show_response.json() == {\"message\": \"Project does-not-exist not found\"}\n\n        delete_file_mock.assert_not_called()\n\n\ndef test_show_fails_version_does_not_exist(client_with_claimed_project):\n    \"\"\"\n    Tests that showing a version fails when the version does not exist\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # hide the version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    with patch(\"os.remove\") as delete_file_mock:\n        # show different version\n        show_response = client_with_claimed_project.post(\"/api/some-project/2.0.0/show\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert show_response.status_code == 404\n        assert show_response.json() == {\"message\": \"Version 2.0.0 not found\"}\n\n        delete_file_mock.assert_not_called()\n\n\ndef test_show_fails_already_shown(client_with_claimed_project):\n    \"\"\"\n    Tests that showing a version fails when the version is already shown\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    with patch(\"os.remove\") as delete_file_mock:\n        # show version\n        show_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/show\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert show_response.status_code == 400\n        assert show_response.json() == {\"message\": \"Version 1.0.0 is not hidden\"}\n\n        delete_file_mock.assert_not_called()\n\n\ndef test_show_fails_no_token(client_with_claimed_project):\n    \"\"\"\n    Tests that showing a version fails when no token is provided\n    \"\"\"\n    with patch(\"os.remove\") as remove_file_mock:\n        # create a version\n        create_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert create_response.status_code == 201\n\n        # hide version\n        hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert hide_response.status_code == 200\n        assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n        # try to show without token\n        show_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/show\")\n        assert show_response.status_code == 401\n        assert show_response.json() == {\"message\": \"Please provide a header with a valid Docat-Api-Key token for some-project\"}\n\n        remove_file_mock.assert_not_called()\n\n\ndef test_show_fails_invalid_token(client_with_claimed_project):\n    \"\"\"\n    Tests that showing a version fails when an invalid token is provided\n    \"\"\"\n    with patch(\"os.remove\") as remove_file_mock:\n        # create a version\n        create_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert create_response.status_code == 201\n\n        # hide version\n        hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert hide_response.status_code == 200\n        assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n        # try to show without token\n        show_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/show\", headers={\"Docat-Api-Key\": \"invalid\"})\n        assert show_response.status_code == 401\n        assert show_response.json() == {\"message\": \"Docat-Api-Key token is not valid for some-project\"}\n\n        remove_file_mock.assert_not_called()\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_hide_and_show_with_tag(_, client_with_claimed_project):\n    \"\"\"\n    Tests that the version is no longer marked as hidden after requesting show on a tag.\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # create a tag\n    create_tag_response = client_with_claimed_project.put(\"/api/some-project/1.0.0/tags/latest\")\n    assert create_tag_response.status_code == 201\n    assert create_tag_response.json() == {\"message\": \"Tag latest -> 1.0.0 successfully created\"}\n\n    # hide the tag\n    hide_response = client_with_claimed_project.post(\"/api/some-project/latest/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version latest is now hidden\"}\n\n    # check hidden\n    project_details_response = client_with_claimed_project.get(\"/api/projects/some-project\")\n    assert project_details_response.status_code == 200\n    assert project_details_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [],\n    }\n\n    # show the version\n    show_response = client_with_claimed_project.post(\"/api/some-project/latest/show\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert show_response.status_code == 200\n    assert show_response.json() == {\"message\": \"Version latest is now shown\"}\n\n    # check detected again\n    project_details_response = client_with_claimed_project.get(\"/api/projects/some-project\")\n    assert project_details_response.status_code == 200\n    assert project_details_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [\"latest\"], \"hidden\": False}],\n    }\n"
  },
  {
    "path": "docat/tests/test_project.py",
    "content": "import io\nfrom datetime import datetime\nfrom unittest.mock import patch\n\nimport httpx\nfrom fastapi.testclient import TestClient\n\nimport docat.app as docat\nfrom docat.models import ProjectDetail, ProjectVersion\nfrom docat.utils import get_project_details\n\nclient = TestClient(docat.app)\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_project_api(_, temp_project_version):\n    docs = temp_project_version(\"project\", \"1.0\")\n    docs = temp_project_version(\"different-project\", \"1.0\")\n\n    with patch(\"docat.app.DOCAT_UPLOAD_FOLDER\", docs):\n        response = client.get(\"/api/projects\")\n\n        assert response.status_code == httpx.codes.OK\n        assert response.json() == {\n            \"projects\": [\n                {\n                    \"name\": \"different-project\",\n                    \"logo\": False,\n                    \"storage\": \"0 bytes\",\n                    \"versions\": [\n                        {\"name\": \"1.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [\"latest\"], \"hidden\": False},\n                    ],\n                },\n                {\n                    \"name\": \"project\",\n                    \"logo\": False,\n                    \"storage\": \"0 bytes\",\n                    \"versions\": [\n                        {\"name\": \"1.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [\"latest\"], \"hidden\": False},\n                    ],\n                },\n            ]\n        }\n\n\ndef test_project_api_without_any_projects():\n    response = client.get(\"/api/projects\")\n\n    assert response.status_code == httpx.codes.OK\n    assert response.json() == {\"projects\": []}\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_project_details_api(_, temp_project_version):\n    project = \"project\"\n    docs = temp_project_version(project, \"1.0\")\n    symlink_to_latest = docs / project / \"latest\"\n    assert symlink_to_latest.is_symlink()\n\n    with patch(\"docat.app.DOCAT_UPLOAD_FOLDER\", docs):\n        response = client.get(f\"/api/projects/{project}\")\n\n        assert response.status_code == httpx.codes.OK\n        assert response.json() == {\n            \"name\": \"project\",\n            \"storage\": \"0 bytes\",\n            \"versions\": [{\"name\": \"1.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [\"latest\"], \"hidden\": False}],\n        }\n\n\ndef test_project_details_api_with_a_project_that_does_not_exist():\n    response = client.get(\"/api/projects/i-do-not-exist\")\n\n    assert not response.status_code == httpx.codes.OK\n    assert response.json() == {\"message\": \"Project i-do-not-exist does not exist\"}\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_get_project_details_with_hidden_versions(_, client_with_claimed_project):\n    \"\"\"\n    Make sure that get_project_details works when include_hidden is set to True.\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # check detected before hiding\n    details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, \"some-project\", include_hidden=True)\n    assert details == ProjectDetail(\n        name=\"some-project\",\n        storage=\"20 bytes\",\n        versions=[ProjectVersion(name=\"1.0.0\", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)],\n    )\n\n    # hide the version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    # check hidden\n    details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, \"some-project\", include_hidden=True)\n    assert details == ProjectDetail(\n        name=\"some-project\",\n        storage=\"20 bytes\",\n        versions=[ProjectVersion(name=\"1.0.0\", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=True)],\n    )\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_project_details_without_hidden_versions(_, client_with_claimed_project):\n    \"\"\"\n    Make sure that project_details works when include_hidden is set to False.\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # check detected before hiding\n    details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, \"some-project\", include_hidden=False)\n    assert details == ProjectDetail(\n        name=\"some-project\",\n        storage=\"20 bytes\",\n        versions=[ProjectVersion(name=\"1.0.0\", timestamp=datetime(2000, 1, 1, 1, 1, 0), tags=[], hidden=False)],\n    )\n\n    # hide the version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    # check hidden\n    details = get_project_details(docat.DOCAT_UPLOAD_FOLDER, \"some-project\", include_hidden=False)\n    assert details == ProjectDetail(name=\"some-project\", storage=\"20 bytes\", versions=[])\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_include_hidden_parameter_for_get_projects(_, client_with_claimed_project):\n    \"\"\"\n    Make sure that include_hidden has the desired effect on the /api/projects endpoint.\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # check detected before hiding\n    get_projects_response = client_with_claimed_project.get(\"/api/projects\")\n    assert get_projects_response.status_code == 200\n    assert get_projects_response.json() == {\n        \"projects\": [\n            {\n                \"name\": \"some-project\",\n                \"logo\": False,\n                \"storage\": \"20 bytes\",\n                \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n            }\n        ]\n    }\n\n    # check include_hidden=True\n    get_projects_response = client_with_claimed_project.get(\"/api/projects?include_hidden=true\")\n    assert get_projects_response.status_code == 200\n    assert get_projects_response.json() == {\n        \"projects\": [\n            {\n                \"name\": \"some-project\",\n                \"logo\": False,\n                \"storage\": \"20 bytes\",\n                \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n            }\n        ]\n    }\n\n    # hide the version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    # check include_hidden=False\n    get_projects_response = client_with_claimed_project.get(\"/api/projects?include_hidden=false\")\n    assert get_projects_response.status_code == 200\n    assert get_projects_response.json() == {\"projects\": []}\n\n    # check include_hidden=True\n    get_projects_response = client_with_claimed_project.get(\"/api/projects?include_hidden=true\")\n    assert get_projects_response.status_code == 200\n    assert get_projects_response.json() == {\n        \"projects\": [\n            {\n                \"name\": \"some-project\",\n                \"logo\": False,\n                \"storage\": \"20 bytes\",\n                \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": True}],\n            }\n        ]\n    }\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_include_hidden_parameter_for_get_project_details(_, client_with_claimed_project):\n    \"\"\"\n    Make sure that include_hidden has the desired effect on the /api/project/{project} endpoint.\n    \"\"\"\n    # create a version\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    # check detected before hiding\n    get_projects_response = client_with_claimed_project.get(\"/api/projects/some-project\")\n    assert get_projects_response.status_code == 200\n    assert get_projects_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n    }\n\n    # check include_hidden=True\n    get_projects_response = client_with_claimed_project.get(\"/api/projects/some-project?include_hidden=true\")\n    assert get_projects_response.status_code == 200\n    assert get_projects_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n    }\n\n    # hide the version\n    hide_response = client_with_claimed_project.post(\"/api/some-project/1.0.0/hide\", headers={\"Docat-Api-Key\": \"1234\"})\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"message\": \"Version 1.0.0 is now hidden\"}\n\n    # check include_hidden=False\n    get_projects_response = client_with_claimed_project.get(\"/api/projects/some-project?include_hidden=false\")\n    assert get_projects_response.status_code == 200\n    assert get_projects_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [],\n    }\n\n    # check include_hidden=True\n    get_projects_response = client_with_claimed_project.get(\"/api/projects/some-project?include_hidden=true\")\n    assert get_projects_response.status_code == 200\n    assert get_projects_response.json() == {\n        \"name\": \"some-project\",\n        \"storage\": \"20 bytes\",\n        \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": True}],\n    }\n"
  },
  {
    "path": "docat/tests/test_rename.py",
    "content": "import io\nfrom pathlib import Path\nfrom unittest.mock import call, patch\n\nfrom tinydb import Query\n\nimport docat.app as docat\n\n\ndef test_rename_fail_project_does_not_exist(client_with_claimed_project):\n    with patch(\"os.rename\") as rename_mock:\n        response = client_with_claimed_project.put(\"/api/does-not-exist/rename/new-project-name\")\n        assert response.status_code == 404\n        assert response.json() == {\"message\": \"Project does-not-exist not found\"}\n\n        assert rename_mock.mock_calls == []\n\n\ndef test_rename_fail_new_project_name_already_used(client_with_claimed_project):\n    with patch(\"os.rename\") as rename_mock:\n        create_first_project_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert create_first_project_response.status_code == 201\n\n        create_second_project_response = client_with_claimed_project.post(\n            \"/api/second-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert create_second_project_response.status_code == 201\n\n        rename_response = client_with_claimed_project.put(\"/api/some-project/rename/second-project\")\n        assert rename_response.status_code == 409\n        assert rename_response.json() == {\"message\": \"New project name second-project already in use\"}\n\n        assert rename_mock.mock_calls == []\n\n\ndef test_rename_not_authenticated(client_with_claimed_project):\n    with patch(\"os.rename\") as rename_mock:\n        create_project_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\",\n            files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")},\n        )\n        assert create_project_response.status_code == 201\n\n        rename_response = client_with_claimed_project.put(\"/api/some-project/rename/new-project-name\")\n        assert rename_response.status_code == 401\n        assert rename_response.json() == {\"message\": \"Please provide a header with a valid Docat-Api-Key token for some-project\"}\n\n        assert rename_mock.mock_calls == []\n\n\ndef test_rename_success(client_with_claimed_project):\n    with patch(\"os.rename\") as rename_mock:\n        create_project_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\",\n            files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")},\n        )\n        assert create_project_response.status_code == 201\n\n        rename_response = client_with_claimed_project.put(\"/api/some-project/rename/new-project-name\", headers={\"Docat-Api-Key\": \"1234\"})\n        assert rename_response.status_code == 200\n        assert rename_response.json() == {\"message\": \"Successfully renamed project some-project to new-project-name\"}\n\n        old_path = docat.DOCAT_UPLOAD_FOLDER / Path(\"some-project\")\n        new_path = docat.DOCAT_UPLOAD_FOLDER / Path(\"new-project-name\")\n        assert rename_mock.mock_calls == [call(old_path, new_path)]\n\n        Project = Query()\n        table = docat.db.table(\"claims\")\n        claims_with_old_name = table.search(Project.name == \"some-project\")\n        assert len(claims_with_old_name) == 0\n        claims_with_new_name = table.search(Project.name == \"new-project-name\")\n        assert len(claims_with_new_name) == 1\n\n\ndef test_rename_rejects_forbidden_project_name(client_with_claimed_project):\n    \"\"\"\n    Names that conflict with pages in docat web are forbidden,\n    and renaming a project to such a name should fail.\n    \"\"\"\n\n    create_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_response.status_code == 201\n\n    with patch(\"os.rename\") as rename_mock:\n        for project_name in [\"upload\", \"claim\", \"Delete \", \"help\", \"Doc\", \"API\"]:\n            rename_response = client_with_claimed_project.put(f\"/api/some-project/rename/{project_name}\", headers={\"Docat-Api-Key\": \"1234\"})\n            assert rename_response.status_code == 400\n            assert rename_response.json() == {\n                \"message\": f'New project name \"{project_name}\" is forbidden, as it conflicts with pages in docat web.'\n            }\n\n            assert rename_mock.mock_calls == []\n"
  },
  {
    "path": "docat/tests/test_stats.py",
    "content": "import io\nfrom datetime import datetime\nfrom unittest.mock import patch\n\nimport pytest\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\n@pytest.mark.parametrize(\n    (\"project_config\", \"n_projects\", \"n_versions\", \"storage\"),\n    [\n        ([(\"some-project\", [\"1.0.0\"])], 1, 1, \"20 bytes\"),\n        ([(\"some-project\", [\"1.0.0\", \"2.0.0\"])], 1, 2, \"40 bytes\"),\n        ([(\"some-project\", [\"1.0.0\", \"2.0.0\"])], 1, 2, \"40 bytes\"),\n        ([(\"some-project\", [\"1.0.0\", \"2.0.0\"]), (\"another-project\", [\"1\"])], 2, 3, \"60 bytes\"),\n    ],\n)\ndef test_get_stats(_, project_config, n_projects, n_versions, storage, client_with_claimed_project):\n    \"\"\"\n    Make sure that get_stats works.\n    \"\"\"\n    # create a version\n    for project_name, versions in project_config:\n        for version in versions:\n            create_response = client_with_claimed_project.post(\n                f\"/api/{project_name}/{version}\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n            )\n            assert create_response.status_code == 201\n\n    # get system stats\n    hide_response = client_with_claimed_project.get(\"/api/stats\")\n    assert hide_response.status_code == 200\n    assert hide_response.json() == {\"n_projects\": n_projects, \"n_versions\": n_versions, \"storage\": storage}\n"
  },
  {
    "path": "docat/tests/test_upload.py",
    "content": "import io\nfrom pathlib import Path\nfrom unittest.mock import call, patch\n\nimport docat.app as docat\n\n\ndef test_successfully_upload(client):\n    with patch(\"docat.app.remove_docs\"):\n        response = client.post(\"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")})\n        response_data = response.json()\n\n        assert response.status_code == 201\n        assert response_data[\"message\"] == \"Documentation uploaded successfully\"\n        assert (docat.DOCAT_UPLOAD_FOLDER / \"some-project\" / \"1.0.0\" / \"index.html\").exists()\n\n\ndef test_successfully_override(client_with_claimed_project):\n    with patch(\"docat.app.remove_docs\") as remove_mock:\n        response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert response.status_code == 201\n\n        response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\",\n            files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")},\n            headers={\"Docat-Api-Key\": \"1234\"},\n        )\n        response_data = response.json()\n\n        assert response.status_code == 201\n        assert response_data[\"message\"] == \"Documentation uploaded successfully\"\n        assert remove_mock.mock_calls == [call(\"some-project\", \"1.0.0\", docat.DOCAT_UPLOAD_FOLDER)]\n\n\ndef test_tags_are_not_overwritten_without_api_key(client_with_claimed_project):\n    with patch(\"docat.app.remove_docs\") as remove_mock:\n        response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert response.status_code == 201\n\n        response = client_with_claimed_project.put(\"/api/some-project/1.0.0/tags/latest\")\n        assert response.status_code == 201\n\n        response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        response_data = response.json()\n\n        assert response.status_code == 401\n        assert response_data[\"message\"] == \"Please provide a header with a valid Docat-Api-Key token for some-project\"\n        assert remove_mock.mock_calls == []\n\n\ndef test_successful_tag_creation(client_with_claimed_project):\n    with patch(\"docat.app.create_symlink\") as create_symlink_mock:\n        create_project_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert create_project_response.status_code == 201\n\n        create_tag_response = client_with_claimed_project.put(\"/api/some-project/1.0.0/tags/latest\")\n\n        assert create_tag_response.status_code == 201\n        assert create_tag_response.json() == {\"message\": \"Tag latest -> 1.0.0 successfully created\"}\n\n        destination_path = docat.DOCAT_UPLOAD_FOLDER / Path(\"some-project\") / Path(\"latest\")\n        assert create_symlink_mock.mock_calls == [call(\"1.0.0\", destination_path), call().__bool__()]\n\n\ndef test_create_tag_fails_when_version_does_not_exist(client_with_claimed_project):\n    with patch(\"docat.app.create_symlink\") as create_symlink_mock:\n        create_project_response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n\n        assert create_project_response.status_code == 201\n\n        create_tag_response = client_with_claimed_project.put(\"/api/some-project/non-existing-version/tags/new-tag\")\n\n        assert create_tag_response.status_code == 404\n        assert create_tag_response.json() == {\"message\": \"Version non-existing-version not found\"}\n\n        assert create_symlink_mock.mock_calls == []\n\n\ndef test_create_tag_fails_on_overwrite_of_version(client_with_claimed_project):\n    \"\"\"\n    Create a tag with the same name as a version.\n    \"\"\"\n    project_name = \"some-project\"\n    version = \"1.0.0\"\n    tag = \"latest\"\n\n    create_first_project_response = client_with_claimed_project.post(\n        f\"/api/{project_name}/{version}\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_first_project_response.status_code == 201\n\n    create_second_project_response = client_with_claimed_project.post(\n        f\"/api/{project_name}/{tag}\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_second_project_response.status_code == 201\n\n    create_tag_response = client_with_claimed_project.put(f\"/api/{project_name}/{version}/tags/{tag}\")\n    assert create_tag_response.status_code == 409\n    assert create_tag_response.json() == {\"message\": f\"Tag {tag} would overwrite an existing version!\"}\n\n\ndef test_create_fails_on_overwrite_of_tag(client_with_claimed_project):\n    \"\"\"\n    Create a version with the same name as a tag.\n    \"\"\"\n    project_name = \"some-project\"\n    version = \"1.0.0\"\n    tag = \"some-tag\"\n\n    create_project_response = client_with_claimed_project.post(\n        f\"/api/{project_name}/{version}\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_project_response.status_code == 201\n\n    create_tag_response = client_with_claimed_project.put(f\"/api/{project_name}/{version}/tags/{tag}\")\n    assert create_tag_response.status_code == 201\n    assert create_tag_response.json() == {\"message\": f\"Tag {tag} -> {version} successfully created\"}\n\n    create_project_with_name_of_tag_response = client_with_claimed_project.post(\n        f\"/api/{project_name}/{tag}\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert create_project_with_name_of_tag_response.status_code == 409\n    assert create_project_with_name_of_tag_response.json() == {\"message\": \"Cannot overwrite existing tag with new version.\"}\n\n\ndef test_fails_with_invalid_token(client_with_claimed_project):\n    with patch(\"docat.app.remove_docs\") as remove_mock:\n        response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n        )\n        assert response.status_code == 201\n\n        response = client_with_claimed_project.post(\n            \"/api/some-project/1.0.0\",\n            files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")},\n            headers={\"Docat-Api-Key\": \"456\"},\n        )\n        response_data = response.json()\n\n        assert response.status_code == 401\n        assert response_data[\"message\"] == \"Docat-Api-Key token is not valid for some-project\"\n\n        assert remove_mock.mock_calls == []\n\n\ndef test_upload_rejects_forbidden_project_name(client_with_claimed_project):\n    \"\"\"\n    Names that conflict with pages in docat web are forbidden,\n    and creating a project with such a name should fail.\n    \"\"\"\n\n    with patch(\"docat.app.remove_docs\") as remove_mock:\n        for project_name in [\"upload\", \"claim\", \" Delete \", \"help\", \"DOC\", \"api\"]:\n            response = client_with_claimed_project.post(\n                f\"/api/{project_name}/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n            )\n            assert response.status_code == 400\n            assert response.json() == {\"message\": f'Project name \"{project_name}\" is forbidden, as it conflicts with pages in docat web.'}\n\n            assert remove_mock.mock_calls == []\n\n\ndef test_upload_issues_warning_missing_index_file(client_with_claimed_project):\n    \"\"\"\n    When a project is uploaded without an index.html file,\n    a warning should be issued, but the upload should succeed.\n    \"\"\"\n\n    response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"some-other-file.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    response_data = response.json()\n\n    assert response.status_code == 201\n    assert response_data[\"message\"] == \"Documentation uploaded successfully, but no index.html found at root of archive.\"\n    assert (docat.DOCAT_UPLOAD_FOLDER / \"some-project\" / \"1.0.0\" / \"some-other-file.html\").exists()\n    assert not (docat.DOCAT_UPLOAD_FOLDER / \"some-project\" / \"1.0.0\" / \"index.html\").exists()\n"
  },
  {
    "path": "docat/tests/test_upload_icon.py",
    "content": "import base64\nimport io\nfrom datetime import datetime\nfrom unittest.mock import call, patch\n\nimport docat.app as docat\n\nONE_PIXEL_PNG = base64.decodebytes(\n    b\"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdjYGBg+A8AAQQBAHAgZQsAAAAASUVORK5CYII=\"\n)\n\n\ndef test_successful_icon_upload(client_with_claimed_project):\n    upload_folder_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert upload_folder_response.status_code == 201\n\n    with patch(\"shutil.copyfileobj\") as copyfileobj_mock, patch(\"os.remove\") as remove_file_mock:\n        upload_response = client_with_claimed_project.post(\n            \"/api/some-project/icon\",\n            files={\"file\": (\"icon.jpg\", io.BytesIO(ONE_PIXEL_PNG), \"image/png\")},\n        )\n\n        assert upload_response.status_code == 200\n        assert upload_response.json() == {\"message\": \"Icon successfully uploaded\"}\n        assert remove_file_mock.mock_calls == []\n        assert len(copyfileobj_mock.mock_calls) == 1\n\n\ndef test_icon_upload_fails_with_no_project(client_with_claimed_project):\n    with patch(\"shutil.copyfileobj\") as copyfileobj_mock, patch(\"os.remove\") as remove_file_mock:\n        upload_response = client_with_claimed_project.post(\n            \"/api/non-existing-project/icon\",\n            files={\"file\": (\"icon.png\", io.BytesIO(ONE_PIXEL_PNG), \"image/png\")},\n        )\n\n        assert upload_response.status_code == 404\n        assert upload_response.json() == {\"message\": \"Project non-existing-project not found\"}\n        assert remove_file_mock.mock_calls == []\n        assert copyfileobj_mock.mock_calls == []\n\n\ndef test_icon_upload_fails_no_token_and_existing_icon(client):\n    \"\"\"\n    upload twice, first time should be successful (nothing replaced),\n    second time should fail (would need token to replace)\n    \"\"\"\n\n    upload_folder_response = client.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert upload_folder_response.status_code == 201\n\n    with patch(\"shutil.copyfileobj\") as copyfileobj_mock, patch(\"os.remove\") as remove_file_mock:\n        upload_response_1 = client.post(\n            \"/api/some-project/icon\",\n            files={\"file\": (\"icon.png\", io.BytesIO(ONE_PIXEL_PNG), \"image/png\")},\n        )\n        assert upload_response_1.status_code == 200\n        assert upload_response_1.json() == {\"message\": \"Icon successfully uploaded\"}\n        assert remove_file_mock.mock_calls == []\n        assert len(copyfileobj_mock.mock_calls) == 1\n\n    with patch(\"shutil.copyfileobj\") as copyfileobj_mock, patch(\"os.remove\") as remove_file_mock:\n        upload_response_2 = client.post(\n            \"/api/some-project/icon\",\n            files={\"file\": (\"icon.png\", io.BytesIO(ONE_PIXEL_PNG), \"image/png\")},\n        )\n        assert upload_response_2.status_code == 401\n        assert upload_response_2.json() == {\"message\": \"Please provide a header with a valid Docat-Api-Key token for some-project\"}\n        assert remove_file_mock.mock_calls == []\n        assert len(copyfileobj_mock.mock_calls) == 0\n\n\ndef test_icon_upload_successful_replacement_with_token(client_with_claimed_project):\n    \"\"\"\n    upload twice, both times should be successful (token provided)\n    \"\"\"\n\n    icon_path = docat.DOCAT_UPLOAD_FOLDER / \"some-project\" / \"logo\"\n\n    upload_folder_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert upload_folder_response.status_code == 201\n\n    with patch(\"shutil.copyfileobj\") as copyfileobj_mock, patch(\"os.remove\") as remove_file_mock:\n        upload_response_1 = client_with_claimed_project.post(\n            \"/api/some-project/icon\",\n            files={\"file\": (\"icon.png\", io.BytesIO(ONE_PIXEL_PNG), \"image/png\")},\n            headers={\"Docat-Api-Key\": \"1234\"},\n        )\n        assert upload_response_1.status_code == 200\n        assert upload_response_1.json() == {\"message\": \"Icon successfully uploaded\"}\n        assert remove_file_mock.mock_calls == []\n        assert len(copyfileobj_mock.mock_calls) == 1\n\n    with patch(\"shutil.copyfileobj\") as copyfileobj_mock, patch(\"os.remove\") as remove_file_mock:\n        upload_response_1 = client_with_claimed_project.post(\n            \"/api/some-project/icon\",\n            files={\"file\": (\"icon.png\", io.BytesIO(ONE_PIXEL_PNG), \"image/png\")},\n            headers={\"Docat-Api-Key\": \"1234\"},\n        )\n        assert upload_response_1.status_code == 200\n        assert upload_response_1.json() == {\"message\": \"Icon successfully uploaded\"}\n        assert remove_file_mock.mock_calls == [call(icon_path)]\n        assert len(copyfileobj_mock.mock_calls) == 1\n\n\ndef test_icon_upload_successful_no_token_no_existing_icon(client):\n    upload_folder_response = client.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert upload_folder_response.status_code == 201\n\n    with patch(\"shutil.copyfileobj\") as copyfileobj_mock, patch(\"os.remove\") as remove_file_mock:\n        upload_response = client.post(\n            \"/api/some-project/icon\",\n            files={\"file\": (\"icon.png\", io.BytesIO(ONE_PIXEL_PNG), \"image/png\")},\n        )\n\n        assert upload_response.status_code == 200\n        assert upload_response.json() == {\"message\": \"Icon successfully uploaded\"}\n        assert remove_file_mock.mock_calls == []\n        assert len(copyfileobj_mock.mock_calls) == 1\n\n\ndef test_icon_upload_fails_no_image(client_with_claimed_project):\n    upload_folder_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert upload_folder_response.status_code == 201\n\n    with patch(\"shutil.copyfileobj\") as copyfileobj_mock, patch(\"os.remove\") as remove_file_mock:\n        upload_response = client_with_claimed_project.post(\n            \"/api/some-project/icon\",\n            files={\"file\": (\"file.zip\", io.BytesIO(b\"not image data\"), \"application/zip\")},\n        )\n\n        assert upload_response.status_code == 400\n        assert upload_response.json() == {\"message\": \"Icon must be an image\"}\n        assert remove_file_mock.mock_calls == []\n        assert copyfileobj_mock.mock_calls == []\n\n\n@patch(\"docat.utils.get_version_timestamp\", return_value=datetime(2000, 1, 1, 1, 1, 0))\ndef test_get_project_recongizes_icon(_, client_with_claimed_project):\n    \"\"\"\n    get_projects should return true, if the project has an icon\n    \"\"\"\n\n    upload_folder_response = client_with_claimed_project.post(\n        \"/api/some-project/1.0.0\", files={\"file\": (\"index.html\", io.BytesIO(b\"<h1>Hello World</h1>\"), \"plain/text\")}\n    )\n    assert upload_folder_response.status_code == 201\n\n    projects_response = client_with_claimed_project.get(\"/api/projects\")\n    assert projects_response.status_code == 200\n    assert projects_response.json() == {\n        \"projects\": [\n            {\n                \"name\": \"some-project\",\n                \"logo\": False,\n                \"storage\": \"20 bytes\",\n                \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n            }\n        ]\n    }\n\n    upload_response = client_with_claimed_project.post(\n        \"/api/some-project/icon\",\n        files={\"file\": (\"icon.jpg\", io.BytesIO(ONE_PIXEL_PNG), \"image/png\")},\n    )\n    assert upload_response.status_code == 200\n\n    projects_response = client_with_claimed_project.get(\"/api/projects\")\n    assert projects_response.status_code == 200\n    assert projects_response.json() == {\n        \"projects\": [\n            {\n                \"name\": \"some-project\",\n                \"logo\": True,\n                \"storage\": \"103 bytes\",\n                \"versions\": [{\"name\": \"1.0.0\", \"timestamp\": \"2000-01-01T01:01:00\", \"tags\": [], \"hidden\": False}],\n            }\n        ]\n    }\n"
  },
  {
    "path": "docat/tests/test_utils.py",
    "content": "from pathlib import Path\nfrom unittest.mock import MagicMock, patch\n\nimport docat.app as docat\nfrom docat.utils import create_symlink, extract_archive, get_dir_size, remove_docs\n\n\ndef test_symlink_creation():\n    \"\"\"\n    Tests the creation of a symlink\n    \"\"\"\n    source = MagicMock()\n    destination = MagicMock()\n    destination.exists.return_value = False\n    destination.symlink_to.return_value = MagicMock()\n\n    assert create_symlink(source, destination)\n\n    destination.symlink_to.assert_called_once_with(source)\n\n\ndef test_symlink_creation_overwrite_destination():\n    \"\"\"\n    Tests the creation of a symlink and overwriting\n    of existing symlink\n    \"\"\"\n    source = MagicMock()\n    destination = MagicMock()\n    destination.exists.return_value = True\n    destination.is_symlink.return_value = True\n    destination.unlink.return_value = MagicMock()\n    destination.symlink_to.return_value = MagicMock()\n\n    assert create_symlink(source, destination)\n\n    destination.unlink.assert_called_once()\n    destination.symlink_to.assert_called_once_with(source)\n\n\ndef test_symlink_creation_do_not_overwrite_destination():\n    \"\"\"\n    Tests wether a symlinc is not created when it\n    would overwrite an existing version\n    \"\"\"\n    source = MagicMock()\n    destination = MagicMock()\n    destination.exists.return_value = True\n    destination.is_symlink.return_value = False\n    destination.unlink.return_value = MagicMock()\n    destination.symlink_to.return_value = MagicMock()\n\n    assert not create_symlink(source, destination)\n\n    destination.unlink.assert_not_called()\n    destination.symlink_to.assert_not_called()\n\n\ndef test_archive_artifact():\n    target_file = Path(\"/some/zipfile.zip\")\n    destination = \"/tmp/null\"\n    with patch.object(Path, \"unlink\") as mock_unlink, patch(\"docat.utils.ZipFile\") as mock_zip:\n        mock_zip_open = MagicMock()\n        mock_zip.return_value.__enter__.return_value.extractall = mock_zip_open\n\n        extract_archive(target_file, destination)\n\n        mock_zip.assert_called_once_with(target_file, \"r\")\n        mock_zip_open.assert_called_once()\n        mock_unlink.assert_called_once()\n\n\ndef test_remove_version(temp_project_version):\n    docs = temp_project_version(\"project\", \"1.0\")\n    remove_docs(\"project\", \"1.0\", docat.DOCAT_UPLOAD_FOLDER)\n\n    assert docs.exists()\n    assert not (docs / \"project\").exists()\n\n\ndef test_remove_symlink_version(temp_project_version):\n    project = \"project\"\n    docs = temp_project_version(project, \"1.0\")\n    symlink_to_latest = docs / project / \"latest\"\n    assert symlink_to_latest.is_symlink()\n\n    remove_docs(project, \"latest\", docat.DOCAT_UPLOAD_FOLDER)\n\n    assert not symlink_to_latest.exists()\n\n\ndef test_broken_symlinks_in_projects(temp_project_version):\n    project = \"project\"\n    docs = temp_project_version(project, \"1.0\")\n\n    create_symlink(docs / project / \"broken\", docs / project / \"latest\")\n\n    get_dir_size(docs / project)\n"
  },
  {
    "path": "web/.gitignore",
    "content": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# production\n/build\n/dist\n\n# misc\n.prettierrc\n.DS_Store\n.env.local\n.env.development.local\n.env.test.local\n.env.production.local\n\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env\n.env.local\n.env.*.local\n"
  },
  {
    "path": "web/.prettierignore",
    "content": "node_modules\n# Ignore artifacts:\nbuild\ncoverage\n"
  },
  {
    "path": "web/.prettierrc.json",
    "content": "{\n  \"trailingComma\": \"none\",\n  \"tabWidth\": 2,\n  \"semi\": false,\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "web/README.md",
    "content": "# docat web\n\n## Project setup\n\n```sh\nyarn install [--pure-lockfile]\n```\n\n### Compiles and hot-reloads for development\n\nThe script for `yarn start` automatically sets `VITE_DOCAT_VERSION` to display the current version in the footer,\nso you can just run:\n\n```sh\nyarn start\n```\n\n### Compiles and minifies for production\n\nTo display the current version of docat in the footer, use the following script to set `VITE_DOCAT_VERSION`.\nThis one liner uses the latest tag, if there is one on the current commit, and the current commit if not.\n\n```sh\nVITE_DOCAT_VERSION=$(git describe --tags --always) yarn build\n```\n\nOtherwise you can just use the following and the footer will show `unknown`.\n\n```sh\nyarn build\n```\n\n### Lints and fixes files\n\n```sh\nyarn lint\n```\n\n### Tests\n\n```sh\nyarn test\n```\n\n### Basic Header Theming\n\nNot happy with the default Docat logo and header?\nJust add your custom html header to the `/var/www/html/config.json` file.\n\n```json\n{\n  \"headerHTML\": \"<h1>MyCompany</h1>\",\n  \"footerHTML\": \"Contact <a href='mailto:maintainers@contact.mail'>Maintainers</a>\"\n}\n```\n\n\n## Development\n\n```sh\nsudo docker run \\\n  --detach \\\n  --volume /path/to/doc:/var/docat/doc/ \\\n  --publish 8000:80 \\\n  docat\n```\n\n## Errors\n\nIf you get a 403 response when trying to read a version,\ntry changing the permissions of your docs folder on your host.\n\n```sh\nsudo chmod 777 /path/to/doc -r\n```\n"
  },
  {
    "path": "web/eslint.config.js",
    "content": "import eslintReact from \"@eslint-react/eslint-plugin\";\nimport eslintJs from \"@eslint/js\";\nimport { defineConfig } from \"eslint/config\";\nimport tseslint from \"typescript-eslint\";\nimport eslintConfigPrettier from \"eslint-config-prettier\";\nimport globals from 'globals'\n\nexport default defineConfig(\n  {\n    files: [\"**/*.ts\", \"**/*.tsx\"],\n    ignores: ['dist/**', 'vite-env.d.ts', 'vite.config.ts'],\n\n    // Extend recommended rule sets from:\n    // 1. ESLint JS's recommended rules\n    // 2. TypeScript ESLint recommended rules\n    // 3. ESLint React's recommended-typescript rules\n    // 4. Prettier (Must be last to disable conflicting rules)\n    extends: [\n      eslintJs.configs.recommended,\n      tseslint.configs.recommended,\n      eslintReact.configs[\"recommended-typescript\"],\n      eslintConfigPrettier,\n    ],\n\n    // Configure language/parsing options\n    languageOptions: {\n      ecmaVersion: 'latest', // Allow modern JS syntax\n      globals: {\n        ...globals.browser, // Allow browser globals like `window`\n      },\n      parser: tseslint.parser, // Your existing parser\n      parserOptions: {\n        projectService: true,\n        tsconfigRootDir: import.meta.dirname,\n      },\n    },\n\n    // TODO: See if some of these could be fixed\n    rules: {\n      \"@eslint-react/dom-no-dangerously-set-innerhtml\": \"off\",\n      \"@eslint-react/exhaustive-deps\": \"off\",\n      \"@eslint-react/set-state-in-effect\": \"off\",\n    },\n  },\n);\n"
  },
  {
    "path": "web/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n    <link rel=\"icon\" href=\"favicon.ico\">\n  </head>\n  <body>\n    <noscript>\n      <strong>We're sorry docat web doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>\n    </noscript>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/index.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "web/package.json",
    "content": "{\n  \"name\": \"docat-web\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"type\": \"module\",\n  \"dependencies\": {\n    \"@emotion/react\": \"^11.14.0\",\n    \"@emotion/styled\": \"^11.14.1\",\n    \"@mui/icons-material\": \"^9.0.0\",\n    \"@mui/material\": \"^9.0.0\",\n    \"fuse.js\": \"^7.3.0\",\n    \"react\": \"^19.2.5\",\n    \"react-dom\": \"^19.2.5\",\n    \"react-markdown\": \"^10.1.0\",\n    \"react-router\": \"^7.14.0\",\n    \"react-router-dom\": \"^7.14.0\",\n    \"semver\": \"^7.7.4\",\n    \"typescript\": \"^6.0.2\",\n    \"vite\": \"^8.0.8\"\n  },\n  \"scripts\": {\n    \"start\": \"VITE_DOCAT_VERSION=$(git describe --tags --always) vite\",\n    \"build\": \"tsc && vite build\",\n    \"preview\": \"vite preview\",\n    \"test\": \"vitest --watch=false\",\n    \"lint\": \"eslint .\"\n  },\n  \"browserslist\": {\n    \"production\": [\n      \">0.2%\",\n      \"not dead\",\n      \"not op_mini all\"\n    ],\n    \"development\": [\n      \"last 1 chrome version\",\n      \"last 1 firefox version\",\n      \"last 1 safari version\"\n    ]\n  },\n  \"devDependencies\": {\n    \"@vitejs/plugin-react\": \"^6.0.1\",\n    \"@eslint/js\": \"^10.0.1\",\n    \"@types/semver\": \"^7.7.1\",\n    \"@types/node\": \"^25.6.0\",\n    \"@types/react\": \"^19.2.14\",\n    \"@types/react-dom\": \"^19.2.3\",\n    \"jsdom\": \"^29.0.2\",\n    \"eslint\": \"^10.2.0\",\n    \"eslint-config-prettier\": \"^10.1.8\",\n    \"@eslint-react/eslint-plugin\": \"^4.2.3\",\n    \"globals\": \"^17.4.0\",\n    \"prettier\": \"^3.8.2\",\n    \"typescript-eslint\": \"^8.58.1\",\n    \"vitest\": \"^4.1.4\"\n  }\n}\n"
  },
  {
    "path": "web/src/App.tsx",
    "content": "import React from 'react';\nimport { createBrowserRouter, RouterProvider } from 'react-router-dom'\nimport { ConfigDataProvider } from './data-providers/ConfigDataProvider'\nimport { MessageBannerProvider } from './data-providers/MessageBannerProvider'\nimport { ProjectDataProvider } from './data-providers/ProjectDataProvider'\nimport { SearchProvider } from './data-providers/SearchProvider'\nimport { StatsDataProvider } from './data-providers/StatsDataProvider'\nimport Claim from './pages/Claim'\nimport Delete from './pages/Delete'\nimport Docs from './pages/Docs'\nimport Help from './pages/Help'\nimport Home from './pages/Home'\nimport NotFound from './pages/NotFound'\nimport Upload from './pages/Upload'\n\nfunction App(): React.JSX.Element {\n  const router = createBrowserRouter([\n    {\n      path: '/',\n      errorElement: <NotFound />,\n      children: [\n        {\n          path: '',\n          element: <Home />\n        },\n        {\n          path: 'upload',\n          element: <Upload />\n        },\n        {\n          path: 'claim',\n          element: <Claim />\n        },\n        {\n          path: 'delete',\n          element: <Delete />\n        },\n        {\n          path: 'help',\n          element: <Help />\n        },\n        {\n          path: ':project',\n          children: [\n            {\n              path: '',\n              element: <Docs />\n            },\n            {\n              path: ':version',\n              children: [\n                {\n                  path: '',\n                  element: <Docs />\n                },\n                {\n                  path: '*',\n                  element: <Docs />\n                }\n              ]\n            }\n          ]\n        }\n      ]\n    }\n  ])\n\n  return (\n    <div className=\"App\">\n      <MessageBannerProvider>\n        <ConfigDataProvider>\n          <ProjectDataProvider>\n            <StatsDataProvider>\n              <SearchProvider>\n                <RouterProvider router={router} />\n              </SearchProvider>\n            </StatsDataProvider>\n          </ProjectDataProvider>\n        </ConfigDataProvider>\n      </MessageBannerProvider>\n    </div>\n  )\n}\n\nexport default App\n"
  },
  {
    "path": "web/src/components/DataSelect.tsx",
    "content": "import { FormGroup, MenuItem, TextField } from '@mui/material'\nimport React, { useState } from 'react'\n\ninterface Props {\n  emptyMessage: string\n  errorMsg?: string\n  value?: string\n  label: string\n  values: string[]\n  onChange: (value: string) => void\n}\n\nexport default function DataSelect(props: Props): React.JSX.Element {\n  const [selectedValue, setSelectedValue] = useState<string>(\n    props.value ?? 'none'\n  )\n\n  // clear field if selected value is not in options\n  if (selectedValue !== 'none' && !props.values.includes(selectedValue)) {\n    setSelectedValue('none')\n  }\n\n  return (\n    <FormGroup>\n      <TextField\n        onChange={(e: { target: { value: string } }) => {\n          setSelectedValue(e.target.value)\n          props.onChange(e.target.value)\n        }}\n        value={props.values.length > 0 ? selectedValue : 'none'}\n        label={props.label}\n        error={props.errorMsg !== undefined && props.errorMsg !== ''}\n        helperText={props.errorMsg}\n        select\n      >\n        <MenuItem value=\"none\" disabled>\n          {props.emptyMessage}\n        </MenuItem>\n\n        {props.values.map((value) => {\n          return (\n            <MenuItem key={value} value={value}>\n              {value}\n            </MenuItem>\n          )\n        })}\n      </TextField>\n    </FormGroup>\n  )\n}\n"
  },
  {
    "path": "web/src/components/DocumentControlButtons.tsx",
    "content": "import { Home, Share } from '@mui/icons-material'\nimport {\n  Checkbox,\n  FormControl,\n  FormControlLabel,\n  FormGroup,\n  MenuItem,\n  Modal,\n  Select,\n  Tooltip\n} from '@mui/material'\nimport React, { useState } from 'react'\nimport { Link } from 'react-router-dom'\nimport type ProjectDetails from '../models/ProjectDetails'\n\nimport styles from './../style/components/DocumentControlButtons.module.css'\n\ninterface Props {\n  version: string\n  versions: ProjectDetails[]\n  onVersionChange: (version: string) => void\n  getShareUrl: (options: { useLatest: boolean, hideUi: boolean }) => string\n}\n\nexport default function DocumentControlButtons(props: Props): React.JSX.Element {\n  const buttonStyle = { width: '25px', height: '25px' }\n\n  const [shareModalOpen, setShareModalOpen] = useState<boolean>(false)\n  const [shareModalUseLatest, setShareModalUseLatest] = useState<boolean>(false)\n  const [shareModalHideUi, setShareModalHideUi] = useState<boolean>(false)\n\n  // Cannot copy when page is served over HTTP\n  const canCopy = navigator.clipboard !== undefined\n\n  return (\n    <div className={styles.controls}>\n      <Tooltip title=\"Docs Overview\" placement=\"top\" arrow>\n        <Link to=\"/\" className={styles['home-button']}>\n          <Home sx={buttonStyle} />\n        </Link>\n      </Tooltip>\n\n      <FormControl>\n        <Select\n          sx={{\n            \"&.MuiOutlinedInput-root\": {\n              \"&:hover fieldset\": {\n                borderColor: \"rgba(0, 0, 0, 0.33)\"\n              },\n              \"&.Mui-focused fieldset\": {\n                borderColor: \"rgba(0, 0, 0, 0.33)\"\n              }\n            }\n          }}\n          className={styles['version-select']}\n          onChange={(e) => {\n            props.onVersionChange(e.target.value)\n          }}\n          value={\n            props.versions.find((v) => v.name === props.version) !== undefined\n              ? props.version\n              : ''\n          }\n        >\n          {props.versions\n            .filter((v) => !v.hidden || v.name === props.version)\n            .map((v) => (\n              <MenuItem key={v.name} value={v.name}>\n                {v.name + (v.tags.length > 0 ? ` (${v.tags.join(', ')})` : '')}\n              </MenuItem>\n            ))}\n        </Select>\n      </FormControl>\n\n      <Tooltip title=\"Share Link\" placement=\"top\" arrow>\n        <button\n          className={styles['share-button']}\n          onClick={() => {\n            setShareModalOpen(true)\n          }}\n        >\n          <Share sx={buttonStyle} />\n        </button>\n      </Tooltip>\n\n      <Modal\n        open={shareModalOpen}\n        onClose={() => {\n          setShareModalOpen(false)\n        }}\n        aria-labelledby=\"share-modal-title\"\n        aria-describedby=\"share-modal-description\"\n      >\n        <div className={styles['share-modal']}>\n          <div className={styles['share-modal-link-container']}>\n            <p className={styles['share-modal-link']}>{props.getShareUrl({ useLatest: shareModalUseLatest, hideUi: shareModalHideUi })}</p>\n            {canCopy && (\n              <div className={styles['share-modal-copy-container']}>\n                <button\n                  className={styles['share-modal-copy']}\n                  onClick={async () => {\n                    const url = props.getShareUrl({\n                      useLatest: shareModalUseLatest,\n                      hideUi: shareModalHideUi\n                    });\n                    await navigator.clipboard.writeText(url);\n                  }}\n                >\n                  Copy\n                </button>\n              </div>\n            )}\n          </div>\n\n          <FormGroup>\n            <FormControlLabel\n              control={\n                <Checkbox\n                  checked={shareModalHideUi}\n                  onChange={(e) => {\n                    setShareModalHideUi(e.target.checked)\n                  }}\n                />\n              }\n              label=\"Hide Version Selector\"\n              className={styles['share-modal-label']}\n            />\n            <FormControlLabel\n              control={\n                <Checkbox\n                  checked={shareModalUseLatest}\n                  onChange={(e) => {\n                    setShareModalUseLatest(e.target.checked)\n                  }}\n                />\n              }\n              label=\"Always use latest version\"\n              className={styles['share-modal-label']}\n            />\n          </FormGroup>\n\n          <button\n            className={styles['share-modal-close']}\n            onClick={() => {\n              setShareModalOpen(false)\n            }}\n          >\n            Close\n          </button>\n        </div>\n      </Modal>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/FavoriteStar.tsx",
    "content": "import { Star, StarOutlined } from '@mui/icons-material'\nimport React, { useState } from 'react'\nimport ProjectRepository from '../repositories/ProjectRepository'\n\ninterface Props {\n  projectName: string\n  onFavoriteChanged: () => void\n}\n\nexport default function FavoriteStar(props: Props): React.JSX.Element {\n  const [isFavorite, setIsFavorite] = useState<boolean>(\n    ProjectRepository.isFavorite(props.projectName)\n  )\n\n  const toggleFavorite = (): void => {\n    const newIsFavorite = !isFavorite\n    ProjectRepository.setFavorite(props.projectName, newIsFavorite)\n    setIsFavorite(newIsFavorite)\n\n    props.onFavoriteChanged()\n  }\n\n  const StarType = isFavorite ? Star : StarOutlined\n\n  return (\n    <StarType\n      style={{ color: '#505050', cursor: 'pointer' }}\n      onClick={toggleFavorite}\n    />\n  )\n}\n"
  },
  {
    "path": "web/src/components/FileInput.tsx",
    "content": "import { InputLabel } from '@mui/material'\nimport React, { useRef, useState } from 'react'\n\nimport styles from './../style/components/FileInput.module.css'\n\ninterface Props {\n  label: string\n  okTypes: string[]\n  file: File | undefined\n  onChange: (file: File | undefined) => void\n  isValid: (file: File) => boolean\n}\n\nexport default function FileInput(props: Props): React.JSX.Element {\n  const [fileName, setFileName] = useState<string>(\n    props.file?.name !== undefined ? props.file.name : ''\n  )\n  const [dragActive, setDragActive] = useState<boolean>(false)\n  const inputRef = useRef(null)\n\n  /**\n   * Checks if a file was selected and if it is valid\n   * before it is selected.\n   * @param files FileList from the event\n   */\n  const updateFileIfValid = (files: FileList | null): void => {\n    if (files == null || files.length < 1 || files[0] == null) {\n      return\n    }\n\n    const file = files[0]\n    if (!props.isValid(file)) {\n      return\n    }\n\n    setFileName(file.name)\n    props.onChange(file)\n  }\n\n  /**\n   * This updates the file upload container to show a custom style when\n   * the user is dragging a file into or out of the container.\n   * @param e drag enter event\n   */\n  const handleDragEvents = (e: React.DragEvent<HTMLDivElement>): void => {\n    e.preventDefault()\n    e.stopPropagation()\n\n    if (e.type === 'dragenter' || e.type === 'dragover') {\n      setDragActive(true)\n    } else if (e.type === 'dragleave') {\n      setDragActive(false)\n    }\n  }\n\n  /**\n   * Handles the drop event when the user drops a file into the container.\n   * @param e DragEvent\n   */\n  const handleDrop = (e: React.DragEvent<HTMLDivElement>): void => {\n    e.preventDefault()\n    e.stopPropagation()\n    setDragActive(false)\n\n    if (e.dataTransfer?.files[0] == null) {\n      return\n    }\n\n    updateFileIfValid(e.dataTransfer.files)\n  }\n\n  /**\n   * Handles the file input via the file browser.\n   * @param e change event\n   */\n  const handleSelect = (e: React.ChangeEvent<HTMLInputElement>): void => {\n    e.preventDefault()\n\n    updateFileIfValid(e.target.files)\n  }\n\n  /**\n   * This triggers the input when the container is clicked.\n   */\n  const onButtonClick = (): void => {\n    if (inputRef?.current != null) {\n      // @ts-expect-error - the ref is not null, therefore the button should be able to be clicked\n      inputRef.current.click()\n    }\n  }\n\n  return (\n    <div className={styles['file-upload-container']}>\n      {!dragActive && (\n        <InputLabel className={styles['file-upload-label']}>\n          {props.label}\n        </InputLabel>\n      )}\n\n      <div\n        className={\n          dragActive\n            ? styles['file-drop-zone'] + ' ' + styles['drag-active']\n            : styles['file-drop-zone']\n        }\n        onDragEnter={handleDragEvents}\n        onClick={onButtonClick}\n      >\n        <input\n          name=\"upload\"\n          type=\"file\"\n          className={styles['file-input']}\n          ref={inputRef}\n          accept={props.okTypes.join(',')}\n          onChange={handleSelect}\n        />\n\n        {fileName !== '' && (\n          <>\n            <p>{fileName}</p>\n            <p>-</p>\n          </>\n        )}\n\n        <p>Drag zip file here or</p>\n\n        <button className={styles['file-upload-button']} type=\"button\">\n          click to browse.\n        </button>\n\n        {dragActive && (\n          <div\n            className={styles['drag-file-element']}\n            onDragEnter={handleDragEvents}\n            onDragLeave={handleDragEvents}\n            onDragOver={handleDragEvents}\n            onDrop={handleDrop}\n          ></div>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/Footer.tsx",
    "content": "import { Box } from '@mui/material'\nimport { useState, JSX } from 'react'\nimport { Link } from 'react-router-dom'\nimport { useConfig } from '../data-providers/ConfigDataProvider'\nimport styles from './../style/components/Footer.module.css'\n\nexport default function Footer(): JSX.Element {\n\n  const defaultFooter = (\n    <></>\n  )\n\n  const [footer, setFooter] = useState<JSX.Element>(defaultFooter)\n  const config = useConfig()\n\n  // set custom header if found in config\n  if (config.footerHTML != null && footer === defaultFooter) {\n    setFooter(<div dangerouslySetInnerHTML={{ __html: config.footerHTML }} />)\n  }\n\n  return (\n    <div className={styles.footer}>\n      <Link to=\"/help\" className={styles['help-link']}>\n        HELP\n      </Link>\n\n      <Box sx={{ fontSize: '1.05em', fontWeight: 300, opacity: 0.6, marginLeft: '8px', marginTop: 1 }}>\n        {footer}\n      </Box>\n\n      <div className={styles['version-info']}>\n        <Link to=\"https://github.com/docat-org/docat\" target='_blank'>\n          VERSION{'  '}\n          {import.meta.env.VITE_DOCAT_VERSION ?? 'unknown'}\n        </Link>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/Header.tsx",
    "content": "import { useState, JSX } from 'react'\nimport { Link } from 'react-router-dom'\n\nimport { useConfig } from '../data-providers/ConfigDataProvider'\n\nimport docatLogo from '../assets/logo.png'\nimport styles from './../style/components/Header.module.css'\n\n\nexport default function Header(): JSX.Element {\n  const defaultHeader = (\n    <>\n      <img alt=\"docat logo\" src={docatLogo} />\n      <h1>DOCAT</h1>\n    </>\n  )\n\n  const [header, setHeader] = useState<JSX.Element>(defaultHeader)\n  const config = useConfig()\n\n  // set custom header if found in config\n  if (config.headerHTML != null && header === defaultHeader) {\n    setHeader(<div dangerouslySetInnerHTML={{ __html: config.headerHTML }} />)\n  }\n\n  return (\n    <div className={styles.header}>\n      <Link to=\"/\">{header}</Link>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/IFrame.tsx",
    "content": "import React, { useRef } from 'react'\nimport { generateKey } from '../data-providers/RandomId'\n\nimport styles from '../style/components/IFrame.module.css'\ninterface Props {\n  src: string\n  onPageChanged: (page: string, hash: string, title?: string) => void\n  onHashChanged: (hash: string) => void\n  onTitleChanged: (title: string) => void\n  onNotFound: () => void\n  onFaviconChanged?: (faviconUrl: string | null) => void\n}\n\nexport default function IFrame(props: Props): React.JSX.Element {\n  const iFrameRef = useRef<HTMLIFrameElement>(null)\n\n  const onIframeLoad = (): void => {\n    if (iFrameRef.current === null) {\n      console.error('iFrameRef is null')\n      return\n    }\n\n    // remove the event listeners to prevent memory leaks\n    iFrameRef.current.contentWindow?.removeEventListener(\n      'hashchange',\n      hashChangeEventListener\n    )\n    iFrameRef.current.contentWindow?.removeEventListener(\n      'titlechange',\n      titleChangeEventListener\n    )\n\n    const url = iFrameRef.current?.contentDocument?.location.href\n    if (url == null) {\n      console.warn('IFrame onload event triggered, but url is null')\n      return\n    }\n\n    // make all external links in iframe open in new tab\n    // and make internal links replace the iframe url so that change\n    // doesn't show up in the page history (we'd need to click back twice)\n    iFrameRef.current.contentDocument\n      ?.querySelectorAll('a')\n      .forEach((a: HTMLAnchorElement) => {\n        if (typeof a.href === 'string' && !a.href.startsWith(window.location.origin)) {\n          a.setAttribute('target', '_blank')\n          return\n        }\n\n        const href = a.getAttribute('href') ?? ''\n        if (href.trim() === '') {\n          // ignore empty links, may be handled with js internally.\n          // Will inevitably cause the user to have to click back\n          // multiple times to get back to the previous page.\n          return\n        }\n\n        // From here: https://www.ozzu.com/questions/358584/how-do-you-ignore-iframes-javascript-history\n        a.onclick = () => {\n          iFrameRef.current?.contentWindow?.location.replace(a.href)\n          return false\n        }\n      })\n\n    // React to page 404ing\n    void (async (): Promise<void> => {\n      const response = await fetch(url, { method: 'HEAD' })\n      if (response.status === 404) {\n        props.onNotFound()\n      }\n    })()\n\n    // Add the event listener again\n    iFrameRef.current.contentWindow?.addEventListener(\n      'hashchange',\n      hashChangeEventListener\n    )\n    iFrameRef.current.contentWindow?.addEventListener(\n      'titlechange',\n      titleChangeEventListener\n    )\n\n    const parts = url.split('/doc/').slice(1).join('/doc/').split('/')\n    const urlPageAndHash = parts.slice(2).join('/')\n    const hashIndex = urlPageAndHash.includes('#')\n      ? urlPageAndHash.indexOf('#')\n      : urlPageAndHash.length\n    const urlPage = urlPageAndHash.slice(0, hashIndex)\n    const urlHash = urlPageAndHash.slice(hashIndex)\n    const title = iFrameRef.current?.contentDocument?.title\n\n    props.onPageChanged(urlPage, urlHash, title)\n\n    const favicon = extractFaviconUrl(iFrameRef.current.contentDocument)\n    props.onFaviconChanged?.(favicon)\n  }\n\n  const extractFaviconUrl = (doc: Document | null | undefined): string | null => {\n    if (doc == null) {\n      return null\n    }\n\n    const link = doc.querySelector('link[rel=\"icon\"]') as HTMLLinkElement | null\n    if (!link?.href) {\n      return null\n    }\n\n    return link.href\n  }\n\n  const hashChangeEventListener = (): void => {\n    if (iFrameRef.current === null) {\n      console.error('hashChangeEvent from iframe but iFrameRef is null')\n      return\n    }\n\n    const url = iFrameRef.current?.contentDocument?.location.href\n    if (url == null) {\n      return\n    }\n\n    let hash = url.split('#')[1]\n    if (hash !== null) {\n      hash = `#${hash}`\n    } else {\n      hash = ''\n    }\n\n    props.onHashChanged(hash)\n  }\n\n  const titleChangeEventListener = (): void => {\n    if (iFrameRef.current === null) {\n      console.error('titleChangeEvent from iframe but iFrameRef is null')\n      return\n    }\n\n    const title = iFrameRef.current?.contentDocument?.title\n    if (title == null) {\n      return\n    }\n\n    props.onTitleChanged(title)\n  }\n\n  return (\n    <iframe\n      ref={iFrameRef}\n      key={generateKey()}\n      className={styles['docs-iframe']}\n      src={props.src}\n      title=\"docs\"\n      onLoad={onIframeLoad}\n    />\n  )\n}\n"
  },
  {
    "path": "web/src/components/InfoBanner.tsx",
    "content": "import { Alert, Snackbar } from '@mui/material'\nimport React, { useState } from 'react'\nimport { type Message } from '../data-providers/MessageBannerProvider'\nimport { generateKey } from '../data-providers/RandomId'\n\ninterface Props {\n  message: Message\n}\n\nexport default function Banner(props: Props): React.JSX.Element {\n  const [show, setShow] = useState<boolean>(false)\n  const [prevMessage, setPrevMessage] = useState(props.message);\n\n  if (props.message !== prevMessage) {\n    setPrevMessage(props.message);\n    setShow(true);\n  }\n\n  return (\n    <Snackbar\n      key={generateKey()}\n      open={show && props.message.content != null}\n      autoHideDuration={props.message.showMs}\n      onClose={() => {\n        setShow(false)\n      }}\n    >\n      <Alert\n        onClose={() => {\n          setShow(false)\n        }}\n        severity={props.message.type}\n        sx={{ width: '100%' }}\n      >\n        {props.message.content}\n      </Alert>\n    </Snackbar>\n  )\n}\n"
  },
  {
    "path": "web/src/components/NavigationTitle.tsx",
    "content": "import React from 'react';\nimport { ArrowBackIos } from '@mui/icons-material'\nimport { Link } from 'react-router-dom'\n\nimport styles from './../style/components/NavigationTitle.module.css'\n\ninterface Props {\n  title: string\n  backLink?: string\n  description?: string | React.JSX.Element\n}\n\nexport default function NavigationTitle(props: Props): React.JSX.Element {\n  return (\n    <div className={styles['nav-title']}>\n      <div className={styles['page-header']}>\n        <Link\n          to={props.backLink != null ? props.backLink : '/'}\n          className={styles['back-link']}\n        >\n          <ArrowBackIos />\n        </Link>\n        <h1 className={styles['page-title']}>{props.title}</h1>\n      </div>\n\n      <div className={styles['page-description']}>{props.description}</div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/PageLayout.tsx",
    "content": "import { JSX } from 'react'\nimport styles from './../style/components/PageLayout.module.css'\nimport Footer from './Footer'\nimport Header from './Header'\nimport NavigationTitle from './NavigationTitle'\n\ninterface Props {\n  title: string\n  description?: string | JSX.Element\n  showSearchBar?: boolean\n  children: JSX.Element | JSX.Element[]\n}\n\nexport default function PageLayout(props: Props): JSX.Element {\n  return (\n    <>\n      <Header />\n      <div className={styles.main}>\n        <NavigationTitle title={props.title} description={props.description} />\n        {props.children}\n      </div>\n      <Footer />\n    </>\n  )\n}\n"
  },
  {
    "path": "web/src/components/Project.tsx",
    "content": "import React from 'react';\nimport { Link } from 'react-router-dom'\nimport { type Project as ProjectType } from '../models/ProjectsResponse'\nimport ProjectRepository from '../repositories/ProjectRepository'\nimport styles from './../style/components/Project.module.css'\n\nimport { Box, Tooltip, Typography } from '@mui/material'\nimport FavoriteStar from './FavoriteStar'\n\ninterface Props {\n  project: ProjectType\n  onFavoriteChanged: () => void\n}\n\nfunction timeSince(date: Date) {\n  const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);\n  let interval = seconds / 31536000;\n\n  if (interval > 1) {\n    return Math.floor(interval) + \" years\";\n  }\n  interval = seconds / 2592000;\n  if (interval > 1) {\n    return Math.floor(interval) + \" months\";\n  }\n  interval = seconds / 86400;\n  if (interval > 1) {\n    return Math.floor(interval) + \" days\";\n  }\n  interval = seconds / 3600;\n  if (interval > 1) {\n    return Math.floor(interval) + \" hours\";\n  }\n  interval = seconds / 60;\n  if (interval > 1) {\n    return Math.floor(interval) + \" minutes\";\n  }\n  return Math.floor(seconds) + \" seconds\";\n}\n\nexport default function Project(props: Props): React.JSX.Element {\n  const latestVersion = ProjectRepository.getLatestVersion(props.project.versions)\n\n  return (\n    <div className={styles['project-card']}>\n\n        {props.project.logo ?\n            <>\n              <Link to={`${props.project.name}/latest`}>\n                <img\n                  className={styles['project-logo']}\n                  src={ProjectRepository.getProjectLogoURL(props.project.name)}\n                  alt={`${props.project.name} project logo`}\n                />\n              </Link>\n            </> : <></>\n        }\n\n      <div className={styles['project-header']}>\n        <Link to={`${props.project.name}/latest`}>\n          <div className={styles['project-card-title']}>\n            {props.project.name}{' '}\n            <span className={styles['secondary-typography']}>\n              {latestVersion.name}\n            </span>\n          </div>\n        </Link>\n\n        <Tooltip title={new Date(latestVersion.timestamp).toISOString().slice(0, -8).replace('T', ' ')} placement=\"left\" arrow >\n          <Box sx={{\n              display: {\n                xs: 'none',\n                sm: 'inherit'\n              }\n            }} className={styles['secondary-typography']}>\n            {timeSince(new Date(latestVersion.timestamp))} ago\n          </Box>\n        </Tooltip>\n      </div>\n      <div className={styles['project-header']}>\n        <div className={styles.subhead}>\n          {props.project.versions.length === 1\n            ? `${props.project.versions.length} version`\n            : `${props.project.versions.length} versions`}\n            <Typography component={'span'} sx={{ marginLeft: 1.5, fontSize: '0.9em', fontWeight: 300 }}>{props.project.storage}</Typography>\n        </div>\n\n        <FavoriteStar\n          projectName={props.project.name}\n          onFavoriteChanged={props.onFavoriteChanged}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/ProjectList.tsx",
    "content": "import React from 'react';\nimport Project from './Project'\n\nimport { type Project as ProjectType } from '../models/ProjectsResponse'\nimport styles from './../style/components/ProjectList.module.css'\n\ninterface Props {\n  projects: ProjectType[]\n  onFavoriteChanged: () => void\n}\n\nexport default function ProjectList(props: Props): React.JSX.Element {\n  if (props.projects.length === 0) {\n    return <></>\n  }\n\n  return (\n    <div className={styles['project-list']}>\n      {props.projects.map((project) => (\n        <Project\n          project={project}\n          key={project.name}\n          onFavoriteChanged={() => {\n            props.onFavoriteChanged()\n          }}\n        />\n      ))}\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/components/SearchBar.tsx",
    "content": "import SearchIcon from '@mui/icons-material/Search';\nimport StarIcon from '@mui/icons-material/Star';\nimport StarBorderIcon from '@mui/icons-material/StarBorder';\nimport { Divider, IconButton, InputBase, Paper, Tooltip } from '@mui/material';\nimport React, { useEffect, useState } from 'react';\nimport { useSearchParams } from 'react-router-dom';\nimport { useSearch } from '../data-providers/SearchProvider';\n\n\ninterface Props {\n  showFavourites: boolean\n  onShowFavourites: (all: boolean) => void\n}\n\nexport default function SearchBar(props: Props): React.JSX.Element {\n  const [showFavourites, setShowFavourites] = useState(true);\n  const [searchParams, setSearchParams] = useSearchParams();\n\n  const { query, setQuery } = useSearch()\n  const [searchQuery, setSearchQuery] = useState<string>(query)\n\n\n  const updateSearch = (q: string) => {\n    setSearchQuery(q)\n    setQuery(q)\n\n    if (q) {\n      setSearchParams({q})\n    } else {\n      setSearchParams({})\n    }\n  }\n\n  useEffect(() => {\n    const q = searchParams.get(\"q\")\n    if (q) {\n      updateSearch(q)\n    }\n    setShowFavourites(props.showFavourites)\n  }, [props.showFavourites]);\n\n  const onFavourites = (show: boolean): void => {\n    setSearchParams({})\n    setSearchQuery(\"\")\n    setQuery(\"\")\n\n    setShowFavourites(show)\n    props.onShowFavourites(!show)\n  }\n\n  const onSearch = (e: React.ChangeEvent<HTMLInputElement>): void => {\n    setShowFavourites(false)\n    updateSearch(e.target.value)\n  }\n\n  return (\n    <Paper\n      component=\"form\"\n      sx={{\n        p: '2px 4px',\n        display: 'flex',\n        alignItems: 'center',\n        maxWidth: 600,\n        marginLeft: '16px',\n      }}\n    >\n      <InputBase\n        sx={{ ml: 1, flex: 1 }}\n        placeholder=\"Search Docs\"\n        inputProps={{ 'aria-label': 'search docs' }}\n        value={searchQuery}\n        onChange={onSearch}\n        onKeyDown={(e): void => {\n          if (e.key === 'Enter') {\n            e.preventDefault()\n            setQuery(searchQuery)\n          }\n        }}\n\n      />\n      <IconButton type=\"button\" sx={{ p: '10px' }} aria-label=\"search\">\n        <SearchIcon />\n      </IconButton>\n      <Divider sx={{ height: 28, m: 0.5 }} orientation=\"vertical\" />\n      <Tooltip title={`Show ${showFavourites ? 'all docs' : 'favourites only'}`} placement=\"right\" arrow>\n        <IconButton onClick={() => onFavourites(!showFavourites)} sx={{ p: '10px' }} aria-label=\"directions\">\n          { showFavourites  ?  <StarIcon /> : <StarBorderIcon /> }\n        </IconButton>\n      </Tooltip>\n    </Paper>\n  )\n}\n"
  },
  {
    "path": "web/src/components/StyledForm.tsx",
    "content": "import React from 'react';\nimport styles from './../style/components/StyledForm.module.css'\n\ninterface Props {\n  children: React.JSX.Element[]\n}\n\nexport default function StyledForm(props: Props): React.JSX.Element {\n  if (props.children.length === 0) {\n    return <></>\n  }\n\n  return <div className={styles.form}>{props.children}</div>\n}\n"
  },
  {
    "path": "web/src/data-providers/ConfigDataProvider.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children\n*/\n\nimport { createContext, use, useEffect, useState, JSX } from 'react'\n\nexport interface Config {\n  headerHTML?: string\n  footerHTML?: string\n}\n\nconst Context = createContext<Config>({})\n\n/**\n * Provides the config from the backend for the whole application,\n * so it can be used in every component without it being reloaded the whole time.\n */\nexport const ConfigDataProvider = ({ children }: any): JSX.Element => {\n  const [config, setConfig] = useState<Config>({})\n\n  useEffect(() => {\n    void (async () => {\n      try {\n        const res = await fetch('/doc/config.json')\n        const data = (await res.json()) as Config\n        setConfig(data)\n      } catch (err) {\n        console.error(err)\n      }\n    })()\n  }, [])\n\n  return <Context value={config}>{children}</Context>\n}\n\nexport const useConfig = (): Config => use(Context)\n"
  },
  {
    "path": "web/src/data-providers/MessageBannerProvider.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children\n*/\n\nimport React, { useState, useCallback, use, JSX } from 'react'\nimport Banner from '../components/InfoBanner'\n\nexport interface Message {\n  content: string | JSX.Element | undefined\n  type: 'success' | 'info' | 'warning' | 'error'\n  showMs: number | null // null = infinite\n}\n\ninterface MessageBannerState {\n  showMessage: (message: Message) => void\n  clearMessages: () => void\n}\n\nexport const Context = React.createContext<MessageBannerState>({\n  showMessage: (): void => {\n    console.warn('MessageBannerProvider not initialized')\n  },\n  clearMessages: (): void => {\n    console.warn('MessageBannerProvider not initialized')\n  }\n})\n\nexport function MessageBannerProvider({ children }: any): JSX.Element {\n  // We need to store the last timeout, so we can clear when a new message is shown\n  const [lastTimeout, setLastTimeout] = useState<ReturnType<typeof setTimeout>>()\n  const [message, setMessage] = useState<Message>({\n    content: undefined,\n    type: 'success',\n    showMs: 6000\n  })\n\n  const showMessage = useCallback((message: Message) => {\n    if (lastTimeout !== undefined) {\n      clearTimeout(lastTimeout)\n    }\n\n    setMessage(message)\n\n    if (message.showMs === null) {\n      // don't hide message\n      return\n    }\n\n    // Hide message after 6 seconds\n    const newTimeout = setTimeout(() => {\n      setMessage({\n        content: undefined,\n        type: 'success',\n        showMs: 6000\n      })\n    }, message.showMs)\n\n    setLastTimeout(newTimeout)\n  }, [])\n\n  const clearMessages = useCallback(() => {\n    if (lastTimeout !== undefined) {\n      clearTimeout(lastTimeout)\n    }\n\n    setMessage({\n      content: undefined,\n      type: 'success',\n      showMs: 6000\n    })\n  }, [])\n\n  return (\n    <Context value={{ showMessage, clearMessages }}>\n      <Banner message={message} />\n      {children}\n    </Context>\n  )\n}\n\nexport const useMessageBanner = (): MessageBannerState => use(Context)\n"
  },
  {
    "path": "web/src/data-providers/ProjectDataProvider.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children\n*/\n\nimport React, { createContext, use, useEffect, useState, JSX } from 'react'\nimport { type Project } from '../models/ProjectsResponse'\nimport type ProjectsResponse from '../models/ProjectsResponse'\nimport { useMessageBanner } from './MessageBannerProvider'\n\ninterface ProjectState {\n  projects: Project[] | null\n  loadingFailed: boolean\n  reload: () => void\n}\n\nconst Context = createContext<ProjectState>({\n  projects: null,\n  loadingFailed: false,\n  reload: (): void => {\n    console.warn('ProjectDataProvider not initialized')\n  }\n})\n\n/**\n * Provides the projects for the whole application,\n * so that it can be used in every component without it being reloaded\n * the whole time or having to be passed down.\n *\n * If reloading is required, call the reload function.\n */\nexport function ProjectDataProvider({ children }: any): JSX.Element {\n  const { showMessage } = useMessageBanner()\n\n  const loadData = (): void => {\n    void (async (): Promise<void> => {\n      try {\n        const response = await fetch('/api/projects?include_hidden=true')\n\n        if (!response.ok) {\n          throw new Error(\n            `Failed to load projects, status code: ${response.status}`\n          )\n        }\n\n        const data: ProjectsResponse = await response.json()\n        setState({\n          projects: data.projects,\n          loadingFailed: false,\n          reload: loadData\n        })\n      } catch (e) {\n        console.error(e)\n\n        showMessage({\n          content: 'Failed to load projects',\n          type: 'error',\n          showMs: 6000\n        })\n\n        setState({\n          projects: null,\n          loadingFailed: true,\n          reload: loadData\n        })\n      }\n    })()\n  }\n\n  const [state, setState] = useState<ProjectState>({\n    projects: null,\n    loadingFailed: false,\n    reload: loadData\n  })\n\n  useEffect(() => {\n    loadData()\n  }, [])\n\n  return <Context value={state}>{children}</Context>\n}\n\nexport const useProjects = (): ProjectState => use(Context)\n"
  },
  {
    "path": "web/src/data-providers/RandomId.tsx",
    "content": "// Generate a mostly random key\nexport const generateKey = () => {\n  // Use the native function not available in http mode\n  if (window.crypto && crypto.randomUUID) {\n    return crypto.randomUUID();\n  }\n\n  // Fallback\n  return Date.now().toString(36) + Math.random().toString(36).substring(2);\n}\n"
  },
  {
    "path": "web/src/data-providers/SearchProvider.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children\n*/\n\nimport React, { createContext, use, useEffect, useState, JSX } from 'react'\nimport { type Project } from '../models/ProjectsResponse'\nimport { useProjects } from './ProjectDataProvider'\nimport Fuse from 'fuse.js'\n\ninterface SearchState {\n  filteredProjects: Project[] | null\n  query: string\n  setQuery: (query: string) => void\n}\n\nconst Context = createContext<SearchState>({\n  filteredProjects: null,\n  query: '',\n  setQuery: (): void => {\n    console.warn('SearchDataProvider not initialized')\n  }\n})\n\nexport function SearchProvider({ children }: any): JSX.Element {\n  const { projects } = useProjects()\n\n  const filterProjects = (query: string): Project[] | null => {\n    if (projects == null) {\n      return null\n    }\n\n    if (query.trim() === '') {\n      return projects\n    }\n\n    const fuse = new Fuse(projects, {\n      keys: ['name'],\n      includeScore: true\n    })\n\n    // sort by match score\n    return fuse\n      .search(query)\n      .sort((x, y) => (x.score ?? 0) - (y.score ?? 0))\n      .map((result) => result.item)\n  }\n\n  const setQuery = (query: string): void => {\n    setState({\n      query,\n      filteredProjects: filterProjects(query),\n      setQuery\n    })\n  }\n\n  const [state, setState] = useState<SearchState>({\n    filteredProjects: null,\n    query: '',\n    setQuery\n  })\n\n  useEffect(() => {\n    setState({\n      query: '',\n      filteredProjects: filterProjects(''),\n      setQuery\n    })\n  }, [projects])\n\n  return <Context value={state}>{children}</Context>\n}\n\nexport const useSearch = (): SearchState => use(Context)\n"
  },
  {
    "path": "web/src/data-providers/StatsDataProvider.tsx",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n/*\n  We need any, because we don't know the type of the children\n*/\n\nimport { createContext, use, useEffect, useState, JSX } from 'react'\nimport { useMessageBanner } from './MessageBannerProvider'\n\n\ntype Stats = {\n  n_projects: number\n  n_versions: number\n  storage: string\n}\n\ninterface StatsState {\n  stats: Stats | null\n  loadingFailed: boolean\n  reload: () => void\n}\n\nconst Context = createContext<StatsState>({\n  stats: null,\n  loadingFailed: false,\n  reload: (): void => {\n    console.warn('StatsProvider not initialized')\n  }\n})\n\n/**\n * Provides the stats of the docat instance\n * If reloading is required, call the reload function.\n */\nexport function StatsDataProvider({ children }: any): JSX.Element {\n  const { showMessage } = useMessageBanner()\n\n  const loadData = (): void => {\n    void (async (): Promise<void> => {\n      try {\n        const response = await fetch('/api/stats')\n\n        if (!response.ok) {\n          throw new Error(\n            `Failed to load stats, status code: ${response.status}`\n          )\n        }\n\n        const data: Stats = await response.json()\n        setState({\n          stats: data,\n          loadingFailed: false,\n          reload: loadData\n        })\n      } catch (e) {\n        console.error(e)\n\n        showMessage({\n          content: 'Failed to load stats',\n          type: 'error',\n          showMs: 6000\n        })\n\n        setState({\n          stats: null,\n          loadingFailed: true,\n          reload: loadData\n        })\n      }\n    })()\n  }\n\n  const [state, setState] = useState<StatsState>({\n    stats: null,\n    loadingFailed: false,\n    reload: loadData\n  })\n\n  useEffect(() => {\n    loadData()\n  }, [])\n\n  return <Context value={state}>{children}</Context>\n}\n\nexport const useStats = (): StatsState => use(Context)\n"
  },
  {
    "path": "web/src/index.css",
    "content": "* {\n  margin: 0;\n  padding: 0;\n  font-family: \"Roboto\", Helvetica, Arial, sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n\n  --primary-foreground: #383838;\n  --secondary-foreground: #e8e8e8;\n  --button-primary: #2c3e50;\n  --icons: #505050;\n}\n\nh1 {\n  font-size: 30px;\n  font-weight: 300;\n  color: var(--primary-foreground);\n}\n\na,\nu {\n  text-decoration: none;\n  color: black;\n}\n\ncode {\n  font-family: \"Consolas\", \"Liberation Mono\", Menlo, Courier, monospace;\n}\n\n.loading-spinner {\n  --spinner-size: 40px;\n\n  display: inline-block;\n  width: var(--spinner-size);\n  height: var(--spinner-size);\n  margin-bottom: 5vh;\n\n  position: relative;\n  left: calc(50% - var(--spinner-size) / 2 - 8px);\n}\n\n.loading-spinner:after {\n  content: \" \";\n  display: block;\n  width: var(--spinner-size);\n  height: var(--spinner-size);\n  margin: 8px;\n  border-radius: 50%;\n  border: 6px solid var(--button-primary);\n  border-color: var(--button-primary) transparent var(--button-primary)\n    transparent;\n  animation: loading-spinner 1.2s linear infinite;\n}\n\n@keyframes loading-spinner {\n  0% {\n    transform: rotate(0deg);\n  }\n  100% {\n    transform: rotate(360deg);\n  }\n}\n"
  },
  {
    "path": "web/src/index.tsx",
    "content": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport './index.css'\nimport App from './App'\n\nconst root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement)\nroot.render(\n  <React.StrictMode>\n    <App />\n  </React.StrictMode>\n)\n"
  },
  {
    "path": "web/src/models/ProjectDetails.ts",
    "content": "export default class ProjectDetails {\n  name: string\n  hidden: boolean\n  timestamp: Date\n  tags: string[]\n\n  constructor(name: string, tags: string[], hidden: boolean, timestamp: Date) {\n    this.name = name\n    this.tags = tags\n    this.hidden = hidden\n    this.timestamp = timestamp\n  }\n}\n"
  },
  {
    "path": "web/src/models/ProjectsResponse.ts",
    "content": "import type ProjectDetails from './ProjectDetails'\n\nexport interface Project {\n  name: string\n  logo: boolean\n  storage: string\n  versions: ProjectDetails[]\n}\n\nexport default interface ProjectsResponse {\n  projects: Project[]\n}\n"
  },
  {
    "path": "web/src/pages/Claim.tsx",
    "content": "import { TextField } from '@mui/material'\nimport React, { useEffect, useState, JSX } from 'react'\nimport DataSelect from '../components/DataSelect'\nimport PageLayout from '../components/PageLayout'\nimport StyledForm from '../components/StyledForm'\nimport { useMessageBanner } from '../data-providers/MessageBannerProvider'\nimport { useProjects } from '../data-providers/ProjectDataProvider'\nimport ProjectRepository from '../repositories/ProjectRepository'\n\nexport default function Claim(): JSX.Element {\n  const { projects, loadingFailed } = useProjects()\n\n  const { showMessage } = useMessageBanner()\n  const [project, setProject] = useState<string>('none')\n  const [token, setToken] = useState<string>('')\n\n  const [projectMissing, setProjectMissing] = useState<boolean | null>(null)\n\n  useEffect(() => {\n     document.title = 'Claim Token | docat'\n  }, []);\n\n  const claim = async (): Promise<void> => {\n    if (project == null || project === '' || project === 'none') {\n      setProjectMissing(true)\n      return\n    }\n\n    try {\n      const response = await ProjectRepository.claim(project)\n      setToken(response.token)\n    } catch (e) {\n      console.error(e)\n      showMessage({\n        content: (e as { message: string }).message,\n        type: 'error',\n        showMs: 6000\n      })\n    }\n  }\n\n  /**\n   * Returns loaded project names for DataSelect\n   * @returns project names as string[] or an empty array\n   */\n  const getProjects = (): string[] => {\n    if (loadingFailed || projects == null) {\n      return []\n    }\n\n    return projects.map((project) => project.name)\n  }\n\n  const onProjectSelect = (p: string): void => {\n    if (p == null || p === '' || p === 'none') {\n      setProjectMissing(true)\n    } else {\n      setProjectMissing(false)\n    }\n\n    setProject(p)\n    setToken('')\n  }\n\n  return (\n    <PageLayout\n      title=\"Claim Token\"\n      description=\"Please make sure to store this token safely, as only one token can be generated per project and you will not be able to claim it again.\"\n    >\n      <StyledForm>\n        <DataSelect\n          emptyMessage=\"Please select a Project\"\n          label=\"Project\"\n          values={getProjects()}\n          onChange={onProjectSelect}\n          value={project ?? 'none'}\n          errorMsg={\n            projectMissing === true ? 'Please select a Project' : undefined\n          }\n        />\n\n        {token !== '' ? (\n          <TextField\n            fullWidth\n            label=\"Token\"\n            slotProps={{\n              input: {\n                readOnly: true,\n              },\n            }}\n            value={token}\n          >\n            {token}\n          </TextField>\n        ) : (\n          <></>\n        )}\n\n        <button\n          type=\"submit\"\n          disabled={token !== ''}\n          onClick={async () => {\n            try {\n              await claim();\n            } catch (e) {\n              console.error(e);\n            }\n          }}\n        >\n          Claim\n        </button>\n      </StyledForm>\n    </PageLayout>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Delete.tsx",
    "content": "import { TextField } from '@mui/material'\nimport React, { useEffect, useState, JSX } from 'react'\nimport DataSelect from '../components/DataSelect'\nimport ProjectRepository from '../repositories/ProjectRepository'\nimport StyledForm from '../components/StyledForm'\nimport PageLayout from '../components/PageLayout'\nimport { useProjects } from '../data-providers/ProjectDataProvider'\nimport type ProjectDetails from '../models/ProjectDetails'\nimport { useMessageBanner } from '../data-providers/MessageBannerProvider'\n\ninterface Validation {\n  projectMissing?: boolean\n  versionMissing?: boolean\n  tokenMissing?: boolean\n}\n\nexport default function Delete(): JSX.Element {\n  const { showMessage } = useMessageBanner()\n  const [project, setProject] = useState<string>('none')\n  const [version, setVersion] = useState<string>('none')\n  const [token, setToken] = useState<string>('')\n  const { projects, loadingFailed, reload } = useProjects()\n  const [versions, setVersions] = useState<ProjectDetails[]>([])\n  const [validation, setValidation] = useState<Validation>({})\n\n  document.title = 'Delete Documentation | docat'\n\n  useEffect(() => {\n    if (project === '' || project === 'none') {\n      setVersions([])\n      return\n    }\n\n    setVersions(projects?.find((p) => p.name === project)?.versions ?? [])\n  }, [project])\n\n  const validate = (\n    field: 'project' | 'version' | 'token',\n    value: string\n  ): boolean => {\n    const valid = value !== 'none' && value !== ''\n    setValidation({ ...validation, [`${field}Missing`]: !valid })\n    return valid\n  }\n\n  const deleteDocumentation = (): void => {\n    void (async () => {\n      if (!validate('project', project)) return\n      if (!validate('version', version)) return\n      if (!validate('token', token)) return\n\n      try {\n        await ProjectRepository.deleteDoc(project, version, token)\n\n        showMessage({\n          type: 'success',\n          content: `Documentation for ${project} (${version}) deleted successfully.`,\n          showMs: 6000\n        })\n        setProject('none')\n        setVersion('none')\n        setToken('')\n        reload()\n      } catch (e) {\n        console.error(e)\n\n        showMessage({\n          type: 'error',\n          content: (e as { message: string }).message,\n          showMs: 6000\n        })\n      }\n    })()\n  }\n\n  /**\n   * Returns loaded project names for DataSelect\n   * @returns string[] or an empty array\n   */\n  const getProjects = (): string[] => {\n    if (loadingFailed || projects == null) {\n      return []\n    }\n\n    return projects.map((project) => project.name)\n  }\n\n  /**\n   * Returns loaded Versions for DataSelect\n   * @returns string[] or an empty array\n   */\n  const getVersions = (): string[] => {\n    if (project === '' || project === 'none') {\n      return []\n    }\n\n    return versions.map((v) => v.name)\n  }\n\n  return (\n    <PageLayout title=\"Delete Documentation\">\n      <StyledForm>\n        <DataSelect\n          emptyMessage=\"Please select a Project\"\n          label=\"Project\"\n          values={getProjects()}\n          onChange={(project) => {\n            setProject(project)\n            setVersion('none')\n            validate('project', project)\n          }}\n          value={project ?? 'none'}\n          errorMsg={\n            validation.projectMissing === true\n              ? 'Please select a Project'\n              : undefined\n          }\n        />\n        <DataSelect\n          emptyMessage=\"Please select a Version\"\n          label=\"Version\"\n          values={getVersions()}\n          onChange={(version) => {\n            setVersion(version)\n            validate('version', version)\n          }}\n          value={version ?? 'none'}\n          errorMsg={\n            validation.versionMissing === true\n              ? 'Please select a Version'\n              : undefined\n          }\n        />\n\n        <TextField\n          fullWidth\n          label=\"Token\"\n          value={token}\n          onChange={(e) => {\n            setToken(e.target.value)\n            validate('token', e.target.value)\n          }}\n          error={validation.tokenMissing}\n          helperText={\n            validation.tokenMissing === true\n              ? 'Please enter a Token'\n              : undefined\n          }\n        >\n          {token}\n        </TextField>\n\n        <button type=\"submit\" onClick={deleteDocumentation}>\n          Delete\n        </button>\n      </StyledForm>\n    </PageLayout>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Docs.tsx",
    "content": "import { useEffect, useMemo, useState, useRef, JSX } from 'react'\nimport ProjectRepository from '../repositories/ProjectRepository'\nimport type ProjectDetails from '../models/ProjectDetails'\nimport LoadingPage from './LoadingPage'\nimport NotFound from './NotFound'\nimport DocumentControlButtons from '../components/DocumentControlButtons'\nimport IFrame from '../components/IFrame'\nimport { useLocation, useParams, useSearchParams } from 'react-router-dom'\nimport { useMessageBanner } from '../data-providers/MessageBannerProvider'\n\nexport default function Docs(): JSX.Element {\n  const params = useParams()\n  const [searchParams] = useSearchParams()\n  const location = useLocation()\n  const { showMessage, clearMessages } = useMessageBanner()\n\n  const [versions, setVersions] = useState<ProjectDetails[]>([])\n  const [displayVersion, setDisplayVersion] = useState<ProjectDetails | null>(null)\n  const [projectLoading, setProjectLoading] = useState<boolean>(true)\n  const [notFound, setNotFound] = useState<boolean>(false)\n\n  const pageRef = useRef(params['*'] ?? '')\n  const hashRef = useRef(location.hash)\n\n  const [project, setProject] = useState<string>(params.project ?? '')\n  const [version, setVersion] = useState<string>(params.version ?? 'latest')\n  const [hideUi, setHideUi] = useState<boolean>(searchParams.get('hide-ui') === '' || searchParams.get('hide-ui') === 'true')\n  const [iframeUpdateTrigger, setIframeUpdateTrigger] = useState<number>(0)\n\n  // This provides the url for the iframe.\n  // It is always the same, except when the version changes,\n  // as this memo will trigger a re-render of the iframe, which\n  // is not needed when only the page or hash changes, because\n  // the iframe keeps track of that itself.\n  const iFrameSrc = useMemo(() => {\n    if (!displayVersion) {\n      return ''\n    }\n    return ProjectRepository.getProjectDocsURL(\n      project,\n      displayVersion.name,\n      pageRef.current,\n      hashRef.current\n    )\n  }, [project, displayVersion, iframeUpdateTrigger])\n\n  useEffect(() => {\n    setProjectLoading(true)\n    const loadProject = async () => {\n      try {\n        let allVersions = await ProjectRepository.getVersions(project)\n        allVersions = allVersions.sort((a, b) =>\n          ProjectRepository.compareVersions(a, b)\n        )\n        setVersions(allVersions)\n      } catch (e) {\n        console.error(e)\n      }\n    }\n    loadProject().finally(() => {\n      setProjectLoading(false)\n    })\n  }, [project]);\n\n  const buildBrowserUrl = (project: string, version: string, page: string, hash: string, hideUi: boolean): string => {\n    return `/${project}/${version}/${page}${hideUi ? '?hide-ui' : ''}${hash}`\n  }\n\n  const getShareUrl = (options: { useLatest: boolean, hideUi: boolean }): string => {\n    return buildBrowserUrl(project, options.useLatest ? 'latest' : displayVersion?.name ?? 'latest', pageRef.current, hashRef.current, options.hideUi)\n  }\n\n  const updateUrl = (newProject: string, newVersion: string, hideUi: boolean): void => {\n    window.history.pushState(null, '', buildBrowserUrl(newProject, newVersion, pageRef.current, hashRef.current, hideUi))\n  }\n\n  useEffect(() => {\n    if (versions.length === 0) {\n      return\n    }\n\n    if (version === 'latest') {\n      const latestVersion = ProjectRepository.getLatestVersion(versions)\n      setDisplayVersion(latestVersion)\n    } else {\n      const matchingVersion = versions.find((v) => v.name === version || v.tags.includes(version))\n      if (matchingVersion) {\n        setDisplayVersion(matchingVersion)\n      } else {\n        setNotFound(true)\n        console.error(`Version '${version}' doesn't exist`)\n      }\n    }\n  }, [versions, version])\n\n  useEffect(() => {\n    const latestVersion = ProjectRepository.getLatestVersion(versions)\n    if (displayVersion === latestVersion) {\n      clearMessages()\n    } else {\n      showMessage({\n        content: 'You are viewing an outdated version of the documentation.',\n        type: 'warning',\n        showMs: null\n      })\n    }\n  }, [displayVersion, versions, showMessage, clearMessages])\n\n  const updateTitle = (newTitle: string): void => {\n    document.title = newTitle\n  }\n\n  const iFramePageChanged = (urlPage: string, urlHash: string, title?: string): void => {\n    if (title != null && title !== document.title) {\n      updateTitle(title)\n    }\n    if (urlPage === pageRef.current) {\n      return\n    }\n    pageRef.current = urlPage\n    hashRef.current = urlHash\n    updateUrl(project, version, hideUi)\n  }\n\n  const iFrameHashChanged = (newHash: string): void => {\n    if (newHash === hashRef.current) {\n      return\n    }\n    hashRef.current = newHash\n    updateUrl(project, version, hideUi)\n  }\n\n  const iFrameNotFound = (): void => {\n    setNotFound(true)\n  }\n\n  const iFrameFaviconChanged = (faviconUrl: string | null): void => {\n    const favicon = document.querySelector('link[rel=\"icon\"]') as HTMLLinkElement | null\n    if (favicon == null || faviconUrl == null) {\n      return\n    }\n    favicon.href = faviconUrl\n  }\n\n  useEffect(() => {\n    const urlProject = params.project ?? ''\n    const urlVersion = params.version ?? 'latest'\n    const urlPage = params['*'] ?? ''\n    const urlHash = location.hash\n    const urlHideUi = searchParams.get('hide-ui') === '' || searchParams.get('hide-ui') === 'true'\n\n    // update the state to the url params on first load\n    setNotFound(false)\n    setProject(urlProject)\n    setVersion(urlVersion)\n    setHideUi(urlHideUi)\n\n    if (urlPage !== pageRef.current) {\n      pageRef.current = urlPage\n      setIframeUpdateTrigger((v) => v + 1)\n    }\n    if (urlHash !== hashRef.current) {\n      hashRef.current = urlHash\n      setIframeUpdateTrigger((v) => v + 1)\n    }\n  }, [location])\n\n  if (projectLoading) {\n    return <LoadingPage />\n  }\n\n  if (displayVersion == null || notFound) {\n    return <NotFound />\n  }\n\n  return (\n    <>\n      <IFrame\n        src={iFrameSrc}\n        onPageChanged={iFramePageChanged}\n        onHashChanged={iFrameHashChanged}\n        onTitleChanged={updateTitle}\n        onNotFound={iFrameNotFound}\n        onFaviconChanged={iFrameFaviconChanged}\n      />\n      {!hideUi && (\n        <DocumentControlButtons\n          version={displayVersion.name}\n          versions={versions}\n          onVersionChange={(newVersion) => {\n            updateUrl(project, newVersion, hideUi)\n            setVersion(newVersion)\n          }}\n          getShareUrl={getShareUrl}\n        />\n      )}\n    </>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Help.tsx",
    "content": "import { useEffect, useState, JSX } from 'react'\nimport ReactMarkdown from 'react-markdown'\n\n// @ts-expect-error ts can't read symbols from a md file\nimport gettingStarted from './../assets/getting-started.md'\n\nimport Footer from '../components/Footer'\nimport Header from '../components/Header'\nimport LoadingPage from './LoadingPage'\n\nimport styles from './../style/pages/Help.module.css'\n\nexport default function Help(): JSX.Element {\n  useEffect(() => {\n     document.title = 'Help | docat';\n  }, []);\n\n  const [content, setContent] = useState<string>('')\n  const [loading, setLoading] = useState<boolean>(true)\n\n  /**\n   * Replaces the links to \"http://localhost:3000\" with the current url of the page\n   * @param text the contents of the markdown file\n   * @returns the contents of the markdown file with the links replaced\n   */\n  const replaceLinks = (text: string): string => {\n    const protocol = document.location.protocol\n    const host = document.location.hostname\n    const port =\n      document.location.port !== '' ? `:${document.location.port}` : ''\n\n    const currentUrl = `${protocol}//${host}${port}`\n\n    return text.replaceAll('http://localhost:8000', currentUrl)\n  }\n\n  // Load the markdown file\n  useEffect(() => {\n    void (async (): Promise<void> => {\n      try {\n        // the import \"gettingStarted\" is just a path to the md file,\n        // so we need to fetch the contents of the file manually\n\n        const response = await fetch(gettingStarted as RequestInfo)\n        const text = await response.text()\n        const content = replaceLinks(text)\n        setContent(content)\n      } catch (e) {\n        console.error(e)\n      } finally {\n        setLoading(false)\n      }\n    })()\n  }, [])\n\n  if (loading) {\n    return <LoadingPage />\n  }\n\n  return (\n    <>\n      <Header />\n      <div className={styles['markdown-container']}>\n        <ReactMarkdown>\n          {content}\n        </ReactMarkdown>\n      </div>\n      <Footer />\n    </>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Home.tsx",
    "content": "import { useEffect, useState, JSX } from 'react';\nimport { useNavigate } from 'react-router';\n\nimport { Delete, ErrorOutlined, FileUpload, KeyboardArrowDown, Lock } from '@mui/icons-material';\nimport { useProjects } from '../data-providers/ProjectDataProvider';\nimport { useSearch } from '../data-providers/SearchProvider';\nimport { type Project } from '../models/ProjectsResponse';\n\nimport Footer from '../components/Footer';\nimport Header from '../components/Header';\nimport ProjectList from '../components/ProjectList';\nimport ProjectRepository from '../repositories/ProjectRepository';\nimport LoadingPage from './LoadingPage';\n\nimport { Box, Button, IconButton, Tooltip, Typography } from '@mui/material';\nimport { Link } from 'react-router-dom';\nimport SearchBar from '../components/SearchBar';\nimport { useStats } from '../data-providers/StatsDataProvider';\nimport styles from './../style/pages/Home.module.css';\n\n\nexport default function Home(): JSX.Element {\n  const navigate = useNavigate()\n  const { loadingFailed } = useProjects()\n  const { stats, loadingFailed: statsLoadingFailed } = useStats()\n  const { filteredProjects: projects, query } = useSearch()\n  const [showAll, setShowAll] = useState(false);\n  const [favoriteProjects, setFavoriteProjects] = useState<Project[]>([])\n\n  document.title = 'Home | docat'\n\n  // Keep compatibility with hash-based URI\n  if (location.hash.startsWith('#/')) {\n    navigate(location.hash.replace('#', ''), { replace: true })\n  }\n\n  const updateFavorites = (): void => {\n    if (projects == null) return\n\n    setFavoriteProjects(\n      projects.filter((project) => ProjectRepository.isFavorite(project.name))\n    )\n  }\n\n  const onShowFavourites = (all: boolean): void => {\n    setShowAll(all);\n  }\n\n  useEffect(() => {\n    updateFavorites()\n  }, [projects])\n\n  if (loadingFailed || statsLoadingFailed) {\n    return (\n      <div className={styles.home}>\n        <Header />\n        <div className={styles['loading-error']}>\n          <ErrorOutlined color=\"error\" />\n          <div>Failed to load projects</div>\n        </div>\n        <Footer />\n      </div>\n    )\n  }\n\n  if (projects == null || stats == null) {\n    return <LoadingPage />\n  }\n\n  return (\n    <div className={styles.home}>\n      <Header />\n\n      <div className={styles['project-overview']}>\n        <Box sx={{ width: { sm: '80%' }, maxWidth: '800px'}}>\n\n\n        <Box sx={{\n          display: 'flex',\n          marginTop: '24px',\n          marginBottom: '32px',\n          flexWrap: {\n            sm: 'nowrap',\n            xs: 'wrap'\n          }\n        }}>\n\n          <Box sx={{\n            width: {\n              sm: '100%'\n            },\n            maxWidth: '600px',\n            marginBottom: '8px'\n          }}>\n            <SearchBar showFavourites={!showAll} onShowFavourites={onShowFavourites} />\n          </Box>\n\n          <Box sx={{ display: 'flex' }}>\n            <Tooltip title=\"Upload Documentation\" placement=\"right\" arrow>\n              <IconButton\n                sx={{ marginLeft: 2, height: '46px', width: '46px', marginTop: '2px'}}\n                href=\"/upload\"\n              >\n                <FileUpload></FileUpload>\n              </IconButton>\n            </Tooltip>\n\n            <Tooltip title=\"Claim a Project\" placement=\"right\" arrow>\n              <IconButton\n                sx={{ marginLeft: 2, height: '46px', width: '46px', marginTop: '2px'}}\n                href=\"/claim\"\n              >\n                <Lock></Lock>\n              </IconButton>\n            </Tooltip>\n\n            <Tooltip title=\"Delete a project version\" placement=\"right\" arrow>\n              <IconButton\n                sx={{ marginLeft: 2, height: '46px', width: '46px', marginTop: '2px'}}\n                href=\"/delete\"\n              >\n                <Delete></Delete>\n              </IconButton>\n            </Tooltip>\n          </Box>\n        </Box>\n\n        { projects.length === 0 ?\n          <>{ query !== \"\" ?\n            <Box sx={{marginLeft: '24px', color: '#6e6e6e'}}>\n              Couldn&apos;t find any docs\n            </Box> :\n            <Box sx={{marginLeft: '24px'}}>\n              Looks like you don&apos;t have any docs yet.\n              <Button href=\"/help\" onClick={() => onShowFavourites(true)}>\n                Get started now!\n              </Button>\n            </Box>\n          }</> :\n          <>\n          { (query || showAll) ?\n            <ProjectList\n              projects={projects}\n              onFavoriteChanged={() => {\n                updateFavorites()\n              }}\n            />\n            :\n            <>\n              <Typography sx={{ marginLeft: '24px', marginBottom: 1.5, fontSize: 20, fontWeight: 300 }}>FAVOURITES</Typography>\n              { (favoriteProjects.length === 0) ?\n                <Box sx={{marginLeft: '24px'}}>\n                  No docs favourited at the moment, search for docs or\n                  <Button onClick={() => onShowFavourites(true)}>\n                    Show all docs.\n                  </Button>\n\n                </Box> :\n                <>\n                  <ProjectList\n                    projects={favoriteProjects}\n                    onFavoriteChanged={() => {\n                      updateFavorites()\n                    }}\n                  />\n\n                  <Box sx={{ marginTop: 3, marginLeft: '24px', opacity: 0.6, '&:hover': {\n                    opacity: 0.8,\n                  }, }}>\n                    <Link to={''} onClick={() => onShowFavourites(true)} >\n                      <Typography component={'span'} sx={{ fontSize: '1.1em', fontWeight: 300 }}>SHOW ALL DOCS </Typography>\n                      <KeyboardArrowDown sx={{ marginBottom: -0.6, marginLeft: 1 }} />\n                    </Link>\n                  </Box>\n                </>\n              }\n            </>\n          }\n          </>\n        }\n        </Box>\n        <Box sx={{\n          display: {\n            md: 'block',\n            sm: 'none',\n            xs: 'none'\n          },\n          borderLeft:\n          '1px solid #efefef',\n          paddingLeft: 3,\n          marginTop: 15,\n           width: '400px'\n        }}>\n          <Typography component={'span'} sx={{display: 'inline-block', fontSize: '1.1em', fontWeight: 300}}>INSTANCE STATS</Typography>\n          <Box />\n\n          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200}}># </Typography>\n          <Typography component={'span'} sx={{width: 100, display: 'inline-block', marginTop: 1, fontSize: '1em', fontWeight: 300}}>DOCS </Typography>\n          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200}} >{stats.n_projects}</Typography>\n\n          <Box />\n          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200 }}># </Typography>\n          <Typography component={'span'} sx={{width: 100, display: 'inline-block',  marginTop: 0.4, fontSize: '1em', fontWeight: 300}}>VERSIONS </Typography>\n          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200 }} >{stats.n_versions}</Typography>\n\n          <Box />\n          <Typography component={'span'} sx={{width: 115, display: 'inline-block',  marginTop: 0.4, fontSize: '1em', fontWeight: 300}}>STORAGE </Typography>\n          <Typography component={'span'} sx={{opacity: 0.8, fontSize: '1em', fontWeight: 200 }} >{stats.storage}</Typography>\n        </Box>\n      </div>\n      <Footer />\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/LoadingPage.tsx",
    "content": "import React from 'react'\nimport Footer from '../components/Footer'\nimport Header from '../components/Header'\n\nexport default function LoadingPage(): React.JSX.Element {\n  return (\n    <>\n      <Header />\n      <div className=\"loading-spinner\"></div>\n      <Footer />\n    </>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/NotFound.tsx",
    "content": "import React from 'react'\nimport { Link } from 'react-router-dom'\nimport Footer from '../components/Footer'\nimport Header from '../components/Header'\nimport styles from './../style/pages/NotFound.module.css'\n\nexport default function NotFound(): React.JSX.Element {\n  return (\n    <div className={styles['not-found']}>\n      <Header />\n      <div className={styles['not-found-container']}>\n        <h1 className={styles['not-found-title']}>404 - Not Found</h1>\n        <p className={styles['not-found-text']}>\n          Sorry, the page you were looking for was not found.\n        </p>\n        <Link to=\"/\" className={styles['not-found-link']}>\n          Home\n        </Link>\n      </div>\n      <Footer />\n    </div>\n  )\n}\n"
  },
  {
    "path": "web/src/pages/Upload.tsx",
    "content": "import { TextField } from '@mui/material'\nimport React, { useState, useEffect, JSX } from 'react'\n\nimport FileInput from '../components/FileInput'\nimport PageLayout from '../components/PageLayout'\nimport StyledForm from '../components/StyledForm'\nimport { useMessageBanner } from '../data-providers/MessageBannerProvider'\nimport { useProjects } from '../data-providers/ProjectDataProvider'\nimport ProjectRepository from '../repositories/ProjectRepository'\nimport LoadingPage from './LoadingPage'\n\nimport styles from '../style/pages/Upload.module.css'\n\ninterface Validation {\n  projectMsg?: string\n  versionMsg?: string\n  fileMsg?: string\n}\n\nconst okFileTypes = [\n  'application/zip',\n  'zip',\n  'application/octet-stream',\n  'application/x-zip',\n  'application/x-zip-compressed'\n]\n\nexport default function Upload(): JSX.Element {\n  useEffect(() => {\n     document.title = 'Upload | docat';\n  }, []);\n\n  const { reload: reloadProjects } = useProjects()\n  const { showMessage } = useMessageBanner()\n\n  const [project, setProject] = useState<string>('')\n  const [version, setVersion] = useState<string>('')\n  const [file, setFile] = useState<File | undefined>(undefined)\n  const [isUploading, setIsUploading] = useState<boolean>(false)\n  const [validation, setValidation] = useState<Validation>({})\n\n  const validateInput = (inputName: string, value: string): boolean => {\n    const validationProp = `${inputName}Msg` as keyof typeof validation\n\n    if (value.trim().length > 0) {\n      setValidation({\n        ...validation,\n        [validationProp]: undefined\n      })\n      return true\n    }\n\n    const input = inputName.charAt(0).toUpperCase() + inputName.slice(1)\n    const validationMsg = `${input} is required`\n\n    setValidation({\n      ...validation,\n      [validationProp]: validationMsg\n    })\n    return false\n  }\n\n  const validateFile = (file: File | undefined): boolean => {\n    if (file == null || file.name == null) {\n      setValidation({\n        ...validation,\n        fileMsg: 'File is required'\n      })\n      return false\n    }\n\n    if (file.type == null) {\n      setValidation({\n        ...validation,\n        fileMsg: 'Could not determine file type'\n      })\n      return false\n    }\n\n    if (okFileTypes.find((x) => x === file.type) === undefined) {\n      setValidation({\n        ...validation,\n        fileMsg: 'This file type is not allowed'\n      })\n      return false\n    }\n\n    setValidation({\n      ...validation,\n      fileMsg: undefined\n    })\n    return true\n  }\n\n  const upload = (): void => {\n    void (async () => {\n      if (!validateInput('project', project)) return\n      if (!validateInput('version', version)) return\n      if (!validateFile(file) || file === undefined) return\n\n      setIsUploading(true)\n      const formData = new FormData()\n      formData.append('file', file)\n\n      const { success, message } = await ProjectRepository.upload(\n        project,\n        version,\n        formData\n      )\n\n      if (!success) {\n        console.error(message)\n        showMessage({\n          type: 'error',\n          content: message,\n          showMs: 6000\n        })\n        setIsUploading(false)\n        return\n      }\n\n      // reset the form\n      setProject('')\n      setVersion('')\n      setFile(undefined)\n      setValidation({})\n\n      showMessage({\n        type: 'success',\n        content: message,\n        showMs: 6000\n      })\n\n      reloadProjects()\n      setIsUploading(false)\n    })()\n  }\n\n  if (isUploading) {\n    return <LoadingPage />\n  }\n\n  const description = (\n    <p>\n      If you want to automate the upload of your documentation consider using{' '}\n      <code>curl</code> to post it to the server. There are some examples in the{' '}\n      <a\n        href=\"https://github.com/docat-org/docat/\"\n        target=\"_blank\"\n        rel=\"noreferrer\"\n      >\n        docat repository\n      </a>\n      .\n    </p>\n  )\n\n  return (\n    <PageLayout title=\"Upload Documentation\" description={description}>\n      <StyledForm>\n        <TextField\n          fullWidth\n          autoComplete=\"off\"\n          label=\"Project\"\n          value={project}\n          onChange={(e) => {\n            const project = e.target.value\n            setProject(project)\n            validateInput('project', project)\n          }}\n          error={validation.projectMsg !== undefined}\n          helperText={validation.projectMsg}\n        >\n          {project}\n        </TextField>\n\n        <TextField\n          fullWidth\n          autoComplete=\"off\"\n          label=\"Version\"\n          value={version}\n          onChange={(e) => {\n            const version = e.target.value\n            setVersion(version)\n            validateInput('version', version)\n          }}\n          error={validation.versionMsg !== undefined}\n          helperText={validation.versionMsg}\n        >\n          {version}\n        </TextField>\n\n        <FileInput\n          label=\"Zip File\"\n          file={file}\n          onChange={(file) => {\n            setFile(file)\n          }}\n          okTypes={okFileTypes}\n          isValid={validateFile}\n        ></FileInput>\n        <p className={`${styles['validation-message']} ${styles.red}`}>\n          {validation.fileMsg}\n        </p>\n\n        <button type=\"submit\" onClick={upload} className={styles['upload-btn']}>\n          Upload\n        </button>\n      </StyledForm>\n    </PageLayout>\n  )\n}\n"
  },
  {
    "path": "web/src/react-app-env.d.ts",
    "content": "import 'react-scripts'\n"
  },
  {
    "path": "web/src/repositories/ProjectRepository.ts",
    "content": "import semver from 'semver'\nimport type ProjectDetails from '../models/ProjectDetails'\nimport { type Project } from '../models/ProjectsResponse'\n\nconst RESOURCE = 'doc'\n\nfunction dateTimeReviver(key: string, value: unknown) {\n  if (key === 'timestamp') {\n    return new Date(value as string)\n  }\n  return value\n}\n\nfunction filterHiddenVersions(allProjects: Project[]): Project[] {\n  // create deep-copy first\n  const projects = JSON.parse(JSON.stringify(allProjects), dateTimeReviver) as Project[]\n\n  projects.forEach((p) => {\n    p.versions = p.versions.filter((v) => !v.hidden)\n  })\n\n  return projects.filter((p) => p.versions.length > 0)\n}\n\n/**\n * Returns a list of all versions of a project.\n * @param {string} projectName Name of the project\n */\nasync function getVersions(projectName: string): Promise<ProjectDetails[]> {\n  const res = await fetch(`/api/projects/${projectName}?include_hidden=true`)\n\n  if (!res.ok) {\n    console.error(((await res.json()) as { message: string }).message)\n    return []\n  }\n\n  const json = (await res.json()) as {\n    versions: ProjectDetails[]\n  }\n\n  return json.versions\n}\n\n/**\n * Returns the latest version of a project.\n * Order of precedence: latest, latest tag, latest version\n * @param versions all versions of a project\n */\nfunction getLatestVersion(versions: ProjectDetails[]): ProjectDetails {\n  const latest = versions.find((v) => v.name.includes('latest'))\n  if (latest != null) {\n    return latest\n  }\n\n  const latestTag = versions.find((v) => v.tags.includes('latest'))\n  if (latestTag != null) {\n    return latestTag\n  }\n\n  const sortedVersions = versions.sort((a, b) => compareVersions(a, b))\n\n  return sortedVersions[sortedVersions.length - 1]\n}\n\n/**\n * Returns the logo URL of a given project\n * @param {string} projectName Name of the project\n */\nfunction getProjectLogoURL(projectName: string): string {\n  return `/${RESOURCE}/${projectName}/logo`\n}\n\n/**\n * Returns the project documentation URL\n * @param {string} projectName Name of the project\n * @param {string} version Version name\n * @param {string?} docsPath Path to the documentation page\n * @param {string?} hash Hash part of the url (html id)\n */\nfunction getProjectDocsURL(\n  projectName: string,\n  version: string,\n  docsPath?: string,\n  hash?: string\n): string {\n  return `/${RESOURCE}/${projectName}/${version}/${docsPath ?? ''}${hash ?? ''}`\n}\n\n/**\n * Uploads new project documentation\n * @param {string} projectName Name of the project\n * @param {string} version Name of the version\n * @param {FormData} body Data to upload\n * @returns {Promise<{ success: boolean, message: string }>} Success status and (error) message\n */\nasync function upload(\n  projectName: string,\n  version: string,\n  body: FormData\n): Promise<{ success: boolean; message: string }> {\n  try {\n    const resp = await fetch(`/api/${projectName}/${version}`, {\n      method: 'POST',\n      body\n    })\n\n    if (resp.ok) {\n      const json = (await resp.json()) as { message: string }\n      const msg = json.message\n      return { success: true, message: msg }\n    }\n\n    switch (resp.status) {\n      case 401:\n        return {\n          success: false,\n          message: 'Failed to upload documentation: Version already exists'\n        }\n      case 504:\n        return {\n          success: false,\n          message: 'Failed to upload documentation: Server unreachable'\n        }\n      default:\n        return {\n          success: false,\n          message: `Failed to upload documentation: ${((await resp.json()) as { message: string }).message}`\n        }\n    }\n  } catch (e) {\n    return {\n      success: false,\n      message: `Failed to upload documentation: ${(e as { message: string }).message}`\n    }\n  }\n}\n\n/**\n * Claim the project token\n * @param {string} projectName Name of the project\n */\nasync function claim(projectName: string): Promise<{ token: string }> {\n  const resp = await fetch(`/api/${projectName}/claim`)\n\n  if (resp.ok) {\n    const json = (await resp.json()) as { token: string }\n    return json\n  }\n\n  switch (resp.status) {\n    case 504:\n      throw new Error('Failed to claim project: Server unreachable')\n    default:\n      throw new Error(\n        `Failed to claim project: ${((await resp.json()) as { message: string }).message}`\n      )\n  }\n}\n\n/**\n * Deletes existing project documentation\n * @param {string} projectName Name of the project\n * @param {string} version Name of the version\n * @param {string} token Token to authenticate\n */\nasync function deleteDoc(\n  projectName: string,\n  version: string,\n  token: string\n): Promise<void> {\n  const headers = { 'Docat-Api-Key': token }\n  const resp = await fetch(`/api/${projectName}/${version}`, {\n    method: 'DELETE',\n    headers\n  })\n\n  if (resp.ok) return\n\n  switch (resp.status) {\n    case 401:\n      throw new Error('Failed to delete documentation: Invalid token')\n    case 504:\n      throw new Error('Failed to delete documentation: Server unreachable')\n    default:\n      throw new Error(\n        `Failed to delete documentation: ${((await resp.json()) as { message: string }).message}`\n      )\n  }\n}\n\n/**\n * Compare two versions according to semantic version (semver library)\n * Will always consider the version latest as higher version\n *\n * @param {Object} versionA first version to compare\n * @param {string} versionA.name version name\n * @param {string[] | undefined} versionA.tags optional tags for this vertion\n *\n * @param {Object} versionB second version to compare\n * @param {string} versionB.name version name\n * @param {string[] | undefined} versionB.tags optional tags for this vertion\n */\nfunction compareVersions(\n  versionA: { name: string; tags?: string[] },\n  versionB: { name: string; tags?: string[] }\n): number {\n  if ((versionA.tags ?? []).includes('latest')) {\n    return 1\n  }\n\n  if ((versionB.tags ?? []).includes('latest')) {\n    return -1\n  }\n\n  const semverA = semver.coerce(versionA.name)\n  const semverB = semver.coerce(versionB.name)\n\n  if (semverA == null || semverB == null) {\n    return versionA.name.localeCompare(versionB.name)\n  }\n\n  return semver.compare(semverA, semverB)\n}\n\n/**\n * Returns boolean indicating if the project name is part of the favorites.\n * @param {string} projectName name of the project\n * @returns {boolean} - true is project is favorite\n */\nfunction isFavorite(projectName: string): boolean {\n  return localStorage.getItem(projectName) === 'favorite'\n}\n\n/**\n * Sets favorite preference on project\n * @param {string} projectName\n * @param {boolean} shouldBeFavorite\n */\nfunction setFavorite(projectName: string, shouldBeFavorite: boolean): void {\n  if (shouldBeFavorite) {\n    localStorage.setItem(projectName, 'favorite')\n  } else {\n    localStorage.removeItem(projectName)\n  }\n}\n\nconst exp = {\n  getVersions,\n  getLatestVersion,\n  filterHiddenVersions,\n  getProjectLogoURL,\n  getProjectDocsURL,\n  upload,\n  claim,\n  deleteDoc,\n  compareVersions,\n  isFavorite,\n  setFavorite\n}\n\nexport default exp\n"
  },
  {
    "path": "web/src/style/components/ControlButtons.module.css",
    "content": ".upload-button,\n.claim-button,\n.delete-button,\n.single-control-button {\n  width: 50px;\n  height: 50px;\n  border-radius: 50%;\n  background-color: var(--primary-foreground);\n  color: white;\n  position: fixed;\n  bottom: 20px;\n  border: none;\n  cursor: pointer;\n  z-index: 1; /* Without this, the footer is on top of the controls */\n\n  /* Center the icon */\n  display: flex;\n  justify-content: center;\n  align-items: center;\n}\n\n.upload-button {\n  right: 180px;\n}\n\n.claim-button {\n  right: 100px;\n}\n\n.delete-button,\n.single-control-button {\n  right: 20px;\n}\n"
  },
  {
    "path": "web/src/style/components/DocumentControlButtons.module.css",
    "content": "\n.controls {\n  position: fixed;\n  bottom: 32px;\n  right: 32px;\n  height: 50px;\n  display: flex;\n}\n\n.home-button,\n.share-button {\n  height: 50px;\n  width: 50px;\n  color: rgba(0, 0, 0, 0.87);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n}\n\n.home-button:hover,\n.share-button:hover {\n  background-color: #dbdbdb;\n}\n\n.home-button {\n  background-color: #efefef;\n  border-top-left-radius: 0.5rem;\n  border-bottom-left-radius: 0.5rem;\n}\n\n.share-button {\n  color: rgba(0, 0, 0, 0.87);\n  margin-left: 8px;\n  border-radius: 0.5rem;\n  cursor: pointer;\n}\n\n.version-select {\n  background: white;\n  overflow: hidden;\n\n  padding: 9px;\n  width: 200px;\n\n  font-size: 1.05em;\n\n  border-left-color: #efefef !important;\n  border-top-left-radius: 0 !important;\n  border-bottom-left-radius: 0 !important;\n\n}\n\n\n.version-select:focus-visible {\n  outline: none;\n}\n\n\n.share-modal {\n  display:flex;\n  flex-direction: column;\n\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  transform: translate(-50%, -50%);\n\n  width: 400px;\n  max-width: 80vw;\n  height: 200px;\n  overflow: hidden;\n\n  background-color: #fff;\n  border: none;\n  border-radius: 0.5rem;\n  padding: 1rem;\n  outline: 0;\n}\n\n.share-modal > * {\n  margin-bottom: 1rem;\n}\n\n.share-modal-link-container {\n  display: flex;\n}\n\n.share-modal-link {\n  padding: 0.5rem 1rem;\n  border-radius: 0.5rem;\n  border: 1px solid rgba(0, 0, 0, 0.42);\n  word-break: break-all;\n  user-select: all;\n  -moz-user-select: all;\n  -webkit-user-select: all;\n  font-size: small;\n  width: 100%;\n}\n\n.share-modal-copy-container {\n  display: flex;\n  align-items: center;\n  margin-left: 1rem;\n}\n\n.share-modal-copy {\n  padding: 0.5rem 1rem;\n  border: none;\n  border-radius: 0.5rem;\n  background-color: var(--primary-foreground);\n  color: #fff;\n  cursor: pointer;\n}\n\n.share-modal-close {\n  position: absolute;\n  bottom: 0.5rem;\n  right: 1rem;\n  border: none;\n  background-color: transparent;\n  cursor: pointer;\n  font-weight: bold;\n}\n\n.share-modal-label span {\n  font-size: small !important;\n}\n\n@media only screen and (max-width: 380px) {\n  .controls {\n    left: 32px;\n  }\n\n  .version-select {\n    width: calc(100vw - 100px - 64px)\n  }\n\n  .share-modal-link-container {\n    flex-direction: column;\n    align-items: center;\n  }\n\n  .share-modal-link {\n    width: auto;\n  }\n\n  .share-modal-copy-container {\n    margin-left: 0;\n    margin-top: 1rem;\n    width: 100%;\n    justify-content: center;\n  }\n\n  .share-modal-copy {\n    width: 80%;\n  }\n}\n"
  },
  {
    "path": "web/src/style/components/FileInput.module.css",
    "content": ".file-upload-container {\n  display: flex;\n  flex-direction: column;\n  max-height: 250px;\n}\n\n.file-drop-zone {\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n  height: max(150px, 25vh);\n  border-radius: 5px;\n  border: 1px solid rgba(0, 0, 0, 0.23);\n  cursor: pointer;\n}\n\n.file-drop-zone > * {\n  color: grey;\n}\n\n.drag-active {\n  background-color: var(--secondary-foreground);\n}\n\n.file-input {\n  display: none;\n}\n\n.file-upload-button {\n  cursor: pointer;\n  padding: 0.25rem;\n  border: none;\n  font-size: 1em;\n  background-color: transparent;\n}\n\n.drag-file-element {\n  position: absolute;\n  width: 100%;\n  height: 100%;\n  border-radius: 5px;\n  top: 0px;\n  right: 0px;\n  bottom: 0px;\n  left: 0px;\n}\n\n.file-upload-button:hover {\n  text-decoration-line: underline;\n}\n\n.file-upload-label {\n  background-color: white;\n  width: fit-content;\n  padding: 0 0.45em !important;\n  font-size: 0.8em !important;\n  z-index: 1;\n  top: 10px;\n  left: 0.75em;\n}\n"
  },
  {
    "path": "web/src/style/components/Footer.module.css",
    "content": ".footer {\n    margin-top: 32px;\n    padding: 15px 0 15px 0;\n}\n\n.help-link {\n    font-weight: 200;\n    text-decoration: none;\n    font-size: 20px;\n    padding-left: 8px;\n}\n\n.version-info {\n    font-weight: 200;\n    font-size: 14px;\n    margin-top: 0.8em;\n    color: var(--primary-foreground);\n    padding-left: 8px;\n}\n\n@media only screen and (min-width: 1000px) {\n    .footer {\n        padding-left: calc(15% + 16px);\n    }\n}\n\n@media only screen and (max-width: 1000px) {\n    .footer {\n        padding-left: 30px;\n    }\n}\n\n@media only screen and (max-width: 300px) {\n    .footer {\n        padding-left: 10px;\n    }\n}\n"
  },
  {
    "path": "web/src/style/components/Header.module.css",
    "content": ".header {\n  --header-height: 74px;\n\n  min-width: 230px;\n  height: var(--header-height);\n  border-bottom: 1px solid var(--secondary-foreground);\n}\n\n@media only screen and (min-width: 1000px) {\n  .header {\n    padding-left: calc(15% + 16px);\n  }\n}\n\n@media only screen and (max-width: 1000px) {\n  .header {\n    padding-left: 30px;\n  }\n}\n\n@media only screen and (max-width: 300px) {\n  .header {\n    padding-left: 10px;\n  }\n}\n\nimg {\n  height: var(--header-height);\n  float: left;\n}\n\nh1 {\n  float: left;\n  margin-top: calc(var(--header-height) / 2 - 15px);\n  margin-left: 10px;\n  font-size: 30px;\n  font-weight: 00;\n}\n"
  },
  {
    "path": "web/src/style/components/IFrame.module.css",
    "content": "html {\n  overflow: auto;\n}\n\nhtml,\nbody,\niframe {\n  margin: 0px;\n  padding: 0px;\n  height: 100%;\n  border: none;\n}\n\niframe {\n  height: 100vh;\n}\n\niframe {\n  position: relative;\n  display: block;\n  width: 100%;\n  border: none;\n  overflow-y: auto;\n  overflow-x: hidden;\n}\n"
  },
  {
    "path": "web/src/style/components/NavigationTitle.module.css",
    "content": ".nav-title {\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n}\n\n@media only screen and (min-width: 1400px) {\n  .nav-title {\n    width: 50%;\n  }\n}\n\n.page-header {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n}\n\n.back-link {\n  height: 24px;\n  width: 24px;\n}\n\n.page-title {\n  padding-left: 15px;\n  overflow-x: hidden;\n  text-overflow: ellipsis;\n}\n\n.page-description {\n  padding: 16px 0 16px 0;\n  font-size: 0.9em;\n}\n\n.page-description a {\n  text-decoration: underline;\n}\n"
  },
  {
    "path": "web/src/style/components/PageLayout.module.css",
    "content": ".main {\n  display: flex;\n  flex-direction: column;\n}\n\n@media only screen and (min-width: 650px) {\n  .main {\n    padding: 1% 0;\n    margin: 3% 16% 1% 16%;\n  }\n}\n\n@media only screen and (max-width: 650px) {\n  .main {\n    padding: 1% 0;\n    margin: 3%;\n  }\n}\n"
  },
  {
    "path": "web/src/style/components/Project.module.css",
    "content": ".project-card {\n  max-width: 800px;\n  margin-left: 24px;\n  margin-bottom: 8px;\n  margin-top: 8px;\n}\n\n.secondary-typography {\n  color: var(--primary-foreground);\n  opacity: 0.6;\n}\n\n.project-header {\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 6px;\n}\n\n.project-footer {\n  display: flex;\n  justify-content: space-between;\n  margin-bottom: 6px;\n}\n\n.subhead {\n  color: var(--primary-foreground);\n  opacity: 0.54;\n  font-size: 16px;\n}\n\n.project-card-title {\n  font-weight: 400;\n  font-size: 1.1em;\n}\n\n.project-logo {\n  float: left;\n  width: 40px;\n  height: 40px;\n  border-radius: 50%;\n  margin-right: 16px;\n}\n"
  },
  {
    "path": "web/src/style/components/ProjectList.module.css",
    "content": ".project-list {\n  display: grid;\n}\n\n.project-list {\n  min-width: 250px;\n  margin-right: 32px;\n}\n"
  },
  {
    "path": "web/src/style/components/SearchBar.module.css",
    "content": ".search-bar {\n  border: 1px solid #e8e8e8;\n  float: right;\n  margin: 8px 16px 0 0;\n  border: none;\n}\n\n@media only screen and (max-width: 500px) {\n  .search-bar {\n    display: none;\n  }\n}\n"
  },
  {
    "path": "web/src/style/components/StyledForm.module.css",
    "content": ".form {\n  display: flex;\n  flex-direction: column;\n}\n\n.form > div {\n  margin: 16px 0 0 0;\n  width: 100%;\n  min-height: 5em;\n}\n\n@media only screen and (min-width: 1400px) {\n  .form {\n    width: 50%;\n  }\n}\n\n@media only screen and (max-width: 1400px) {\n  .form {\n    width: 100%;\n  }\n}\n\nbutton[type='submit'] {\n  margin-top: 16px;\n  padding: 8px;\n  width: 100%;\n  max-width: 175px;\n  font-size: 1.05em;\n  border-radius: 8px;\n  border: none;\n  background-color: var(--button-primary);\n  color: white;\n  cursor: pointer;\n}\n\nbutton:disabled {\n  background-color: gray;\n  cursor: not-allowed;\n}\n"
  },
  {
    "path": "web/src/style/pages/Help.module.css",
    "content": "/* from here: https://github.com/sindresorhus/github-markdown-css */\n\n@media (prefers-color-scheme: light) {\n  .markdown-container {\n    color-scheme: light;\n    --color-prettylights-syntax-comment: #6e7781;\n    --color-prettylights-syntax-constant: #0550ae;\n    --color-prettylights-syntax-entity: #8250df;\n    --color-prettylights-syntax-storage-modifier-import: #24292f;\n    --color-prettylights-syntax-entity-tag: #116329;\n    --color-prettylights-syntax-keyword: #cf222e;\n    --color-prettylights-syntax-string: #0a3069;\n    --color-prettylights-syntax-variable: #953800;\n    --color-prettylights-syntax-brackethighlighter-unmatched: #82071e;\n    --color-prettylights-syntax-invalid-illegal-text: #f6f8fa;\n    --color-prettylights-syntax-invalid-illegal-bg: #82071e;\n    --color-prettylights-syntax-carriage-return-text: #f6f8fa;\n    --color-prettylights-syntax-carriage-return-bg: #cf222e;\n    --color-prettylights-syntax-string-regexp: #116329;\n    --color-prettylights-syntax-markup-list: #3b2300;\n    --color-prettylights-syntax-markup-heading: #0550ae;\n    --color-prettylights-syntax-markup-italic: #24292f;\n    --color-prettylights-syntax-markup-bold: #24292f;\n    --color-prettylights-syntax-markup-deleted-text: #82071e;\n    --color-prettylights-syntax-markup-deleted-bg: #ffebe9;\n    --color-prettylights-syntax-markup-inserted-text: #116329;\n    --color-prettylights-syntax-markup-inserted-bg: #dafbe1;\n    --color-prettylights-syntax-markup-changed-text: #953800;\n    --color-prettylights-syntax-markup-changed-bg: #ffd8b5;\n    --color-prettylights-syntax-markup-ignored-text: #eaeef2;\n    --color-prettylights-syntax-markup-ignored-bg: #0550ae;\n    --color-prettylights-syntax-meta-diff-range: #8250df;\n    --color-prettylights-syntax-brackethighlighter-angle: #57606a;\n    --color-prettylights-syntax-sublimelinter-gutter-mark: #8c959f;\n    --color-prettylights-syntax-constant-other-reference-link: #0a3069;\n    --color-fg-default: #24292f;\n    --color-fg-muted: #57606a;\n    --color-fg-subtle: #6e7781;\n    --color-canvas-default: #ffffff;\n    --color-canvas-subtle: #f6f8fa;\n    --color-border-default: #d0d7de;\n    --color-border-muted: hsla(210, 18%, 87%, 1);\n    --color-neutral-muted: rgba(175, 184, 193, 0.2);\n    --color-accent-fg: #0969da;\n    --color-accent-emphasis: #0969da;\n    --color-attention-subtle: #fff8c5;\n    --color-danger-fg: #cf222e;\n  }\n}\n\n.markdown-container {\n  -ms-text-size-adjust: 100%;\n  -webkit-text-size-adjust: 100%;\n  margin: 3vh 15% 0 15%;\n  padding: 0 0 3vh 0;\n  color: var(--color-fg-default);\n  background-color: var(--color-canvas-default);\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial,\n    sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\";\n  font-size: 16px;\n  line-height: 1.5;\n  word-wrap: break-word;\n  max-width: 800px;\n}\n\n.markdown-container .octicon {\n  display: inline-block;\n  fill: currentColor;\n  vertical-align: text-bottom;\n}\n\n.markdown-container h1:hover .anchor .octicon-link:before,\n.markdown-container h2:hover .anchor .octicon-link:before,\n.markdown-container h3:hover .anchor .octicon-link:before,\n.markdown-container h4:hover .anchor .octicon-link:before,\n.markdown-container h5:hover .anchor .octicon-link:before,\n.markdown-container h6:hover .anchor .octicon-link:before {\n  width: 16px;\n  height: 16px;\n  content: \" \";\n  display: inline-block;\n  background-color: currentColor;\n  -webkit-mask-image: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>\");\n  mask-image: url(\"data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' version='1.1' aria-hidden='true'><path fill-rule='evenodd' d='M7.775 3.275a.75.75 0 001.06 1.06l1.25-1.25a2 2 0 112.83 2.83l-2.5 2.5a2 2 0 01-2.83 0 .75.75 0 00-1.06 1.06 3.5 3.5 0 004.95 0l2.5-2.5a3.5 3.5 0 00-4.95-4.95l-1.25 1.25zm-4.69 9.64a2 2 0 010-2.83l2.5-2.5a2 2 0 012.83 0 .75.75 0 001.06-1.06 3.5 3.5 0 00-4.95 0l-2.5 2.5a3.5 3.5 0 004.95 4.95l1.25-1.25a.75.75 0 00-1.06-1.06l-1.25 1.25a2 2 0 01-2.83 0z'></path></svg>\");\n}\n\n.markdown-container details,\n.markdown-container figcaption,\n.markdown-container figure {\n  display: block;\n}\n\n.markdown-container summary {\n  display: list-item;\n}\n\n.markdown-container [hidden] {\n  display: none !important;\n}\n\n.markdown-container a {\n  background-color: transparent;\n  color: var(--color-accent-fg);\n  text-decoration: none;\n}\n\n.markdown-container a:active,\n.markdown-container a:hover {\n  outline-width: 0;\n}\n\n.markdown-container abbr[title] {\n  border-bottom: none;\n  text-decoration: underline dotted;\n}\n\n.markdown-container b,\n.markdown-container strong {\n  font-weight: 600;\n}\n\n.markdown-container dfn {\n  font-style: italic;\n}\n\n.markdown-container h1 {\n  margin: 0.67em 0;\n  font-weight: 600;\n  padding-bottom: 0.3em;\n  font-size: 2em;\n  border-bottom: 1px solid var(--color-border-muted);\n}\n\n.markdown-container mark {\n  background-color: var(--color-attention-subtle);\n  color: var(--color-text-primary);\n}\n\n.markdown-container small {\n  font-size: 90%;\n}\n\n.markdown-container sub,\n.markdown-container sup {\n  font-size: 75%;\n  line-height: 0;\n  position: relative;\n  vertical-align: baseline;\n}\n\n.markdown-container sub {\n  bottom: -0.25em;\n}\n\n.markdown-container sup {\n  top: -0.5em;\n}\n\n.markdown-container img {\n  border-style: none;\n  max-width: 100%;\n  box-sizing: content-box;\n  background-color: var(--color-canvas-default);\n}\n\n.markdown-container code,\n.markdown-container kbd,\n.markdown-container pre,\n.markdown-container samp {\n  font-family: monospace, monospace;\n  font-size: 1em;\n}\n\n.markdown-container figure {\n  margin: 1em 40px;\n}\n\n.markdown-container hr {\n  box-sizing: content-box;\n  overflow: hidden;\n  background: transparent;\n  border-bottom: 1px solid var(--color-border-muted);\n  height: 0.25em;\n  padding: 0;\n  margin: 24px 0;\n  background-color: var(--color-border-default);\n  border: 0;\n}\n\n.markdown-container input {\n  font: inherit;\n  margin: 0;\n  overflow: visible;\n  font-family: inherit;\n  font-size: inherit;\n  line-height: inherit;\n}\n\n.markdown-container [type=\"button\"],\n.markdown-container [type=\"reset\"],\n.markdown-container [type=\"submit\"] {\n  -webkit-appearance: button;\n}\n\n.markdown-container [type=\"button\"]::-moz-focus-inner,\n.markdown-container [type=\"reset\"]::-moz-focus-inner,\n.markdown-container [type=\"submit\"]::-moz-focus-inner {\n  border-style: none;\n  padding: 0;\n}\n\n.markdown-container [type=\"button\"]:-moz-focusring,\n.markdown-container [type=\"reset\"]:-moz-focusring,\n.markdown-container [type=\"submit\"]:-moz-focusring {\n  outline: 1px dotted ButtonText;\n}\n\n.markdown-container [type=\"checkbox\"],\n.markdown-container [type=\"radio\"] {\n  box-sizing: border-box;\n  padding: 0;\n}\n\n.markdown-container [type=\"number\"]::-webkit-inner-spin-button,\n.markdown-container [type=\"number\"]::-webkit-outer-spin-button {\n  height: auto;\n}\n\n.markdown-container [type=\"search\"] {\n  -webkit-appearance: textfield;\n  outline-offset: -2px;\n}\n\n.markdown-container [type=\"search\"]::-webkit-search-cancel-button,\n.markdown-container [type=\"search\"]::-webkit-search-decoration {\n  -webkit-appearance: none;\n}\n\n.markdown-container ::-webkit-input-placeholder {\n  color: inherit;\n  opacity: 0.54;\n}\n\n.markdown-container ::-webkit-file-upload-button {\n  -webkit-appearance: button;\n  font: inherit;\n}\n\n.markdown-container a:hover {\n  text-decoration: underline;\n}\n\n.markdown-container hr::before {\n  display: table;\n  content: \"\";\n}\n\n.markdown-container hr::after {\n  display: table;\n  clear: both;\n  content: \"\";\n}\n\n.markdown-container table {\n  border-spacing: 0;\n  border-collapse: collapse;\n  display: block;\n  width: max-content;\n  max-width: 100%;\n  overflow: auto;\n}\n\n.markdown-container td,\n.markdown-container th {\n  padding: 0;\n}\n\n.markdown-container details summary {\n  cursor: pointer;\n}\n\n.markdown-container details:not([open]) > *:not(summary) {\n  display: none !important;\n}\n\n.markdown-container kbd {\n  display: inline-block;\n  padding: 3px 5px;\n  font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,\n    Liberation Mono, monospace;\n  line-height: 10px;\n  color: var(--color-fg-default);\n  vertical-align: middle;\n  background-color: var(--color-canvas-subtle);\n  border: solid 1px var(--color-neutral-muted);\n  border-bottom-color: var(--color-neutral-muted);\n  border-radius: 6px;\n  box-shadow: inset 0 -1px 0 var(--color-neutral-muted);\n}\n\n.markdown-container h1,\n.markdown-container h2,\n.markdown-container h3,\n.markdown-container h4,\n.markdown-container h5,\n.markdown-container h6 {\n  margin-top: 24px;\n  margin-bottom: 16px;\n  font-weight: 600;\n  line-height: 1.25;\n}\n\n.markdown-container h2 {\n  font-weight: 600;\n  padding-bottom: 0.3em;\n  font-size: 1.5em;\n  border-bottom: 1px solid var(--color-border-muted);\n}\n\n.markdown-container h3 {\n  font-weight: 600;\n  font-size: 1.25em;\n}\n\n.markdown-container h4 {\n  font-weight: 600;\n  font-size: 1em;\n}\n\n.markdown-container h5 {\n  font-weight: 600;\n  font-size: 0.875em;\n}\n\n.markdown-container h6 {\n  font-weight: 600;\n  font-size: 0.85em;\n  color: var(--color-fg-muted);\n}\n\n.markdown-container p {\n  margin-top: 0;\n  margin-bottom: 10px;\n}\n\n.markdown-container blockquote {\n  margin: 0;\n  padding: 0 1em;\n  color: var(--color-fg-muted);\n  border-left: 0.25em solid var(--color-border-default);\n}\n\n.markdown-container ul,\n.markdown-container ol {\n  margin-top: 0;\n  margin-bottom: 0;\n  padding-left: 2em;\n}\n\n.markdown-container ol ol,\n.markdown-container ul ol {\n  list-style-type: lower-roman;\n}\n\n.markdown-container ul ul ol,\n.markdown-container ul ol ol,\n.markdown-container ol ul ol,\n.markdown-container ol ol ol {\n  list-style-type: lower-alpha;\n}\n\n.markdown-container dd {\n  margin-left: 0;\n}\n\n.markdown-container tt,\n.markdown-container code {\n  font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,\n    Liberation Mono, monospace;\n  font-size: 12px;\n}\n\n.markdown-container pre {\n  margin-top: 0;\n  margin-bottom: 0;\n  font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,\n    Liberation Mono, monospace;\n  font-size: 12px;\n  word-wrap: normal;\n}\n\n.markdown-container .octicon {\n  display: inline-block;\n  overflow: visible !important;\n  vertical-align: text-bottom;\n  fill: currentColor;\n}\n\n.markdown-container ::placeholder {\n  color: var(--color-fg-subtle);\n  opacity: 1;\n}\n\n.markdown-container input::-webkit-outer-spin-button,\n.markdown-container input::-webkit-inner-spin-button {\n  margin: 0;\n  -webkit-appearance: none;\n  appearance: none;\n}\n\n.markdown-container .pl-c {\n  color: var(--color-prettylights-syntax-comment);\n}\n\n.markdown-container .pl-c1,\n.markdown-container .pl-s .pl-v {\n  color: var(--color-prettylights-syntax-constant);\n}\n\n.markdown-container .pl-e,\n.markdown-container .pl-en {\n  color: var(--color-prettylights-syntax-entity);\n}\n\n.markdown-container .pl-smi,\n.markdown-container .pl-s .pl-s1 {\n  color: var(--color-prettylights-syntax-storage-modifier-import);\n}\n\n.markdown-container .pl-ent {\n  color: var(--color-prettylights-syntax-entity-tag);\n}\n\n.markdown-container .pl-k {\n  color: var(--color-prettylights-syntax-keyword);\n}\n\n.markdown-container .pl-s,\n.markdown-container .pl-pds,\n.markdown-container .pl-s .pl-pse .pl-s1,\n.markdown-container .pl-sr,\n.markdown-container .pl-sr .pl-cce,\n.markdown-container .pl-sr .pl-sre,\n.markdown-container .pl-sr .pl-sra {\n  color: var(--color-prettylights-syntax-string);\n}\n\n.markdown-container .pl-v,\n.markdown-container .pl-smw {\n  color: var(--color-prettylights-syntax-variable);\n}\n\n.markdown-container .pl-bu {\n  color: var(--color-prettylights-syntax-brackethighlighter-unmatched);\n}\n\n.markdown-container .pl-ii {\n  color: var(--color-prettylights-syntax-invalid-illegal-text);\n  background-color: var(--color-prettylights-syntax-invalid-illegal-bg);\n}\n\n.markdown-container .pl-c2 {\n  color: var(--color-prettylights-syntax-carriage-return-text);\n  background-color: var(--color-prettylights-syntax-carriage-return-bg);\n}\n\n.markdown-container .pl-sr .pl-cce {\n  font-weight: bold;\n  color: var(--color-prettylights-syntax-string-regexp);\n}\n\n.markdown-container .pl-ml {\n  color: var(--color-prettylights-syntax-markup-list);\n}\n\n.markdown-container .pl-mh,\n.markdown-container .pl-mh .pl-en,\n.markdown-container .pl-ms {\n  font-weight: bold;\n  color: var(--color-prettylights-syntax-markup-heading);\n}\n\n.markdown-container .pl-mi {\n  font-style: italic;\n  color: var(--color-prettylights-syntax-markup-italic);\n}\n\n.markdown-container .pl-mb {\n  font-weight: bold;\n  color: var(--color-prettylights-syntax-markup-bold);\n}\n\n.markdown-container .pl-md {\n  color: var(--color-prettylights-syntax-markup-deleted-text);\n  background-color: var(--color-prettylights-syntax-markup-deleted-bg);\n}\n\n.markdown-container .pl-mi1 {\n  color: var(--color-prettylights-syntax-markup-inserted-text);\n  background-color: var(--color-prettylights-syntax-markup-inserted-bg);\n}\n\n.markdown-container .pl-mc {\n  color: var(--color-prettylights-syntax-markup-changed-text);\n  background-color: var(--color-prettylights-syntax-markup-changed-bg);\n}\n\n.markdown-container .pl-mi2 {\n  color: var(--color-prettylights-syntax-markup-ignored-text);\n  background-color: var(--color-prettylights-syntax-markup-ignored-bg);\n}\n\n.markdown-container .pl-mdr {\n  font-weight: bold;\n  color: var(--color-prettylights-syntax-meta-diff-range);\n}\n\n.markdown-container .pl-ba {\n  color: var(--color-prettylights-syntax-brackethighlighter-angle);\n}\n\n.markdown-container .pl-sg {\n  color: var(--color-prettylights-syntax-sublimelinter-gutter-mark);\n}\n\n.markdown-container .pl-corl {\n  text-decoration: underline;\n  color: var(--color-prettylights-syntax-constant-other-reference-link);\n}\n\n.markdown-container [data-catalyst] {\n  display: block;\n}\n\n.markdown-container g-emoji {\n  font-family: \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\";\n  font-size: 1em;\n  font-style: normal !important;\n  font-weight: 400;\n  line-height: 1;\n  vertical-align: -0.075em;\n}\n\n.markdown-container g-emoji img {\n  width: 1em;\n  height: 1em;\n}\n\n.markdown-container::before {\n  display: table;\n  content: \"\";\n}\n\n.markdown-container::after {\n  display: table;\n  clear: both;\n  content: \"\";\n}\n\n.markdown-container > *:first-child {\n  margin-top: 0 !important;\n}\n\n.markdown-container > *:last-child {\n  margin-bottom: 0 !important;\n}\n\n.markdown-container a:not([href]) {\n  color: inherit;\n  text-decoration: none;\n}\n\n.markdown-container .absent {\n  color: var(--color-danger-fg);\n}\n\n.markdown-container .anchor {\n  float: left;\n  padding-right: 4px;\n  margin-left: -20px;\n  line-height: 1;\n}\n\n.markdown-container .anchor:focus {\n  outline: none;\n}\n\n.markdown-container p,\n.markdown-container blockquote,\n.markdown-container ul,\n.markdown-container ol,\n.markdown-container dl,\n.markdown-container table,\n.markdown-container pre,\n.markdown-container details {\n  margin-top: 0;\n  margin-bottom: 16px;\n}\n\n.markdown-container blockquote > :first-child {\n  margin-top: 0;\n}\n\n.markdown-container blockquote > :last-child {\n  margin-bottom: 0;\n}\n\n.markdown-container sup > a::before {\n  content: \"[\";\n}\n\n.markdown-container sup > a::after {\n  content: \"]\";\n}\n\n.markdown-container h1 .octicon-link,\n.markdown-container h2 .octicon-link,\n.markdown-container h3 .octicon-link,\n.markdown-container h4 .octicon-link,\n.markdown-container h5 .octicon-link,\n.markdown-container h6 .octicon-link {\n  color: var(--color-fg-default);\n  vertical-align: middle;\n  visibility: hidden;\n}\n\n.markdown-container h1:hover .anchor,\n.markdown-container h2:hover .anchor,\n.markdown-container h3:hover .anchor,\n.markdown-container h4:hover .anchor,\n.markdown-container h5:hover .anchor,\n.markdown-container h6:hover .anchor {\n  text-decoration: none;\n}\n\n.markdown-container h1:hover .anchor .octicon-link,\n.markdown-container h2:hover .anchor .octicon-link,\n.markdown-container h3:hover .anchor .octicon-link,\n.markdown-container h4:hover .anchor .octicon-link,\n.markdown-container h5:hover .anchor .octicon-link,\n.markdown-container h6:hover .anchor .octicon-link {\n  visibility: visible;\n}\n\n.markdown-container h1 tt,\n.markdown-container h1 code,\n.markdown-container h2 tt,\n.markdown-container h2 code,\n.markdown-container h3 tt,\n.markdown-container h3 code,\n.markdown-container h4 tt,\n.markdown-container h4 code,\n.markdown-container h5 tt,\n.markdown-container h5 code,\n.markdown-container h6 tt,\n.markdown-container h6 code {\n  padding: 0 0.2em;\n  font-size: inherit;\n}\n\n.markdown-container ul.no-list,\n.markdown-container ol.no-list {\n  padding: 0;\n  list-style-type: none;\n}\n\n.markdown-container ol[type=\"1\"] {\n  list-style-type: decimal;\n}\n\n.markdown-container ol[type=\"a\"] {\n  list-style-type: lower-alpha;\n}\n\n.markdown-container ol[type=\"i\"] {\n  list-style-type: lower-roman;\n}\n\n.markdown-container div > ol:not([type]) {\n  list-style-type: decimal;\n}\n\n.markdown-container ul ul,\n.markdown-container ul ol,\n.markdown-container ol ol,\n.markdown-container ol ul {\n  margin-top: 0;\n  margin-bottom: 0;\n}\n\n.markdown-container li > p {\n  margin-top: 16px;\n}\n\n.markdown-container li + li {\n  margin-top: 0.25em;\n}\n\n.markdown-container dl {\n  padding: 0;\n}\n\n.markdown-container dl dt {\n  padding: 0;\n  margin-top: 16px;\n  font-size: 1em;\n  font-style: italic;\n  font-weight: 600;\n}\n\n.markdown-container dl dd {\n  padding: 0 16px;\n  margin-bottom: 16px;\n}\n\n.markdown-container table th {\n  font-weight: 600;\n}\n\n.markdown-container table th,\n.markdown-container table td {\n  padding: 6px 13px;\n  border: 1px solid var(--color-border-default);\n}\n\n.markdown-container table tr {\n  background-color: var(--color-canvas-default);\n  border-top: 1px solid var(--color-border-muted);\n}\n\n.markdown-container table tr:nth-child(2n) {\n  background-color: var(--color-canvas-subtle);\n}\n\n.markdown-container table img {\n  background-color: transparent;\n}\n\n.markdown-container img[align=\"right\"] {\n  padding-left: 20px;\n}\n\n.markdown-container img[align=\"left\"] {\n  padding-right: 20px;\n}\n\n.markdown-container .emoji {\n  max-width: none;\n  vertical-align: text-top;\n  background-color: transparent;\n}\n\n.markdown-container span.frame {\n  display: block;\n  overflow: hidden;\n}\n\n.markdown-container span.frame > span {\n  display: block;\n  float: left;\n  width: auto;\n  padding: 7px;\n  margin: 13px 0 0;\n  overflow: hidden;\n  border: 1px solid var(--color-border-default);\n}\n\n.markdown-container span.frame span img {\n  display: block;\n  float: left;\n}\n\n.markdown-container span.frame span span {\n  display: block;\n  padding: 5px 0 0;\n  clear: both;\n  color: var(--color-fg-default);\n}\n\n.markdown-container span.align-center {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-container span.align-center > span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: center;\n}\n\n.markdown-container span.align-center span img {\n  margin: 0 auto;\n  text-align: center;\n}\n\n.markdown-container span.align-right {\n  display: block;\n  overflow: hidden;\n  clear: both;\n}\n\n.markdown-container span.align-right > span {\n  display: block;\n  margin: 13px 0 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-container span.align-right span img {\n  margin: 0;\n  text-align: right;\n}\n\n.markdown-container span.float-left {\n  display: block;\n  float: left;\n  margin-right: 13px;\n  overflow: hidden;\n}\n\n.markdown-container span.float-left span {\n  margin: 13px 0 0;\n}\n\n.markdown-container span.float-right {\n  display: block;\n  float: right;\n  margin-left: 13px;\n  overflow: hidden;\n}\n\n.markdown-container span.float-right > span {\n  display: block;\n  margin: 13px auto 0;\n  overflow: hidden;\n  text-align: right;\n}\n\n.markdown-container code,\n.markdown-container tt {\n  padding: 0.2em 0.4em;\n  margin: 0;\n  font-size: 85%;\n  background-color: var(--color-neutral-muted);\n  border-radius: 6px;\n}\n\n.markdown-container code br,\n.markdown-container tt br {\n  display: none;\n}\n\n.markdown-container del code {\n  text-decoration: inherit;\n}\n\n.markdown-container pre code {\n  font-size: 100%;\n}\n\n.markdown-container pre > code {\n  padding: 0;\n  margin: 0;\n  word-break: normal;\n  white-space: pre;\n  background: transparent;\n  border: 0;\n}\n\n.markdown-container .highlight {\n  margin-bottom: 16px;\n}\n\n.markdown-container .highlight pre {\n  margin-bottom: 0;\n  word-break: normal;\n}\n\n.markdown-container .highlight pre,\n.markdown-container pre {\n  padding: 16px;\n  overflow: auto;\n  font-size: 85%;\n  line-height: 1.45;\n  background-color: var(--color-canvas-subtle);\n  border-radius: 6px;\n}\n\n.markdown-container pre code,\n.markdown-container pre tt {\n  display: inline;\n  max-width: auto;\n  padding: 0;\n  margin: 0;\n  overflow: visible;\n  line-height: inherit;\n  word-wrap: normal;\n  background-color: transparent;\n  border: 0;\n}\n\n.markdown-container .csv-data td,\n.markdown-container .csv-data th {\n  padding: 5px;\n  overflow: hidden;\n  font-size: 12px;\n  line-height: 1;\n  text-align: left;\n  white-space: nowrap;\n}\n\n.markdown-container .csv-data .blob-num {\n  padding: 10px 8px 9px;\n  text-align: right;\n  background: var(--color-canvas-default);\n  border: 0;\n}\n\n.markdown-container .csv-data tr {\n  border-top: 0;\n}\n\n.markdown-container .csv-data th {\n  font-weight: 600;\n  background: var(--color-canvas-subtle);\n  border-top: 0;\n}\n\n.markdown-container .footnotes {\n  font-size: 12px;\n  color: var(--color-fg-muted);\n  border-top: 1px solid var(--color-border-default);\n}\n\n.markdown-container .footnotes ol {\n  padding-left: 16px;\n}\n\n.markdown-container .footnotes li {\n  position: relative;\n}\n\n.markdown-container .footnotes li:target::before {\n  position: absolute;\n  top: -8px;\n  right: -8px;\n  bottom: -8px;\n  left: -24px;\n  pointer-events: none;\n  content: \"\";\n  border: 2px solid var(--color-accent-emphasis);\n  border-radius: 6px;\n}\n\n.markdown-container .footnotes li:target {\n  color: var(--color-fg-default);\n}\n\n.markdown-container .footnotes .data-footnote-backref g-emoji {\n  font-family: monospace;\n}\n\n.markdown-container .task-list-item {\n  list-style-type: none;\n}\n\n.markdown-container .task-list-item label {\n  font-weight: 400;\n}\n\n.markdown-container .task-list-item.enabled label {\n  cursor: pointer;\n}\n\n.markdown-container .task-list-item + .task-list-item {\n  margin-top: 3px;\n}\n\n.markdown-container .task-list-item .handle {\n  display: none;\n}\n\n.markdown-container .task-list-item-checkbox {\n  margin: 0 0.2em 0.25em -1.6em;\n  vertical-align: middle;\n}\n\n.markdown-container .contains-task-list:dir(rtl) .task-list-item-checkbox {\n  margin: 0 -1.6em 0.25em 0.2em;\n}\n\n.markdown-container ::-webkit-calendar-picker-indicator {\n  filter: invert(50%);\n}\n"
  },
  {
    "path": "web/src/style/pages/Home.module.css",
    "content": ".loading-error {\n  color: red;\n  text-align: center;\n  padding: 30px 0;\n  font-size: large;\n}\n\n.no-results {\n  text-align: center;\n  font-size: 130%;\n  margin: 16px;\n}\n\n.divider {\n  border-bottom: 1px solid var(--secondary-foreground);\n  margin-bottom: 16px;\n  padding-bottom: 16px;\n}\n\n.project-overview {\n  display: flex;\n  flex-direction: row;\n  align-items: flex-start;\n}\n\n.card {\n  border: 1px solid var(--secondary-foreground);\n  border-radius: 5px;\n  width: 250px;\n  padding: 16px;\n  margin-left: 16px;\n}\n\n.clear {\n  clear: both;\n}\n\n.card-header {\n  margin-top: 8px;\n}\n\n@media only screen and (min-width: 1000px) {\n  .project-overview {\n    margin: 20px 15%;\n    width: 70%;\n  }\n}\n\n@media only screen and (max-width: 1000px) {\n  .project-overview {\n    margin: 20px;\n  }\n}\n"
  },
  {
    "path": "web/src/style/pages/NotFound.module.css",
    "content": ".not-found {\n  height: fit-content;\n  min-height: 100vh;\n  display: flex;\n  flex-direction: column;\n  justify-content: space-between;\n}\n\n.not-found-container {\n  display: flex;\n  flex-direction: column;\n  justify-content: center;\n  align-items: center;\n\n  width: 100vw;\n}\n\n.not-found-title {\n  font-weight: 600;\n}\n\n.not-found-text {\n  margin-top: 10px;\n  font-size: 1.2rem;\n  text-align: center;\n}\n\n.not-found-link {\n  background-color: var(--button-primary);\n  color: white;\n  margin: 15px 0;\n  padding: 8px 16px;\n  border-radius: 8px;\n}\n"
  },
  {
    "path": "web/src/style/pages/Upload.module.css",
    "content": ".validation-message {\n  padding-top: 5px;\n  margin-left: 14px;\n  font-size: 0.8em;\n  min-height: 1em;\n}\n\n.red {\n  color: red;\n}\n"
  },
  {
    "path": "web/src/tests/repositories/ProjectRepository.test.ts",
    "content": "/* eslint-disable @typescript-eslint/no-explicit-any */\n// -> we need any for our mocks\n\nimport ProjectDetails from '../../models/ProjectDetails'\nimport { type Project } from '../../models/ProjectsResponse'\nimport ProjectRepository from '../../repositories/ProjectRepository'\nconst mockFetchData = (fetchData: any): void => {\n  globalThis.fetch = vi.fn().mockImplementation(\n    async () =>\n      await Promise.resolve({\n        ok: true,\n        json: async () => await Promise.resolve(fetchData)\n      })\n  )\n}\n\nconst mockFetchError = (errorMsg = 'Error'): void => {\n  globalThis.fetch = vi.fn().mockImplementation(\n    async () =>\n      await Promise.resolve({\n        ok: false,\n        json: async () => await Promise.resolve({ message: errorMsg })\n      })\n  )\n}\n\nconst mockFetchStatus = (status: number, message?: string): void => {\n  globalThis.fetch = vi.fn().mockImplementation(\n    async () =>\n      await Promise.resolve({\n        ok: false,\n        status,\n        json: async () => await Promise.resolve({ message: message ?? 'Error' })\n      })\n  )\n}\n\ndescribe('get versions', () => {\n  test('should return versions', async () => {\n    const projectName = 'test'\n    const versions = ['1.0.0', '2.0.0']\n    const responseData = versions.map(\n      (version) => new ProjectDetails(version, ['tag'], false, new Date())\n    )\n\n    mockFetchData({ versions: responseData })\n\n    const result = await ProjectRepository.getVersions(projectName)\n\n    expect(result).toEqual(responseData)\n  })\n\n  test('should return empty array on error and log error', async () => {\n    const projectName = 'test'\n\n    mockFetchError('Test Error')\n    console.error = vi.fn()\n\n    const result = await ProjectRepository.getVersions(projectName)\n\n    expect(result).toEqual([])\n    expect(console.error).toBeCalledWith('Test Error')\n  })\n})\n\ndescribe('get project logo url', () => {\n  test('should return the correct url', () => {\n    const projectName = 'test-project'\n\n    const result = ProjectRepository.getProjectLogoURL(projectName)\n\n    expect(result).toEqual(`/doc/${projectName}/logo`)\n  })\n})\n\ndescribe('get project docs url', () => {\n  test('should return the correct url without path', () => {\n    const projectName = 'test-project'\n    const version = '1.0.0'\n\n    const result = ProjectRepository.getProjectDocsURL(projectName, version)\n\n    expect(result).toEqual(`/doc/${projectName}/${version}/`)\n  })\n\n  test('should return the correct url with path', () => {\n    const projectName = 'test-project'\n    const version = '1.0.0'\n    const path = 'path/to/file'\n\n    const result = ProjectRepository.getProjectDocsURL(\n      projectName,\n      version,\n      path\n    )\n\n    expect(result).toEqual(`/doc/${projectName}/${version}/${path}`)\n  })\n})\n\ndescribe('upload', () => {\n  test('should post file', async () => {\n    const project = 'test-project'\n    const version = '1.0.0'\n\n    mockFetchData({ message: 'Documentation was uploaded successfully' })\n\n    const body = new FormData()\n    body.append('file', new Blob([''], { type: 'text/plain' }))\n\n    const { success, message } = await ProjectRepository.upload(\n      project,\n      version,\n      body\n    )\n\n    expect(globalThis.fetch).toHaveBeenCalledTimes(1)\n    expect(globalThis.fetch).toHaveBeenCalledWith(`/api/${project}/${version}`, {\n      body,\n      method: 'POST'\n    })\n\n    expect(success).toEqual(true)\n    expect(message).toEqual('Documentation was uploaded successfully')\n  })\n\n  test('should throw version already exists on 401 status code', async () => {\n    const project = 'test-project'\n    const version = '1.0.0'\n\n    mockFetchStatus(401)\n\n    const body = new FormData()\n    body.append('file', new Blob([''], { type: 'text/plain' }))\n\n    const { success, message } = await ProjectRepository.upload(\n      project,\n      version,\n      body\n    )\n\n    expect(success).toEqual(false)\n    expect(message).toEqual(\n      'Failed to upload documentation: Version already exists'\n    )\n  })\n\n  test('should throw server unreachable on 504 status code', async () => {\n    const project = 'test-project'\n    const version = '1.0.0'\n\n    mockFetchStatus(504)\n\n    const body = new FormData()\n    body.append('file', new Blob([''], { type: 'text/plain' }))\n\n    const { success, message } = await ProjectRepository.upload(\n      project,\n      version,\n      body\n    )\n\n    expect(success).toEqual(false)\n    expect(message).toEqual(\n      'Failed to upload documentation: Server unreachable'\n    )\n  })\n\n  test('should throw error on other status code', async () => {\n    const project = 'test-project'\n    const version = '1.0.0'\n\n    mockFetchStatus(500, 'Test Error')\n\n    const body = new FormData()\n    body.append('file', new Blob([''], { type: 'text/plain' }))\n\n    const { success, message } = await ProjectRepository.upload(\n      project,\n      version,\n      body\n    )\n\n    expect(success).toEqual(false)\n    expect(message).toEqual('Failed to upload documentation: Test Error')\n  })\n})\n\ndescribe('claim project', () => {\n  test('should call claim api with project name', async () => {\n    const project = 'test-project'\n\n    mockFetchData({ token: 'test-token' })\n\n    const respToken = await ProjectRepository.claim(project)\n\n    expect(globalThis.fetch).toHaveBeenCalledTimes(1)\n    expect(globalThis.fetch).toHaveBeenCalledWith(`/api/${project}/claim`)\n    expect(respToken.token).toEqual('test-token')\n  })\n\n  test('should throw error when project already claimed', async () => {\n    const project = 'test-project'\n\n    mockFetchStatus(409, `Project ${project} is already claimed!`)\n\n    await expect(ProjectRepository.claim(project)).rejects.toThrow(\n      `Project ${project} is already claimed!`\n    )\n  })\n\n  test('should throw server unreachable on 504 status code', async () => {\n    const project = 'test-project'\n\n    mockFetchStatus(504)\n\n    await expect(ProjectRepository.claim(project)).rejects.toThrow(\n      'Failed to claim project: Server unreachable'\n    )\n  })\n})\n\ndescribe('deleteDoc', () => {\n  test('should call delete api with project name and version', async () => {\n    const project = 'test-project'\n    const version = '1.0.0'\n    const token = 'test-token'\n\n    mockFetchData({})\n\n    await ProjectRepository.deleteDoc(project, version, token)\n\n    expect(globalThis.fetch).toHaveBeenCalledTimes(1)\n    expect(globalThis.fetch).toHaveBeenCalledWith(`/api/${project}/${version}`, {\n      method: 'DELETE',\n      headers: { 'Docat-Api-Key': token }\n    })\n  })\n\n  test('should throw invalid token on 401 status code', async () => {\n    const project = 'test-project'\n    const version = '1.0.0'\n    const token = 'test-token'\n\n    mockFetchStatus(401)\n\n    await expect(\n      ProjectRepository.deleteDoc(project, version, token)\n    ).rejects.toThrow('Failed to delete documentation: Invalid token')\n  })\n\n  test('should throw server unreachable on 504 status code', async () => {\n    const project = 'test-project'\n    const version = '1.0.0'\n    const token = 'test-token'\n\n    mockFetchStatus(504)\n\n    await expect(\n      ProjectRepository.deleteDoc(project, version, token)\n    ).rejects.toThrow('Failed to delete documentation: Server unreachable')\n  })\n\n  test('should throw error on other status code', async () => {\n    const project = 'test-project'\n    const version = '1.0.0'\n    const token = 'test-token'\n    const error = 'Test Error'\n\n    mockFetchStatus(500, error)\n\n    await expect(\n      ProjectRepository.deleteDoc(project, version, token)\n    ).rejects.toThrow(`Failed to delete documentation: ${error}`)\n  })\n})\n\ndescribe('compare versions', () => {\n  test('should sort doc versions as semantic versions', async () => {\n    expect(\n      ProjectRepository.compareVersions({ name: '0.0.0' }, { name: '0.0.1' })\n    ).toBeLessThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: 'a' }, { name: 'b' })\n    ).toBeLessThan(0)\n    expect(\n      ProjectRepository.compareVersions(\n        { name: 'z' },\n        { name: '', tags: ['latest'] }\n      )\n    ).toBeLessThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: '0.0.10' }, { name: '0.1.1' })\n    ).toBeLessThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: '0.0.1' }, { name: '0.0.22' })\n    ).toBeLessThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: '0.0.2' }, { name: '0.0.22' })\n    ).toBeLessThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: '0.0.22' }, { name: '0.0.2' })\n    ).toBeGreaterThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: '0.0.3' }, { name: '0.0.22' })\n    ).toBeLessThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: '0.0.2a' }, { name: '0.0.10' })\n    ).toBeLessThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: '1.2.0' }, { name: '1.0' })\n    ).toBeGreaterThan(0)\n    expect(\n      ProjectRepository.compareVersions({ name: '1.2' }, { name: '2.0.0' })\n    ).toBeLessThan(0)\n  })\n})\n\ndescribe('favories', () => {\n  test('should add and remove favourite projects correctly', () => {\n    const project = 'test-project'\n\n    expect(ProjectRepository.isFavorite(project)).toBe(false)\n\n    ProjectRepository.setFavorite(project, false)\n    expect(ProjectRepository.isFavorite(project)).toBe(false)\n\n    ProjectRepository.setFavorite(project, true)\n    expect(ProjectRepository.isFavorite(project)).toBe(true)\n\n    ProjectRepository.setFavorite(project, false)\n    expect(ProjectRepository.isFavorite(project)).toBe(false)\n  })\n})\n\ndescribe('filterHiddenVersions', () => {\n  test('should remove hidden versions', () => {\n    const shownVersion: ProjectDetails = {\n      name: 'v-2',\n      tags: ['stable'],\n      hidden: false,\n      timestamp: new Date()\n    }\n\n    const hiddenVersion: ProjectDetails = {\n      name: 'v-1',\n      tags: ['latest'],\n      hidden: true,\n      timestamp: new Date()\n    }\n\n    const allProjects: Project[] = [\n      {\n        name: 'test-project-1',\n        storage: \"1 MB\",\n        versions: [shownVersion, hiddenVersion],\n        logo: false\n      }\n    ]\n\n    const shownProjects: Project[] = [\n      {\n        name: 'test-project-1',\n        storage: \"1 MB\",\n        versions: [shownVersion],\n        logo: false\n      }\n    ]\n\n    const result = ProjectRepository.filterHiddenVersions(allProjects)\n    expect(result).toStrictEqual(shownProjects)\n  })\n  test('should remove the whole project if no shown versions are present', () => {\n    const allProjects: Project[] = [\n      {\n        name: 'test-project-1',\n        storage: \"1 MB\",\n        versions: [\n          {\n            name: 'v-1',\n            tags: ['latest'],\n            hidden: true,\n            timestamp: new Date()\n          }\n        ],\n        logo: true\n      }\n    ]\n\n    const result = ProjectRepository.filterHiddenVersions(allProjects)\n    expect(result).toStrictEqual([])\n  })\n})\n\ndescribe('getLatestVersion', () => {\n  test('should return latest version by name', () => {\n    const versions: ProjectDetails[] = [\n      {\n        name: '1.0.0',\n        hidden: false,\n        tags: [],\n        timestamp: new Date()\n      },\n      {\n        name: '2.0.0',\n        hidden: false,\n        tags: [],\n        timestamp: new Date()\n      }\n    ]\n\n    const latestVersion = ProjectRepository.getLatestVersion(versions)\n    expect(latestVersion).toStrictEqual(versions[1])\n  })\n\n  test('should return version with latest in name', () => {\n    const versions: ProjectDetails[] = [\n      {\n        name: '1.0.0',\n        hidden: false,\n        tags: [],\n        timestamp: new Date()\n      },\n      {\n        name: 'latest',\n        hidden: false,\n        tags: [],\n        timestamp: new Date()\n      }\n    ]\n\n    const latestVersion = ProjectRepository.getLatestVersion(versions)\n    expect(latestVersion).toStrictEqual(versions[1])\n  })\n\n  test('should return version with latest tag', () => {\n    const versions: ProjectDetails[] = [\n      {\n        name: '1.0.0',\n        hidden: false,\n        tags: ['latest'],\n        timestamp: new Date()\n      },\n      {\n        name: '2.0.0',\n        hidden: false,\n        tags: [],\n        timestamp: new Date()\n      }\n    ]\n\n    const latestVersion = ProjectRepository.getLatestVersion(versions)\n    expect(latestVersion).toStrictEqual(versions[0])\n  })\n\n  test('should prefer version with latest in name over latest tag', () => {\n    const versions: ProjectDetails[] = [\n      {\n        name: 'latest',\n        hidden: false,\n        tags: [],\n        timestamp: new Date()\n      },\n      {\n        name: '1.0.0',\n        hidden: false,\n        tags: ['latest'],\n        timestamp: new Date()\n      }\n    ]\n\n    const latestVersion = ProjectRepository.getLatestVersion(versions)\n    expect(latestVersion).toStrictEqual(versions[0])\n  })\n})\n"
  },
  {
    "path": "web/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"lib\": [\n      \"dom\",\n      \"dom.iterable\",\n      \"esnext\"\n    ],\n    \"allowJs\": false,\n    \"skipLibCheck\": true,\n    \"esModuleInterop\": true,\n    \"allowSyntheticDefaultImports\": true,\n    \"strict\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noFallthroughCasesInSwitch\": true,\n    \"module\": \"esnext\",\n    \"moduleResolution\": \"bundler\",\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"noEmit\": true,\n    \"jsx\": \"react-jsx\",\n    \"types\": [\"vite/client\", \"vitest/globals\"]\n  },\n  \"include\": [\n    \"src\"\n  ]\n}\n"
  },
  {
    "path": "web/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_DOCAT_VERSION: string\n}\n\ninterface ImportMeta {\n  readonly env: ImportMetaEnv\n}\n"
  },
  {
    "path": "web/vite.config.ts",
    "content": "import { defineConfig, loadEnv } from 'vite'\nimport react from '@vitejs/plugin-react'\n\n// https://vitejs.dev/config/\nexport default defineConfig(({ mode }) => {\n  const env = loadEnv(mode, process.cwd())\n  return {\n    base: '/',\n    plugins: [react()],\n    assetsInclude: ['**/*.md'],\n    server: {\n      port: 8080,\n      proxy: {\n        '/api': {\n          target: 'http://localhost:' + `${env.VITE_PROXY_PORT ?? '5000'}`,\n          changeOrigin: true,\n          secure: false\n        },\n        '/doc': {\n          target: 'http://localhost:' + `${env.VITE_PROXY_PORT ?? '5000'}`,\n          changeOrigin: true,\n          secure: false\n        }\n      }\n    },\n    test: {\n      globals: true,\n      environment: 'jsdom',\n      css: true,\n      reporters: ['verbose'],\n      coverage: {\n        reporter: ['text', 'json', 'html'],\n        include: ['src/**/*'],\n        exclude: []\n      }\n    }\n  }\n})\n"
  }
]