diff --git a/.github/labeler.yml b/.github/labeler.yml index b13a9b8d..8dafa72a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -13,15 +13,13 @@ ci-cd: dependencies: - changed-files: - any-glob-to-any-file: - - requirements/*.txt - - requirements.txt + - pyproject.toml documentation: - changed-files: - any-glob-to-any-file: - "*.md" - docs/** - - requirements/documentation.txt - head-branch: - ^docs - documentation @@ -37,7 +35,6 @@ packaging: - changed-files: - any-glob-to-any-file: - MANIFEST.in - - setup.py - head-branch: - ^packaging - packaging @@ -52,7 +49,6 @@ tooling: - any-glob-to-any-file: - ".*" - codecov.yml - - setup.cfg - .vscode/**/* - head-branch: - ^tooling diff --git a/.github/release.yml b/.github/release.yml index 70c11e03..7b78f6e6 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -1,8 +1,8 @@ changelog: exclude: authors: - - dependabot - - pre-commit-ci + - dependabot[bot] + - pre-commit-ci[bot] categories: - title: Bugs fixes 🐛 labels: diff --git a/.github/workflows/docker-test.yml b/.github/workflows/docker-test.yml index d1cf1e56..3fbf81ca 100644 --- a/.github/workflows/docker-test.yml +++ b/.github/workflows/docker-test.yml @@ -4,19 +4,26 @@ name: "🐳 Docker Builder" # events but only for the master branch on: pull_request: - branches: [main] + branches: + - main paths-ignore: - "docs/**" push: - branches: [main] + branches: + - main paths-ignore: - "docs/**" +# Sets permissions of the GITHUB_TOKEN +permissions: + contents: read + + jobs: docker-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Test Docker Build run: | diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index fbcb71fc..e11f8ee9 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -5,30 +5,36 @@ name: "📚 Documentation" # events but only for the master branch on: push: - branches: [main] + branches: + - main paths: - "docs/**" - "*.md" - ".github/workflows/documentation.yml" + - ./mkdocs_rss_plugin + - pyproject.toml tags: - "*" pull_request: - branches: [main] + branches: + - main paths: - .github/workflows/documentation.yml - docs/ - - requirements/documentation.txt + - pyproject.toml + + workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read - pages: write id-token: write + pages: write # Allow one concurrent deployment concurrency: - group: "pages" + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true # A workflow run is made up of one or more jobs that can run sequentially or in parallel @@ -41,22 +47,22 @@ jobs: # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Get source code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" cache: "pip" - cache-dependency-path: "requirements/documentation.txt" + cache-dependency-path: pyproject.toml - name: Install dependencies - run: | - python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade -r requirements.txt - python -m pip install --upgrade -r requirements/documentation.txt + run: python -m pip install --upgrade pip setuptools wheel + + - name: Install project as a package + run: python -m pip install .[docs] - name: Build static website env: @@ -69,7 +75,7 @@ jobs: run: mkdocs build --verbose - name: Save build doc as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: documentation path: site/ @@ -77,11 +83,11 @@ jobs: retention-days: 30 - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@v6 if: github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main') - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@v4 if: github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main') with: # Upload entire repository @@ -90,4 +96,4 @@ jobs: - name: Deploy to GitHub Pages id: deployment if: github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main') - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/lint-and-tests.yml b/.github/workflows/lint-and-tests.yml index 1bcf8ea2..37495729 100644 --- a/.github/workflows/lint-and-tests.yml +++ b/.github/workflows/lint-and-tests.yml @@ -15,6 +15,10 @@ on: paths-ignore: - "docs/**" +# Sets permissions of the GITHUB_TOKEN +permissions: + contents: read + jobs: lintest: runs-on: ubuntu-latest @@ -22,46 +26,35 @@ jobs: fail-fast: false matrix: python-version: - - "3.8" - - "3.9" - "3.10" - "3.11" - "3.12" + - "3.13" + - "3.14" # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Get source code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: "pip" - cache-dependency-path: "requirements/*.txt" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade -r requirements.txt - python -m pip install --upgrade -r requirements/development.txt - python -m pip install --upgrade -r requirements/testing.txt + cache-dependency-path: pyproject.toml - - name: Lint with flake8 - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Upgrade installation dependencies + run: python -m pip install --upgrade pip setuptools wheel - - name: Install project - run: python -m pip install -e . + - name: Install project as a package + run: python -m pip install .[test] - name: Run Unit tests run: python -m pytest - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v6.0.0 with: env_vars: PYTHON flags: unittests diff --git a/.github/workflows/pr-auto-labeler.yml b/.github/workflows/pr-auto-labeler.yml index 03c703a8..61899f2c 100644 --- a/.github/workflows/pr-auto-labeler.yml +++ b/.github/workflows/pr-auto-labeler.yml @@ -2,11 +2,15 @@ name: "🏷 PR Labeler" on: - pull_request_target +permissions: + contents: read + pull-requests: write + jobs: triage: runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 + - uses: actions/labeler@v6 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" sync-labels: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7092767..4f0df4ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,23 +16,22 @@ jobs: url: https://pypi.org/project/mkdocs-rss-plugin/ permissions: contents: write - id-token: write # IMPORTANT: this permission is mandatory for trusted publishing discussions: write + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: - python-version: "3.x" + python-version: "3.13" cache: "pip" - cache-dependency-path: "requirements/base.txt" + cache-dependency-path: pyproject.toml - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel - python -m pip install --upgrade -r requirements.txt python -m pip install --upgrade build - name: Build a binary wheel and a source tarball @@ -44,7 +43,7 @@ jobs: --outdir dist/ . - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: python_wheel path: dist/* diff --git a/.gitignore b/.gitignore index f1947752..1fc11861 100644 --- a/.gitignore +++ b/.gitignore @@ -216,5 +216,6 @@ dmypy.json # ################################ **/*.xml +mkdocs_rss_plugin/_version.py site/ tests/fixtures/docs/temp_page_not_in_git_log.md diff --git a/.mailmap b/.mailmap index 7f09a1de..c1a23db6 100644 --- a/.mailmap +++ b/.mailmap @@ -1,8 +1,14 @@ +Brian Madden +Brian Madden + Dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> <27856297+dependabot-preview[bot]@users.noreply.github.com> Julien M. Julien M. +Julien M. <1596222+Guts@users.noreply.github.com> + +Meysam Azad Y.D.X. <73375426+YDX-2147483647@users.noreply.github.com> Y.D.X. <73375426+YDX-2147483647@users.noreply.github.com> <73375426+YDX-2147483647@users.noreply.github.com> diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e258068d..83917d22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: ".venv|.direnv|tests/dev/|tests/fixtures/" fail_fast: false repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: check-added-large-files args: @@ -18,9 +18,6 @@ repos: - id: detect-private-key - id: end-of-file-fixer - id: fix-byte-order-marker - - id: fix-encoding-pragma - args: - - --remove - id: trailing-whitespace args: - --markdown-linebreak-ext=md @@ -28,25 +25,28 @@ repos: - repo: https://github.com/pre-commit/pygrep-hooks rev: v1.10.0 hooks: + - id: python-check-blanket-noqa + - id: python-no-log-warn - id: python-use-type-annotations + - id: text-unicode-replacement-char - repo: https://github.com/asottile/pyupgrade - rev: v3.16.0 + rev: v3.21.2 hooks: - id: pyupgrade args: - - "--py38-plus" + - "--py310-plus" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.5.0" + rev: "v0.15.11" hooks: - - id: ruff + - id: ruff-check args: - --fix-only - - --target-version=py38 + - --target-version=py310 - repo: https://github.com/pycqa/isort - rev: 5.13.2 + rev: 8.0.1 hooks: - id: isort args: @@ -54,22 +54,21 @@ repos: - black - --filter-files - - repo: https://github.com/psf/black - rev: 24.4.2 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 26.3.1 hooks: - id: black args: - - --target-version=py38 + - --target-version=py310 - repo: https://github.com/pycqa/flake8 - rev: 7.1.0 + rev: 7.3.0 hooks: - id: flake8 additional_dependencies: - flake8-docstrings<2 language: python args: - - --config=setup.cfg - --select=E9,F63,F7,F82 - --docstring-convention=google diff --git a/CHANGELOG.md b/CHANGELOG.md index 2976fd7e..c3eee4ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,164 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> +## 1.19.0 - 2026-04-17 + +### Features and enhancements 🎉 + +* add(integrations): support integrations with MaterialX theme, fork of Material for Mkdocs by @Guts in + +### Other Changes + +* Quality: customize ruff config, extend git hooks and apply changes by @Guts in + +## 1.18.1 - 2026-04-10 + +### Features and enhancements 🎉 + +* Improve: stylesheet render after first feedbacks by @Guts in + +## 1.18.0 - 2026-04-09 + +### Features and enhancements 🎉 + +* feature(rss): add option to set a stylesheet by @Guts in +* update(packaging): declare plugin compatible with ProperDocs by @Guts in +* update(packaging): support Python 3.14 by @Guts in + +## 1.17.9 - 2026-01-05 + +### Bugs fixes 🐛 + +* fix(integration): Material Social Card URI was wrong for Material blog posts when diretcory URL are disabled at Mkdocs level by @Guts in + +## 1.17.8 - 2026-01-05 + +### Bugs fixes 🐛 + +* fix(tests): use icon from GH repository since Wikimedia returns HTTP 429 by @Guts in +* fix(integration): Material Social Card URI was wrong for Material blog posts by @Guts in + +## 1.17.7 - 2025-11-14 + +### Bugs fixes 🐛 + +* fix(integration): imports from Material for Mkdocs blog plugin for type hint was breaking the build when using another theme by @Guts in + +## 1.17.6 - 2025-11-13 + +### Bugs fixes 🐛 + +* update(chore): bump minimal version of Material for Mkdocs since 9.7.x and fix related bugs by @Guts in +* improve(integrations): refine some logs about Material integrations by @Guts in + +## 1.17.5 - 2025-11-07 + +### Bugs fixes 🐛 + +* fix(xml): escape special chars from site_author (``) by @Guts in + +### Features and enhancements 🎉 + +* improve(performances): run RSS item image fetching only on filtered pages list by @Guts in + +### Tooling 🔧 + +* Packaging: switch to pyproject by @Guts in +* update(security): set GH token permissions scopes in CI workflows by @Guts in + +### Other Changes + +* Packaging/remove-python-3_9-add-3_14 by @Guts in +* fix(packaging): rollback support for Python 3.14 by @Guts in + +## 1.17.4 - 2025-10-10 + +### Bugs fixes 🐛 + +* fix(logs): update references hyperlinks for git depth logs by @Guts in +* fix(material): mkdocs_config.plugins.get("material/blog") can be None by @Guts in +* fix: preserve sentence structure across newlines by @miketheman in + +### Features and enhancements 🎉 + +* improve(images): add timeout to remote images requests by @Guts in + +### New Contributors + +* @miketheman made their first contribution in + +## 1.17.3 - 2025-05-30 + +### Bugs fixes 🐛 + +* fix: return None if remote image length is unavailable by @lukehsiao in + +### New Contributors + +* @lukehsiao made their first contribution in + +## 1.17.2 - 2025-05-23 + +### Bugs fixes 🐛 + +* fix(upstream): force jsonfeed-util version since it uses a non Python 3.9 syntax, breaking lint and tests by @Guts in +* Docs: fix mkdocstings config and improve api autodoc by @Guts in +* fix(material_social): use cards_dir to build cards url for page by @kanru in + +### Other Changes + +* Revert 355: restore minimal JSON Feed minimal version by @Guts in + +### New Contributors + +* @kanru made their first contribution in + +## 1.17.1 - 2024-12-16 + +### Bugs fixes 🐛 + +* fix(rss): email and name were inverted in output and so uncompliant by @Guts in . Thanks @stefansli for the floow up in . + +### Documentation 📖 + +* update(docs): move integrations page as independant menu by @Guts in + +## 1.17.0 - 2024-12-02 + +### Features and enhancements 🎉 + +* refacto(material_integrations): use POO to manage integrations with Material theme framework by @Guts in +* update(chore): use typed dataclasses instead of dict by @Guts in +* feature(integration): make the integration with Material Blog configurable through an option by @Guts in +* Feature: use author name and email from `.authors.yml` set up in Material Blog by @Guts in + +### Tooling 🔧 + +* update(ci): enable manual trigger for build/publish doc by @Guts in + +## 1.16.0 - 2024-10-24 + +### Bugs fixes 🐛 + +* Make match_path OS agnostic by @mvelikikh in + +### Features and enhancements 🎉 + +* feature(integration): support social cards for blog plugin posts by @Guts in + +### Documentation 📖 + +* Remove social options from example by @Andre601 in + +### Other Changes + +* Packaging: drop python 3 8, add 3.13 support by @Guts in + +## New Contributors + +* @Andre601 made their first contribution in +* @mvelikikh made their first contribution in + ## 1.15.0 - 2024-07-03 ### Features and enhancements 🎉 diff --git a/README.md b/README.md index 83b83001..a6284525 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![PyPI - Downloads](https://img.shields.io/pypi/dm/mkdocs-rss-plugin)](https://pypi.org/project/mkdocs-rss-plugin/) [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mkdocs-rss-plugin)](https://pypi.org/project/mkdocs-rss-plugin/) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Guts_mkdocs-rss-plugin&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Guts_mkdocs-rss-plugin) [![codecov](https://codecov.io/gh/Guts/mkdocs-rss-plugin/branch/main/graph/badge.svg?token=A0XPLKiwiW)](https://codecov.io/gh/Guts/mkdocs-rss-plugin) [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) [![flake8](https://img.shields.io/badge/linter-flake8-green)](https://flake8.pycqa.org/) @@ -62,7 +63,7 @@ plugins: rss_updated: feed_rss_updated.xml feed_title: "My custom feed title" # MkDocs site_name: will be used if this key is not present feed_ttl: 1440 - image: https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png + image: https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true json_feed_enabled: true length: 20 match_path: ".*" @@ -73,6 +74,7 @@ plugins: utm_medium: "RSS" utm_campaign: "feed-syndication" use_git: true + use_material_blog: true use_material_social_cards: true ``` @@ -82,16 +84,24 @@ Following initiative from the author of Material for MkDocs, this plugin provide ## Development -Clone the repository: +Once you cloned the repository: ```sh -# install development dependencies -python -m pip install -U -r requirements/development.txt -# alternatively: pip install -e .[dev] - # install project as editable python -m pip install -e . +# including development dependencies +python -m pip install -e .[dev] + +# including documentation dependencies +python -m pip install -e .[docs] + +# including testing dependencies +python -m pip install -e .[test] + +# all inclusive +python -m pip install -e .[dev,docs,test] + # install git hooks pre-commit install ``` @@ -102,8 +112,7 @@ Then follow the [contribution guidelines](CONTRIBUTING.md). ```sh # install development dependencies -python -m pip install -U -r requirements/testing.txt -# alternatively: pip install -e .[test] +python -m pip install -e .[test] # run tests pytest @@ -113,8 +122,7 @@ pytest ```sh # install dependencies for documentation -python -m pip install -U -r requirements/documentation.txt -# alternatively: pip install -e .[doc] +python -m pip install -e .[docs] # build the documentation mkdocs build diff --git a/docs/api.md b/docs/api.md index 0304646a..a355dc8c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -12,18 +12,32 @@ icon: fontawesome/solid/code ::: mkdocs_rss_plugin.config.RssPluginConfig +::: mkdocs_rss_plugin.config._DateFromMeta + +::: mkdocs_rss_plugin.config._FeedsFilenamesConfig + ---- ::: mkdocs_rss_plugin.constants ----- +## Models ::: mkdocs_rss_plugin.models.PageInformation ----- - -::: mkdocs_rss_plugin.util.Util +::: mkdocs_rss_plugin.models.RssFeedBase ## Integrations +::: mkdocs_rss_plugin.integrations.theme_material_base.IntegrationMaterialThemeBase + +::: mkdocs_rss_plugin.integrations.theme_material_blog_plugin.IntegrationMaterialBlog + ::: mkdocs_rss_plugin.integrations.theme_material_social_plugin.IntegrationMaterialSocialCards + +## Utils + +::: mkdocs_rss_plugin.git_manager.ci.CiHandler + +::: mkdocs_rss_plugin.timezoner + +::: mkdocs_rss_plugin.util.Util diff --git a/docs/configuration.md b/docs/configuration.md index e4a71204..c95b99da 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -189,10 +189,6 @@ To customize the value of the RSS description per each page and override the val date: 2024-06-24 description: >- This is the SEO description. -social: - cards_layout_options: - description: >- - This is the social cards description. rss: feed_description: >- And I want to have customized RSS description. @@ -403,9 +399,7 @@ At the end, into the RSS you will get: ``` !!! note "Timezone dependencies" - The timezones data depends on the Python version used to build: - - for Python >= 3.9, it uses the standard library and ships [tzdata](https://pypi.org/project/tzdata/) only on Windows which do not provide such data - - for Python < 3.9, [pytz](https://pypi.org/project/pytz/) is shipped. + The timezones data relies on the standard library and ships [tzdata](https://pypi.org/project/tzdata/) only on Windows which do not provide such data. ---- @@ -495,7 +489,7 @@ Default: `None`. ```yaml plugins: - rss: - image: https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png + image: https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true ``` Output: @@ -503,7 +497,7 @@ Output: ```xml - https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png + https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true MkDocs RSS Plugin https://guts.github.io/mkdocs-rss-plugin/ @@ -556,6 +550,38 @@ Default: `False`. ---- +### :material-brush-variant: `stylesheet`: define a XSL stylesheet { #stylesheet } + +Use a XSL stylesheet to customize how the RSS feed looks like. `auto` is a special value to use the stylesheet shipped with the plugin. + +```yaml +plugins: + - rss: + stylesheet: auto +``` + +If you're willing to work on your own stylesheet, set the URL or relative path: + +```yaml +plugins: + - rss: + stylesheet: https://docs.example.org/rss_stylesheet.xsl +``` + +Note that it's recommended to host the stylesheet on the same website / domain to make CORS happy. + +If you prefer to disable the stylesheet, you set it to `""`: + +```yaml +plugins: + - rss: + stylesheet: "" +``` + +Default: `auto`. + +---- + ### :material-track-light: `url_parameters`: additional URL parameters { #url_parameters } This option allows you to add parameters to the URLs of the RSS feed items. It works as a dictionary of keys/values that is passed to [Python *urllib.parse.urlencode*](https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode). @@ -600,6 +626,16 @@ Default: `true`. ---- +### :material-newspaper-variant-outline: `use_material_blog`: enable/disable integration with Material Blog plugin { #use_material_blog } + +If `false`, the integration with the Blog plugin is disabled. + +Default: `true`. + +> See [the related section in integrations page](./integrations.md#blog-plugin-from-material-theme). + +---- + ### :material-cards: `use_material_social_cards`: enable/disable integration with Material Social Cards plugin { #use_material_social_cards } If `false`, the integration with Social Cards is disabled. diff --git a/docs/contributing.md b/docs/contributing.md index 32121529..347a63d5 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -12,16 +12,24 @@ These are mostly guidelines, not rules. Use your best judgment, and feel free to ## Development -Clone the repository: +Once you cloned the repository: ```sh -# install development dependencies -python -m pip install -U -r requirements/development.txt -# alternatively: pip install -e .[dev] - # install project as editable python -m pip install -e . +# including development dependencies +python -m pip install -e .[dev] + +# including documentation dependencies +python -m pip install -e .[docs] + +# including testing dependencies +python -m pip install -e .[test] + +# all inclusive +python -m pip install -e .[dev,docs,test] + # install git hooks pre-commit install ``` @@ -32,8 +40,7 @@ Then follow the [contribution guidelines](#guidelines). ```sh # install development dependencies -python -m pip install -U -r requirements/testing.txt -# alternatively: pip install -e .[test] +python -m pip install -e .[test] # run tests pytest @@ -43,8 +50,7 @@ pytest ```sh # install dependencies for documentation -python -m pip install -U -r requirements/documentation.txt -# alternatively: pip install -e .[doc] +python -m pip install -e .[docs] # build the documentation mkdocs build @@ -90,10 +96,15 @@ Feel free to use the IDE you love. Here come configurations for some popular IDE ```jsonc { - // Editor - "files.associations": { - "./requirements/*.txt": "pip-requirements" + // JSON + "[json]": { + "editor.bracketPairColorization.enabled": true, + "editor.defaultFormatter": "vscode.json-language-features", + "editor.formatOnSave": true, + "editor.guides.bracketPairs": "active" }, + "json.format.enable": true, + "json.schemaDownload.enable": true, // Markdown "markdown.updateLinksOnFileMove.enabled": "prompt", "markdown.updateLinksOnFileMove.enableForDirectories": true, @@ -104,9 +115,16 @@ Feel free to use the IDE you love. Here come configurations for some popular IDE "editor.bracketPairColorization.enabled": true, "editor.formatOnSave": true, "editor.guides.bracketPairs": "active", - "files.trimTrailingWhitespace": false, + "files.trimTrailingWhitespace": false }, // Python + "python.analysis.autoFormatStrings": true, + "python.analysis.autoImportCompletions": true, + "python.analysis.typeCheckingMode": "basic", + "python.terminal.activateEnvInCurrentTerminal": true, + "python.terminal.activateEnvironment": true, + "python.testing.unittestEnabled": true, + "python.testing.pytestEnabled": true, "[python]": { "editor.codeActionsOnSave": { "source.organizeImports": "explicit" @@ -119,9 +137,24 @@ Feel free to use the IDE you love. Here come configurations for some popular IDE ], "editor.wordWrapColumn": 88, }, - // Extensions + // YAML + "[yaml]": { + "editor.autoIndent": "keep", + "editor.formatOnSave": true, + "editor.insertSpaces": true, + "editor.tabSize": 2, + "diffEditor.ignoreTrimWhitespace": false, + "editor.quickSuggestions": { + "other": true, + "comments": false, + "strings": true + } + }, + // extensions + "autoDocstring.guessTypes": true, + "autoDocstring.docstringFormat": "google-notypes", + "autoDocstring.generateDocstringOnEnter": false, "flake8.args": [ - "--config=setup.cfg", "--verbose" ], "isort.args": [ @@ -129,15 +162,13 @@ Feel free to use the IDE you love. Here come configurations for some popular IDE "black" ], "isort.check": true, - "autoDocstring.guessTypes": true, - "autoDocstring.docstringFormat": "google", - "autoDocstring.generateDocstringOnEnter": false, "yaml.customTags": [ "!ENV scalar", "!ENV sequence", - "tag:yaml.org,2002:python/name:materialx.emoji.to_svg", - "tag:yaml.org,2002:python/name:materialx.emoji.twemoji", + "!relative scalar", + "tag:yaml.org,2002:python/name:material.extensions.emoji.to_svg", + "tag:yaml.org,2002:python/name:material.extensions.emoji.twemoji", "tag:yaml.org,2002:python/name:pymdownx.superfences.fence_code_format" - ], + ] } ``` diff --git a/docs/integrations.md b/docs/integrations.md index 5d4997ff..b4f4a09d 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -3,6 +3,68 @@ title: Integrations icon: octicons/plug-16 --- +Since version 1.19, the plugin supports both [Material for Mkdocs](https://squidfunk.github.io/mkdocs-material/) (which is in maintenance mode since Nov 11, 2025 and until November 2026) and its fork [MaterialX](https://jaywhj.github.io/mkdocs-materialx/). + +## Blog plugin (from Material theme) + +Since version 1.17, the plugin integrates with the [Blog plugin (shipped with Material theme)](https://squidfunk.github.io/mkdocs-material/plugins/blog/) (see also [the tutorial about blog + RSS plugins](https://squidfunk.github.io/mkdocs-material/tutorials/blogs/engage/)). + +In some cases, the RSS plugin needs to work with the Material Blog: + +- for blog posts, the structure of the path to social cards is depending on blog configuration +- retrieve the author's name from the `.authors.yml` file +- optionnaly retrieve the author's email from the `.authors.yml` file + +If you don't want this integration, you can disable it with the option: `use_material_blog=false`. + +> See [related section in settings](./configuration.md#use_material_blog). + +### Example of blog authors with email + +```yaml title="docs/blog/.authors.yml" +authors: + alexvoss: + name: Alex Voss + description: Weltenwanderer + avatar: https://github.com/alexvoss.png + guts: + avatar: https://cdn.geotribu.fr/img/internal/contributeurs/jmou.jfif + description: GIS Watchman + name: Julien Moura + url: https://github.com/guts/ + email: joe@biden.com +``` + +This given Markdown post: + +```markdown title="blog/posts/demo.md" +--- +authors: + - alexvoss + - guts +date: 2024-12-02 +categories: + - tutorial +--- + +# Demonstration blog post + +[...] +``` + +Will be rendered as: + +```xml title="/build/site/feed_rss_created.xml" +[...] + + Demonstration blog post + Alex Voss + Julien Moura (joe@biden.com) +[...] +``` + +---- + ## Social Cards plugin (from Material theme) Since version 1.10, the plugin integrates with the [Social Cards plugin (shipped with Material theme)](https://squidfunk.github.io/mkdocs-material/setup/setting-up-social-cards/) (see also [the full plugin documentation here](https://squidfunk.github.io/mkdocs-material/plugins/social/)). diff --git a/mkdocs.yml b/mkdocs.yml index 6a516d7b..34a4df07 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,8 +22,11 @@ extra: link: https://mapstodon.space/@geojulien name: "Follow me on Mastodon" - icon: "fontawesome/solid/piggy-bank" - link: /team/sponsoring/ - name: "Sponsor development" + link: https://github.com/sponsors/Guts + name: "Sponsor development with Github" + - icon: "simple/liberapay" + link: https://liberapay.com/GeoJulien + name: "Sponsor development with Liberapay" # Extensions to enhance markdown markdown_extensions: @@ -56,9 +59,8 @@ markdown_extensions: nav: - Home: index.md - - Settings: - - configuration.md - - integrations.md + - Settings: configuration.md + - integrations.md - Contributing: contributing.md - API: api.md - Changelog: changelog.md @@ -89,13 +91,17 @@ plugins: handlers: python: options: + docstring_options: + ignore_init_summary: false + trim_doctest_flags: true docstring_style: google + find_stubs_package: true heading_level: 3 - ignore_init_summary: false + show_bases: true + show_inheritance_diagram: true + show_root_heading: true show_source: false - merge_init_into_class: true - paths: [.] - show_root_heading: true + merge_init_into_class: true - privacy: enabled: !ENV [MKDOCS_ENABLE_PLUGIN_PRIVACY, true] @@ -110,11 +116,12 @@ plugins: default_timezone: "Europe/Paris" default_time: "22:00" enabled: !ENV [MKDOCS_ENABLE_PLUGIN_RSS, true] - image: https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png + image: https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true json_feed_enabled: true match_path: ".*" pretty_print: true rss_feed_enabled: true + stylesheet: auto url_parameters: utm_source: "documentation" utm_medium: "RSS" diff --git a/mkdocs_rss_plugin/__about__.py b/mkdocs_rss_plugin/__about__.py index ec40fea1..30b2b286 100644 --- a/mkdocs_rss_plugin/__about__.py +++ b/mkdocs_rss_plugin/__about__.py @@ -12,27 +12,25 @@ # standard library from datetime import date +from importlib import metadata + +_pkg_metadata = metadata.metadata("mkdocs-rss-plugin") or {} + +try: + from ._version import version as __version__ +except ImportError: + __version__ = _pkg_metadata.get("Version", "0.0.0-dev0") + # ############################################################################ # ########## Globals ############# # ################################ -__all__ = [ - "__author__", - "__copyright__", - "__email__", - "__license__", - "__summary__", - "__title__", - "__title_clean__", - "__uri__", - "__version__", - "__version_info__", -] -__author__ = "Julien Moura" + +__author__: str = _pkg_metadata.get("Author-email", "Julien Moura (In Geo Veritas)") __copyright__ = f"2020 - {date.today().year}, {__author__}" __email__ = "dev@ingeoveritas.com" -__license__ = "MIT" +__license__: str = _pkg_metadata.get("License-Expression", "MIT") __summary__ = ( "MkDocs plugin which generates a static RSS feed using git log and page.meta." ) @@ -40,10 +38,24 @@ __title_clean__ = "".join(e for e in __title__ if e.isalnum()) __uri__ = "https://github.com/Guts/mkdocs-rss-plugin/" -__version__ = "1.15.0" +__version_clean__: str = __version__.split(".dev")[0].split("+")[0] __version_info__ = tuple( [ int(num) if num.isdigit() else num for num in __version__.replace("-", ".", 1).split(".") ] ) + + +__all__ = [ + "__author__", + "__copyright__", + "__email__", + "__license__", + "__summary__", + "__title__", + "__title_clean__", + "__uri__", + "__version__", + "__version_info__", +] diff --git a/mkdocs_rss_plugin/config.py b/mkdocs_rss_plugin/config.py index 25317280..8f0327f4 100644 --- a/mkdocs_rss_plugin/config.py +++ b/mkdocs_rss_plugin/config.py @@ -58,6 +58,8 @@ class RssPluginConfig(Config): match_path = config_options.Type(str, default=".*") pretty_print = config_options.Type(bool, default=False) rss_feed_enabled = config_options.Type(bool, default=True) + stylesheet = config_options.Type(str, default="auto") url_parameters = config_options.Optional(config_options.Type(dict)) use_git = config_options.Type(bool, default=True) + use_material_blog = config_options.Type(bool, default=True) use_material_social_cards = config_options.Type(bool, default=True) diff --git a/mkdocs_rss_plugin/constants.py b/mkdocs_rss_plugin/constants.py index 971802b2..9eae3031 100644 --- a/mkdocs_rss_plugin/constants.py +++ b/mkdocs_rss_plugin/constants.py @@ -14,11 +14,11 @@ # ########## Globals ############# # ################################ -DEFAULT_CACHE_FOLDER = Path(".cache/plugins/rss") -DEFAULT_TEMPLATE_FOLDER = Path(__file__).parent / "templates" -DEFAULT_TEMPLATE_FILENAME = DEFAULT_TEMPLATE_FOLDER / "rss.xml.jinja2" -MKDOCS_LOGGER_NAME = "[RSS-plugin]" -REMOTE_REQUEST_HEADERS = { +DEFAULT_CACHE_FOLDER: Path = Path(".cache/plugins/rss") +DEFAULT_TEMPLATE_FOLDER: Path = Path(__file__).parent / "templates" +DEFAULT_TEMPLATE_FILENAME: Path = DEFAULT_TEMPLATE_FOLDER / "rss.xml.jinja2" +MKDOCS_LOGGER_NAME: str = "[RSS-plugin]" +REMOTE_REQUEST_HEADERS: dict[str, str] = { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "User-Agent": f"{__about__.__title__}/{__about__.__version__}", } diff --git a/mkdocs_rss_plugin/git_manager/ci.py b/mkdocs_rss_plugin/git_manager/ci.py index ea220bd3..a688c354 100644 --- a/mkdocs_rss_plugin/git_manager/ci.py +++ b/mkdocs_rss_plugin/git_manager/ci.py @@ -5,7 +5,8 @@ # ################################## # standard library -from os import environ, path +from os import environ +from pathlib import Path # 3rd party from git import Git @@ -27,7 +28,14 @@ class CiHandler: - def __init__(self, repo: Git): + """Helper class to handle CI specific warnings.""" + + def __init__(self, repo: Git) -> None: + """Initialize the CI handler. + + Args: + repo (Git): Git repository object + """ self.repo = repo def raise_ci_warnings(self) -> None: @@ -40,52 +48,44 @@ def raise_ci_warnings(self) -> None: # Gitlab Runners if environ.get("GITLAB_CI") and n_commits < 50: # Default is GIT_DEPTH of 50 for gitlab - logger.info( - """ + logger.info(""" Running on a gitlab runner might lead to wrong \ git revision dates due to a shallow git fetch depth. \ Make sure to set GIT_DEPTH to 1000 in your .gitlab-ci.yml file. \ - (see https://docs.gitlab.com/ee/user/project/pipelines/settings.html#git-shallow-clone). - """ - ) + (see https://docs.gitlab.com/user/project/repository/monorepos/#use-shallow-clones-in-cicd-processes). + """) # Github Actions if environ.get("GITHUB_ACTIONS") and n_commits == 1: # Default is fetch-depth of 1 for github actions - logger.info( - """ + logger.info(""" Running on github actions might lead to wrong \ git revision dates due to a shallow git fetch depth. \ Try setting fetch-depth to 0 in your github action \ - (see https://github.com/actions/checkout). - """ - ) + (see https://github.com/actions/checkout?tab=readme-ov-file#fetch-all-history-for-all-tags-and-branches). + """) # Bitbucket pipelines if environ.get("CI") and n_commits < 50: # Default is fetch-depth of 50 for bitbucket pipelines - logger.info( - """ + logger.info(""" Running on bitbucket pipelines might lead to wrong \ git revision dates due to a shallow git fetch depth. \ Try setting "clone: depth" to "full" in your pipeline \ - (see https://support.atlassian.com/bitbucket-cloud/docs/configure-bitbucket-pipelinesyml/ + (see https://support.atlassian.com/bitbucket-cloud/docs/git-clone-behavior/#Depth and search 'depth'). - """ - ) + """) # Azure Devops Pipeline # Does not limit fetch-depth by default if environ.get("Agent.Source.Git.ShallowFetchDepth", 10e99) < n_commits: - logger.info( - """ + logger.info(""" Running on Azure pipelines \ with limited fetch-depth might lead to wrong git revision dates \ due to a shallow git fetch depth. \ Remove any Shallow Fetch settings \ (see https://docs.microsoft.com/en-us/azure/devops/pipelines/repos/pipeline-options-for-git?view=azure-devops#shallow-fetch). - """ - ) + """) def commit_count(self) -> int: """Helper function to determine the number of commits in a repository. @@ -112,4 +112,4 @@ def is_shallow_clone(self) -> bool: Returns: bool: True if a repo is shallow clone """ - return path.exists(".git/shallow") + return Path(".git/shallow").exists() diff --git a/mkdocs_rss_plugin/integrations/theme_material_base.py b/mkdocs_rss_plugin/integrations/theme_material_base.py new file mode 100644 index 00000000..dc881ab0 --- /dev/null +++ b/mkdocs_rss_plugin/integrations/theme_material_base.py @@ -0,0 +1,77 @@ +#! python3 # noqa: E265 + +# ############################################################################ +# ########## Libraries ############# +# ################################## + +# 3rd party +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.plugins import get_plugin_logger + +# package +from mkdocs_rss_plugin.constants import MKDOCS_LOGGER_NAME + +# conditional +try: + from material import __version__ as material_version + +except ImportError: + material_version = None + + +# ############################################################################ +# ########## Globals ############# +# ################################ + +logger = get_plugin_logger(MKDOCS_LOGGER_NAME) + +# ############################################################################ +# ########## Logic ############### +# ################################ + + +class IntegrationMaterialThemeBase: + # attributes + IS_THEME_MATERIAL: bool = False + THEME_NAME: str = "mkdocs" + + def __init__(self, mkdocs_config: MkDocsConfig) -> None: + """Integration instantiation. + + Args: + mkdocs_config (MkDocsConfig): Mkdocs website configuration object. + """ + # store Mkdocs config as attribute + self.mkdocs_config = mkdocs_config + + self.IS_THEME_MATERIAL = self.is_mkdocs_theme_material() + + def is_mkdocs_theme_material( + self, mkdocs_config: MkDocsConfig | None = None + ) -> bool: + """Check if the theme set in mkdocs.yml is material or not. + + Args: + mkdocs_config (Optional[MkDocsConfig]): Mkdocs website configuration object. + + Returns: + bool: True if the theme's name is 'material' or 'materialx'. False if not. + """ + if mkdocs_config is None and isinstance(self.mkdocs_config, MkDocsConfig): + mkdocs_config: MkDocsConfig = self.mkdocs_config + + if isinstance(mkdocs_config, MkDocsConfig): + self.THEME_NAME = ( + mkdocs_config.theme.name if mkdocs_config.theme else "mkdocs" + ) + self.IS_THEME_MATERIAL = mkdocs_config.theme.name in ( + "material", + "materialx", + ) + return self.IS_THEME_MATERIAL + + logger.warning( + "Cannot check if the theme is Material or not because the MkDocs " + "configuration object is not available." + ) + return False diff --git a/mkdocs_rss_plugin/integrations/theme_material_blog_plugin.py b/mkdocs_rss_plugin/integrations/theme_material_blog_plugin.py new file mode 100644 index 00000000..03dade9e --- /dev/null +++ b/mkdocs_rss_plugin/integrations/theme_material_blog_plugin.py @@ -0,0 +1,159 @@ +#! python3 # noqa: E265 + +# ############################################################################ +# ########## Libraries ############# +# ################################## + +# standard library +from functools import lru_cache +from pathlib import Path +from typing import Union + +# 3rd party +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.plugins import get_plugin_logger + +# package +from mkdocs_rss_plugin.constants import MKDOCS_LOGGER_NAME +from mkdocs_rss_plugin.integrations.theme_material_base import ( + IntegrationMaterialThemeBase, +) +from mkdocs_rss_plugin.models import MkdocsPageSubset + +# conditional +try: + from material import __version__ as material_version + from material.plugins.blog.plugin import BlogPlugin + from material.plugins.blog.structure import Post + +except ImportError: + material_version = BlogPlugin = Post = None + + +# ############################################################################ +# ########## Globals ############# +# ################################ + +logger = get_plugin_logger(MKDOCS_LOGGER_NAME) + +# ############################################################################ +# ########## Logic ############### +# ################################ + + +class IntegrationMaterialBlog(IntegrationMaterialThemeBase): + # attributes + IS_ENABLED: bool = True + IS_BLOG_PLUGIN_ENABLED: bool = True + + def __init__(self, mkdocs_config: MkDocsConfig, switch_force: bool = True) -> None: + """Integration instantiation. + + Args: + mkdocs_config (MkDocsConfig): Mkdocs website configuration object. + switch_force (bool, optional): option to force integration disabling. Set + it to False to disable it even if Social Cards are enabled in Mkdocs + configuration. Defaults to True. + """ + # check if the integration can be enabled or not + self.IS_BLOG_PLUGIN_ENABLED = self.is_blog_plugin_enabled_mkdocs( + mkdocs_config=mkdocs_config + ) + # if every conditions are True, enable the integration + self.IS_ENABLED = all([self.IS_THEME_MATERIAL, self.IS_BLOG_PLUGIN_ENABLED]) + + # except if the end-user wants to disable it + if switch_force is False: + self.IS_ENABLED = False + logger.debug( + "Integration with Blog (Material theme) is " + "disabled in plugin's option in Mkdocs configuration." + ) + + def is_blog_plugin_enabled_mkdocs(self, mkdocs_config: MkDocsConfig | None) -> bool: + """Check if blog plugin is installed and enabled. + + Args: + mkdocs_config (Optional[MkDocsConfig]): Mkdocs website configuration object. + + Returns: + bool: True if the theme material and the plugin blog is enabled. + """ + if mkdocs_config is None and isinstance(self.mkdocs_config, MkDocsConfig): + mkdocs_config = self.mkdocs_config + + if not self.is_mkdocs_theme_material(mkdocs_config=mkdocs_config): + logger.debug("Installed theme is not 'material'. Integration disabled.") + return False + + if mkdocs_config.plugins.get(f"{self.THEME_NAME}/blog") is None: + logger.debug("Material blog plugin is not listed in configuration.") + self.IS_BLOG_PLUGIN_ENABLED = False + return False + + self.blog_plugin_cfg: BlogPlugin | None = mkdocs_config.plugins.get( + f"{self.THEME_NAME}/blog" + ) + + if not self.blog_plugin_cfg.config.enabled: + logger.debug("Material blog plugin is installed but disabled.") + self.IS_BLOG_PLUGIN_ENABLED = False + return False + + logger.debug("Material blog plugin is enabled in Mkdocs configuration.") + self.IS_BLOG_PLUGIN_ENABLED = True + return True + + @lru_cache + def author_name_from_id(self, author_id: str) -> str: + """Return author name from author_id used in Material blog plugin (.authors.yml). + + Args: + author_id (str): author key in .authors.yml + + Returns: + str: author name or passed author_id if not found within .authors.yml + """ + if ( + self.blog_plugin_cfg.config.authors + and isinstance(self.blog_plugin_cfg, BlogPlugin) + and hasattr(self.blog_plugin_cfg, "authors") + and isinstance(self.blog_plugin_cfg.authors, dict) + ): + if author_id in self.blog_plugin_cfg.authors: + author_metadata = self.blog_plugin_cfg.authors.get(author_id) + if "email" in self.blog_plugin_cfg.authors.get(author_id): + return f"{author_metadata.get('email')} ({author_metadata.get('name')})" + else: + return author_metadata.get("name") + else: + logger.error( + f"Author ID '{author_id}' is not part of known authors: " + f"{self.blog_plugin_cfg.authors}. Returning author_id." + ) + return author_id + + def is_page_a_blog_post(self, mkdocs_page: Union["Post", MkdocsPageSubset]) -> bool: + """Identifies if the given page is part of Material Blog. + + Args: + mkdocs_page: page to identify + + Returns: + True if the given page is a Material Blog post. + """ + if self.IS_ENABLED and Post is not None and isinstance(mkdocs_page, Post): + logger.debug( + f"page '{mkdocs_page.file.src_uri}' identified as Material Blog post." + ) + return True + elif isinstance(mkdocs_page, MkdocsPageSubset) and Path( + mkdocs_page.src_uri + ).is_relative_to(self.blog_plugin_cfg.config.blog_dir): + logger.debug( + f"page '{mkdocs_page.src_uri}' identified as Material Blog post " + f"by src_uri matching." + ) + return True + else: + return False diff --git a/mkdocs_rss_plugin/integrations/theme_material_social_plugin.py b/mkdocs_rss_plugin/integrations/theme_material_social_plugin.py index 07914a31..c56eda91 100644 --- a/mkdocs_rss_plugin/integrations/theme_material_social_plugin.py +++ b/mkdocs_rss_plugin/integrations/theme_material_social_plugin.py @@ -6,24 +6,30 @@ # standard library import json -from hashlib import md5 from pathlib import Path -from typing import Optional # 3rd party from mkdocs.config.defaults import MkDocsConfig from mkdocs.plugins import get_plugin_logger -from mkdocs.structure.pages import Page # package from mkdocs_rss_plugin.constants import MKDOCS_LOGGER_NAME +from mkdocs_rss_plugin.integrations.theme_material_base import ( + IntegrationMaterialThemeBase, +) +from mkdocs_rss_plugin.integrations.theme_material_blog_plugin import ( + IntegrationMaterialBlog, +) +from mkdocs_rss_plugin.models import MkdocsPageSubset # conditional try: from material import __version__ as material_version + except ImportError: material_version = None + # ############################################################################ # ########## Globals ############# # ################################ @@ -35,14 +41,12 @@ # ################################ -class IntegrationMaterialSocialCards: +class IntegrationMaterialSocialCards(IntegrationMaterialThemeBase): # attributes IS_ENABLED: bool = True IS_SOCIAL_PLUGIN_ENABLED: bool = True IS_SOCIAL_PLUGIN_CARDS_ENABLED: bool = True - IS_THEME_MATERIAL: bool = False - IS_INSIDERS: bool = False - CARDS_MANIFEST: Optional[dict] = None + CARDS_MANIFEST: dict | None = None def __init__(self, mkdocs_config: MkDocsConfig, switch_force: bool = True) -> None: """Integration instantiation. @@ -53,6 +57,10 @@ def __init__(self, mkdocs_config: MkDocsConfig, switch_force: bool = True) -> No it to False to disable it even if Social Cards are enabled in Mkdocs configuration. Defaults to True. """ + # support cards for blog posts + self.integration_material_blog = IntegrationMaterialBlog( + mkdocs_config=mkdocs_config + ) # check if the integration can be enabled or not self.IS_SOCIAL_PLUGIN_CARDS_ENABLED = ( self.is_social_plugin_and_cards_enabled_mkdocs(mkdocs_config=mkdocs_config) @@ -77,70 +85,44 @@ def __init__(self, mkdocs_config: MkDocsConfig, switch_force: bool = True) -> No # if enabled, save some config elements if self.IS_ENABLED: + self.mkdocs_use_directory_urls = mkdocs_config.use_directory_urls self.mkdocs_site_url = mkdocs_config.site_url self.mkdocs_site_build_dir = mkdocs_config.site_dir + self.social_cards_dir = self.get_social_cards_dir( + mkdocs_config=mkdocs_config + ) self.social_cards_assets_dir = self.get_social_cards_build_dir( mkdocs_config=mkdocs_config ) self.social_cards_cache_dir = self.get_social_cards_cache_dir( mkdocs_config=mkdocs_config ) - if self.is_mkdocs_theme_material_insiders(): - self.load_cache_cards_manifest() - - # store some attributes used to compute social card hash - self.site_name = mkdocs_config.site_name - self.site_description = mkdocs_config.site_description or "" - - def is_mkdocs_theme_material(self, mkdocs_config: MkDocsConfig) -> bool: - """Check if the theme set in mkdocs.yml is material or not. - - Args: - mkdocs_config (MkDocsConfig): Mkdocs website configuration object. - - Returns: - bool: True if the theme's name is 'material'. False if not. - """ - self.IS_THEME_MATERIAL = mkdocs_config.theme.name == "material" - return self.IS_THEME_MATERIAL - def is_mkdocs_theme_material_insiders(self) -> Optional[bool]: - """Check if the material theme is community or insiders edition. + self.load_cache_cards_manifest() - Returns: - bool: True if the theme is Insiders edition. False if community. None if - the Material theme is not installed. - """ - if not self.IS_THEME_MATERIAL: - return None - - if material_version is not None and "insiders" in material_version: - logger.debug("Material theme edition INSIDERS") - self.IS_INSIDERS = True - return True - else: - logger.debug("Material theme edition COMMUNITY") - self.IS_INSIDERS = False - return False - - def is_social_plugin_enabled_mkdocs(self, mkdocs_config: MkDocsConfig) -> bool: + def is_social_plugin_enabled_mkdocs( + self, mkdocs_config: MkDocsConfig | None = None + ) -> bool: """Check if social plugin is installed and enabled. Args: - mkdocs_config (MkDocsConfig): Mkdocs website configuration object. + mkdocs_config (Optional[MkDocsConfig]): Mkdocs website configuration object. Returns: bool: True if the theme material and the plugin social cards is enabled. """ + if mkdocs_config is None and isinstance(self.mkdocs_config, MkDocsConfig): + mkdocs_config = self.mkdocs_config + if not self.is_mkdocs_theme_material(mkdocs_config=mkdocs_config): logger.debug("Installed theme is not 'material'. Integration disabled.") return False - if not mkdocs_config.plugins.get("material/social"): + if not mkdocs_config.plugins.get(f"{self.THEME_NAME}/social"): logger.debug("Material Social plugin not listed in configuration.") return False - social_plugin_cfg = mkdocs_config.plugins.get("material/social") + social_plugin_cfg = mkdocs_config.plugins.get(f"{self.THEME_NAME}/social") if not social_plugin_cfg.config.enabled: logger.debug("Material Social plugin is installed but disabled.") @@ -165,7 +147,7 @@ def is_social_plugin_and_cards_enabled_mkdocs( if not self.is_social_plugin_enabled_mkdocs(mkdocs_config=mkdocs_config): return False - social_plugin_cfg = mkdocs_config.plugins.get("material/social") + social_plugin_cfg = mkdocs_config.plugins.get(f"{self.THEME_NAME}/social") if not social_plugin_cfg.config.cards: logger.debug( @@ -179,24 +161,24 @@ def is_social_plugin_and_cards_enabled_mkdocs( return True def is_social_plugin_enabled_page( - self, mkdocs_page: Page, fallback_value: bool = True + self, mkdocs_page: MkdocsPageSubset, fallback_value: bool = True ) -> bool: """Check if the social plugin is enabled or disabled for a specific page. Plugin has to be enabled in Mkdocs configuration before. Args: - mkdocs_page (Page): Mkdocs page object. - fallback_value (bool, optional): fallback value. It might be the + mkdocs_page: Mkdocs page object. + fallback_value: fallback value. It might be the 'plugins.social.cards.enabled' option in Mkdocs config. Defaults to True. Returns: - bool: True if the social cards are enabled for a page. + True if the social cards are enabled for a page. """ return mkdocs_page.meta.get("social", {"cards": fallback_value}).get( "cards", fallback_value ) - def load_cache_cards_manifest(self) -> Optional[dict]: + def load_cache_cards_manifest(self) -> dict | None: """Load social cards manifest if the file exists. Returns: @@ -220,24 +202,39 @@ def load_cache_cards_manifest(self) -> Optional[dict]: return self.CARDS_MANIFEST - def get_social_cards_build_dir(self, mkdocs_config: MkDocsConfig) -> Path: - """Get Social Cards folder within Mkdocs site_dir. + def get_social_cards_dir(self, mkdocs_config: MkDocsConfig) -> str: + """Get Social Cards folder relative to Mkdocs site_dir. See: https://squidfunk.github.io/mkdocs-material/plugins/social/#config.cards_dir Args: mkdocs_config (MkDocsConfig): Mkdocs website configuration object. Returns: - str: True if the theme material and the plugin social cards is enabled. + str: The cards_dir if the theme material and the plugin social cards is enabled. """ - social_plugin_cfg = mkdocs_config.plugins.get("material/social") + social_plugin_cfg = mkdocs_config.plugins.get(f"{self.THEME_NAME}/social") logger.debug( "Material Social cards folder in Mkdocs build directory: " f"{social_plugin_cfg.config.cards_dir}." ) - return Path(social_plugin_cfg.config.cards_dir).resolve() + return social_plugin_cfg.config.cards_dir + + def get_social_cards_build_dir(self, mkdocs_config: MkDocsConfig) -> Path: + """Get Social Cards folder within Mkdocs site_dir. + See: https://squidfunk.github.io/mkdocs-material/plugins/social/#config.cards_dir + + Args: + mkdocs_config (MkDocsConfig): Mkdocs website configuration object. + + Returns: + Path: Absolute path of the assets dir if the theme material and the plugin + social cards is enabled. + """ + cards_dir = self.get_social_cards_dir(mkdocs_config=mkdocs_config) + + return Path(cards_dir).resolve() def get_social_cards_cache_dir(self, mkdocs_config: MkDocsConfig) -> Path: """Get Social Cards folder within Mkdocs site_dir. @@ -247,92 +244,107 @@ def get_social_cards_cache_dir(self, mkdocs_config: MkDocsConfig) -> Path: mkdocs_config (MkDocsConfig): Mkdocs website configuration object. Returns: - str: True if the theme material and the plugin social cards is enabled. + Path: The cache dir if the theme material and the plugin social cards is enabled. """ - social_plugin_cfg = mkdocs_config.plugins.get("material/social") - self.social_cards_cache_dir = Path(social_plugin_cfg.config.cache_dir).resolve() + social_plugin_cfg = mkdocs_config.plugins.get(f"{self.THEME_NAME}/social") + + if ( + Path(social_plugin_cfg.config.cache_dir) + .resolve() + .is_relative_to(Path(mkdocs_config.config_file_path).parent.resolve()) + ): + self.social_cards_cache_dir = Path( + social_plugin_cfg.config.cache_dir + ).resolve() + else: + self.social_cards_cache_dir = ( + Path(mkdocs_config.config_file_path) + .parent.resolve() + .joinpath(social_plugin_cfg.config.cache_dir) + ) logger.debug( - "Material Social cards cache folder: " f"{self.social_cards_cache_dir}." + "Material Social cards cache folder: " + f"{self.social_cards_cache_dir}. " + f"Already exists: {self.social_cards_cache_dir.is_dir()}" ) return self.social_cards_cache_dir def get_social_card_build_path_for_page( - self, mkdocs_page: Page, mkdocs_site_dir: Optional[str] = None - ) -> Optional[Path]: + self, mkdocs_page: MkdocsPageSubset, mkdocs_site_dir: str | None = None + ) -> Path | None: """Get social card path in Mkdocs build dir for a specific page. Args: - mkdocs_page (Page): Mkdocs page object. - mkdocs_site_dir (Optional[str], optional): Mkdocs build site dir. If None, the + mkdocs_page: Mkdocs page object. + mkdocs_site_dir: Mkdocs build site dir. If None, the 'class.mkdocs_site_build_dir' is used. is Defaults to None. Returns: - Path: path to the image once published + path to the image once published """ if mkdocs_site_dir is None and self.mkdocs_site_build_dir: mkdocs_site_dir = self.mkdocs_site_build_dir - expected_built_card_path = Path( - f"{mkdocs_site_dir}/{self.social_cards_assets_dir}/" - f"{Path(mkdocs_page.file.src_uri).with_suffix('.png')}" - ) + # if page is a blog post + if ( + self.integration_material_blog.IS_BLOG_PLUGIN_ENABLED + and self.integration_material_blog.is_page_a_blog_post(mkdocs_page) + ): + expected_built_card_path = Path( + f"{mkdocs_site_dir}/{self.social_cards_assets_dir}/" + f"{Path(mkdocs_page.dest_uri).parent}.png" + ) + else: + expected_built_card_path = Path( + f"{mkdocs_site_dir}/{self.social_cards_assets_dir}/" + f"{Path(mkdocs_page.src_uri).with_suffix('.png')}" + ) if expected_built_card_path.is_file(): logger.debug( - f"Social card file found in cache folder: {expected_built_card_path}" + f"Social card file found in build folder: {expected_built_card_path}" ) return expected_built_card_path else: - logger.debug(f"Not found: {expected_built_card_path}") + logger.debug( + f"Social card not found in build folder: {expected_built_card_path}" + ) return None - def get_social_card_cache_path_for_page(self, mkdocs_page: Page) -> Optional[Path]: + def get_social_card_cache_path_for_page( + self, mkdocs_page: MkdocsPageSubset + ) -> Path | None: """Get social card path in social plugin cache folder for a specific page. - Note: - As we write this code (June 2024), the cache mechanism in Insiders edition - has stores images directly with the corresponding Page's path and name and - keep a correspondance matrix with hashes in a manifest.json; - the cache mechanism in Community edition uses the hash as file names without - any exposed matching criteria. + The cache mechanism in stores images directly with the + corresponding Page's path and name and keep a correspondance matrix with hashes + in a manifest.json. Args: - mkdocs_page (Page): Mkdocs page object. + mkdocs_page: Mkdocs page object. Returns: - Path: path to the image in local cache folder if it exists + path to the image in local cache folder if it exists """ - if self.IS_INSIDERS: + # if page is a blog post + if ( + self.integration_material_blog.IS_BLOG_PLUGIN_ENABLED + and self.integration_material_blog.is_page_a_blog_post(mkdocs_page) + ): + logger.debug( + f"Looking for social card in cache for blog post: {mkdocs_page.src_uri}" + ) expected_cached_card_path = self.social_cards_cache_dir.joinpath( - f"assets/images/social/{Path(mkdocs_page.file.src_uri).with_suffix('.png')}" + f"assets/images/social/{Path(mkdocs_page.dest_uri).parent}.png" ) - if expected_cached_card_path.is_file(): - logger.debug( - f"Social card file found in cache folder: {expected_cached_card_path}" - ) - return expected_cached_card_path - else: - logger.debug(f"Not found: {expected_cached_card_path}") - else: - if "description" in mkdocs_page.meta: - description = mkdocs_page.meta["description"] - else: - description = self.site_description - - page_hash = md5( - "".join( - [ - self.site_name, - str(mkdocs_page.meta.get("title", mkdocs_page.title)), - description, - ] - ).encode("utf-8") + logger.debug( + f"Looking for social card in cache for page: {mkdocs_page.src_uri}" ) expected_cached_card_path = self.social_cards_cache_dir.joinpath( - f"{page_hash.hexdigest()}.png" + f"assets/images/social/{Path(mkdocs_page.src_uri).with_suffix('.png')}" ) if expected_cached_card_path.is_file(): @@ -341,25 +353,50 @@ def get_social_card_cache_path_for_page(self, mkdocs_page: Page) -> Optional[Pat ) return expected_cached_card_path else: - logger.debug(f"Not found: {expected_cached_card_path}") - return None + logger.debug( + f"Social card not found in cache folder: {expected_cached_card_path}" + ) def get_social_card_url_for_page( self, - mkdocs_page: Page, - mkdocs_site_url: Optional[str] = None, + mkdocs_page: MkdocsPageSubset, + mkdocs_site_url: str | None = None, ) -> str: """Get social card URL for a specific page in documentation. Args: - mkdocs_page (Page): Mkdocs page object. - mkdocs_site_url (Optional[str], optional): Mkdocs site URL. If None, the + mkdocs_page: subset of Mkdocs page object. + mkdocs_site_url: Mkdocs site URL. If None, the 'class.mkdocs_site_url' is used. is Defaults to None. Returns: - str: URL to the image once published + URL to the image once published """ if mkdocs_site_url is None and self.mkdocs_site_url: mkdocs_site_url = self.mkdocs_site_url - return f"{mkdocs_site_url}assets/images/social/{Path(mkdocs_page.file.src_uri).with_suffix('.png')}" + # if page is a blog post + if ( + self.integration_material_blog.IS_BLOG_PLUGIN_ENABLED + and self.integration_material_blog.is_page_a_blog_post(mkdocs_page) + ): + if self.mkdocs_use_directory_urls: + # see: https://github.com/Guts/mkdocs-rss-plugin/issues/319 + page_social_card = ( + f"{mkdocs_site_url}{self.social_cards_dir}/" + f"{Path(mkdocs_page.dest_uri).parent.with_suffix('.png')}" + ) + else: + page_social_card = ( + f"{mkdocs_site_url}{self.social_cards_dir}/" + f"{Path(mkdocs_page.dest_uri).with_suffix('.png')}" + ) + else: + page_social_card = ( + f"{mkdocs_site_url}{self.social_cards_dir}/" + f"{Path(mkdocs_page.src_uri).with_suffix('.png')}" + ) + + logger.debug(f"Use social card url: {page_social_card}") + + return page_social_card diff --git a/mkdocs_rss_plugin/models.py b/mkdocs_rss_plugin/models.py index 7e863bd8..ce1aa7e1 100644 --- a/mkdocs_rss_plugin/models.py +++ b/mkdocs_rss_plugin/models.py @@ -4,26 +4,93 @@ # ########## Libraries ############# # ################################## +# for class autoref typing +from __future__ import annotations + # standard -from datetime import datetime -from pathlib import Path -from typing import NamedTuple, Optional +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +# package modules +from mkdocs_rss_plugin.__about__ import __title__, __version__ +if TYPE_CHECKING: + from collections.abc import MutableMapping + from datetime import datetime + from pathlib import Path + + from mkdocs.structure.pages import Page # ############################################################################ # ########## Classes ############### # ################################## -class PageInformation(NamedTuple): - """Data type to set and get page information in order to produce the RSS feed.""" - - abs_path: Optional[Path] = None - categories: Optional[list] = None - authors: Optional[tuple] = None - created: Optional[datetime] = None - description: Optional[str] = None - guid: Optional[str] = None - image: Optional[str] = None - title: Optional[str] = None - updated: Optional[datetime] = None - url_comments: Optional[str] = None - url_full: Optional[str] = None + + +@dataclass +class MkdocsPageSubset: + """Minimal subset of a Mkdocs Page with only necessary attributes for plugin needs.""" + + abs_src_path: str + dest_uri: str + src_uri: str + title: str | None = None + meta: MutableMapping[str, Any] | None = None + + @classmethod + def from_page(cls, page: Page) -> MkdocsPageSubset: + """Create a PageSubset from a Mkdocs page. + + Args: + page: MkDocs Page object + """ + return cls( + abs_src_path=page.file.abs_src_path, + meta=page.meta, + title=page.title, + src_uri=page.file.src_uri, + dest_uri=page.file.dest_uri, + ) + + +@dataclass +class PageInformation: + """Object describing a page information gathered from Mkdocs and used as feed's item.""" + + abs_path: Path | None = None + categories: list | None = None + authors: tuple | None = None + comments_url: str | None = None + created: datetime | None = None + description: str | None = None + guid: str | None = None + image: tuple[str, str, int] | None = None + link: str | None = None + pub_date: str | None = None + title: str | None = None + updated: datetime | None = None + # private + _mkdocs_page_ref: MkdocsPageSubset | None = field( + default=None, repr=False, compare=False + ) + + +@dataclass +class RssFeedBase: + """Object describing a feed.""" + + author: str | None = None + buildDate: str | None = None + copyright: str | None = None + description: str | None = None + entries: list[PageInformation] = field(default_factory=list) + generator: str = f"{__title__} - v{__version__}" + html_url: str | None = None + json_url: str | None = None + language: str | None = None + logo_url: str | None = None + pubDate: str | None = None + repo_url: str | None = None + rss_url: str | None = None + stylesheet: str | None = None + title: str | None = None + ttl: int | None = None diff --git a/mkdocs_rss_plugin/plugin.py b/mkdocs_rss_plugin/plugin.py index c4ab87f2..a650280a 100644 --- a/mkdocs_rss_plugin/plugin.py +++ b/mkdocs_rss_plugin/plugin.py @@ -7,11 +7,13 @@ # standard library import json from copy import deepcopy +from dataclasses import asdict from datetime import datetime -from email.utils import formatdate +from email.utils import format_datetime, formatdate from pathlib import Path from re import compile as re_compile -from typing import List, Literal, Optional +from shutil import copyfile +from typing import Literal # 3rd party from jinja2 import Environment, FileSystemLoader, select_autoescape @@ -23,17 +25,20 @@ from mkdocs.utils import get_build_timestamp # package modules -from mkdocs_rss_plugin.__about__ import __title__, __uri__, __version__ +from mkdocs_rss_plugin.__about__ import __title__, __version__ from mkdocs_rss_plugin.config import RssPluginConfig from mkdocs_rss_plugin.constants import ( DEFAULT_TEMPLATE_FILENAME, DEFAULT_TEMPLATE_FOLDER, MKDOCS_LOGGER_NAME, ) +from mkdocs_rss_plugin.integrations.theme_material_blog_plugin import ( + IntegrationMaterialBlog, +) from mkdocs_rss_plugin.integrations.theme_material_social_plugin import ( IntegrationMaterialSocialCards, ) -from mkdocs_rss_plugin.models import PageInformation +from mkdocs_rss_plugin.models import MkdocsPageSubset, PageInformation, RssFeedBase from mkdocs_rss_plugin.util import Util # ############################################################################ @@ -55,7 +60,7 @@ class GitRssPlugin(BasePlugin[RssPluginConfig]): # allow to set the plugin multiple times in the same mkdocs config supports_multiple_instances = True - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: """Instantiation.""" # pages storage super().__init__(*args, **kwargs) @@ -79,10 +84,10 @@ def on_startup( # flag used command to disable some actions if serve is used self.cmd_is_serve = command == "serve" - self.pages_to_filter: List[PageInformation] = [] + self.pages_to_filter: list[PageInformation] = [] # prepare output feeds - self.feed_created: dict = {} - self.feed_updated: dict = {} + self.feed_created: RssFeedBase = RssFeedBase() + self.feed_updated: RssFeedBase = RssFeedBase() def on_config(self, config: MkDocsConfig) -> MkDocsConfig: """The config event is the first event called on build and @@ -118,6 +123,12 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig: self.cache_dir.mkdir(parents=True, exist_ok=True) logger.debug(f"Caching HTTP requests to: {self.cache_dir.resolve()}") + # integrations - check if theme is Material and if blog are enabled + self.integration_material_blog = IntegrationMaterialBlog( + mkdocs_config=config, + switch_force=self.config.use_material_blog, + ) + # integrations - check if theme is Material and if social cards are enabled self.integration_material_social_cards = IntegrationMaterialSocialCards( mkdocs_config=config, @@ -128,6 +139,7 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig: self.util = Util( cache_dir=self.cache_dir, use_git=self.config.use_git, + integration_material_blog=self.integration_material_blog, integration_material_social_cards=self.integration_material_social_cards, mkdocs_command_is_on_serve=self.cmd_is_serve, ) @@ -139,30 +151,46 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig: self.tpl_folder = DEFAULT_TEMPLATE_FOLDER # start a feed dictionary using global config vars - base_feed = { - "author": config.site_author or None, - "buildDate": formatdate(get_build_timestamp()), - "copyright": config.copyright, - "description": ( + base_feed = RssFeedBase( + author=config.site_author or None, + buildDate=formatdate(get_build_timestamp()), + copyright=config.copyright, + description=( self.config.feed_description if self.config.feed_description else config.site_description ), - "entries": [], - "generator": f"{__title__} - v{__version__}", - "html_url": self.util.get_site_url(mkdocs_config=config), - "language": self.util.guess_locale(mkdocs_config=config), - "pubDate": formatdate(get_build_timestamp()), - "repo_url": config.repo_url, - "title": ( + entries=[], + generator=f"{__title__} - v{__version__}", + html_url=self.util.get_site_url(mkdocs_config=config), + language=self.util.guess_locale(mkdocs_config=config), + pubDate=formatdate(get_build_timestamp()), + repo_url=config.repo_url, + title=( self.config.feed_title if self.config.feed_title else config.site_name ), - "ttl": self.config.feed_ttl, - } + ttl=self.config.feed_ttl, + ) # feed image if self.config.image: - base_feed["logo_url"] = self.config.image + base_feed.logo_url = self.config.image + + # feed stylesheet (XSL) + if self.config.stylesheet: + if self.config.stylesheet == "auto": + base_feed.stylesheet = "rss.xsl" + logger.debug( + f"Shipped stylesheet will be referenced in RSS feeds: {self.config.stylesheet}" + ) + else: + + base_feed.stylesheet = self.config.stylesheet + logger.debug( + f"Stylesheet will be referenced in RSS feeds: {self.config.stylesheet}" + ) + else: + logger.debug("No stylesheet will be referenced in RSS feeds.") # pattern to match pages included in output self.match_path_pattern = re_compile(self.config.match_path) @@ -224,19 +252,19 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig: self.feed_updated = deepcopy(base_feed) # final feed url - if base_feed.get("html_url"): + if base_feed.html_url: # concatenate both URLs - self.feed_created["rss_url"] = ( - base_feed.get("html_url") + self.config.feeds_filenames.rss_created + self.feed_created.rss_url = ( + base_feed.html_url + self.config.feeds_filenames.rss_created ) - self.feed_updated["rss_url"] = ( - base_feed.get("html_url") + self.config.feeds_filenames.rss_updated + self.feed_updated.rss_url = ( + base_feed.html_url + self.config.feeds_filenames.rss_updated ) - self.feed_created["json_url"] = ( - base_feed.get("html_url") + self.config.feeds_filenames.json_created + self.feed_created.json_url = ( + base_feed.html_url + self.config.feeds_filenames.json_created ) - self.feed_updated["json_url"] = ( - base_feed.get("html_url") + self.config.feeds_filenames.json_updated + self.feed_updated.json_url = ( + base_feed.html_url + self.config.feeds_filenames.json_updated ) else: logger.error( @@ -244,9 +272,9 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig: "configuration file whereas a URL is mandatory to publish. " "See: https://validator.w3.org/feed/docs/rss2.html#requiredChannelElements" ) - self.feed_created["rss_url"] = self.feed_updated["json_url"] = ( - self.feed_updated["rss_url"] - ) = self.feed_updated["json_url"] = None + self.feed_created.rss_url = self.feed_updated.json_url = ( + self.feed_updated.rss_url + ) = self.feed_updated.json_url = None # ending event return config @@ -254,7 +282,7 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig: @event_priority(priority=-75) def on_page_content( self, html: str, page: Page, config: MkDocsConfig, files: Files - ) -> Optional[str]: + ) -> str | None: """The page_content event is called after the Markdown text is rendered to HTML (but before being passed to a template) and can be used to alter the HTML body of the page. @@ -275,7 +303,7 @@ def on_page_content( return # skip pages that don't match the config var match_path - if not self.match_path_pattern.match(page.file.src_path): + if not self.match_path_pattern.match(page.file.src_uri): return # skip pages with draft=true @@ -320,6 +348,7 @@ def on_page_content( categories=self.util.get_categories_from_meta( in_page=page, categories_labels=self.config.categories ), + comments_url=page_url_comments, created=page_dates[0], description=self.util.get_description_or_abstract( in_page=page, @@ -327,15 +356,11 @@ def on_page_content( abstract_delimiter=self.config.abstract_delimiter, ), guid=page.canonical_url, - image=self.util.get_image( - in_page=page, - # below let it as old dict get method to handle custom fallback value - base_url=config.get("site_url", __uri__), - ), + link=page_url_full, title=page.title, updated=page_dates[1], - url_comments=page_url_comments, - url_full=page_url_full, + # for later fetch + _mkdocs_page_ref=MkdocsPageSubset.from_page(page), ) ) @@ -370,43 +395,52 @@ def on_post_build(self, config: config_options.Config) -> None: self.config.feeds_filenames.json_updated ) + # stylesheet for RSS feed + if self.config.stylesheet == "auto": + xsl_source = self.tpl_folder.joinpath("default.xsl") + xsl_dest = Path(config.site_dir).joinpath("rss.xsl") + copyfile(xsl_source, xsl_dest) + # created items - self.feed_created.get("entries").extend( + self.feed_created.entries.extend( self.util.filter_pages( pages=self.pages_to_filter, - attribute="created", + filter_attribute="created", length=self.config.length, ) ) # updated items - self.feed_updated.get("entries").extend( + self.feed_updated.entries.extend( self.util.filter_pages( pages=self.pages_to_filter, - attribute="updated", + filter_attribute="updated", length=self.config.length, ) ) + # load RSS items images (enclosures) + logger.debug( + f"Loading images for {len(self.feed_created.entries)} pages by creation " + f"and {len(self.feed_updated.entries)} pages by update" + ) + processed_refs = set() + self.util.load_images_for_pages( + self.feed_created.entries, config.site_url, processed_refs + ) + self.util.load_images_for_pages( + self.feed_updated.entries, config.site_url, processed_refs + ) + # RSS if self.config.rss_feed_enabled: - # write feeds according to the pretty print option + # Jinja environment depending on the pretty print option if pretty_print: # load Jinja environment and template env = Environment( autoescape=select_autoescape(["html", "xml"]), loader=FileSystemLoader(self.tpl_folder), ) - - template = env.get_template(self.tpl_file.name) - - # write feeds to files - with out_feed_created.open(mode="w", encoding="UTF8") as fifeed_created: - fifeed_created.write(template.render(feed=self.feed_created)) - - with out_feed_updated.open(mode="w", encoding="UTF8") as fifeed_updated: - fifeed_updated.write(template.render(feed=self.feed_updated)) - else: # load Jinja environment and template env = Environment( @@ -415,24 +449,46 @@ def on_post_build(self, config: config_options.Config) -> None: lstrip_blocks=True, trim_blocks=True, ) - template = env.get_template(self.tpl_file.name) - # write feeds to files stripping out spaces and new lines - with out_feed_created.open(mode="w", encoding="UTF8") as fifeed_created: + template = env.get_template(self.tpl_file.name) + + # -- Feed sorted by creation date + logger.debug("Fill creation dates and dump created feed into RSS template.") + # set pub date as created + for page in self.feed_created.entries: + page.pub_date = format_datetime(dt=page.created) + + # write file + with out_feed_created.open(mode="w", encoding="UTF8") as fifeed_created: + if pretty_print: + fifeed_created.write(template.render(feed=self.feed_created)) + else: prev_char = "" - for char in template.render(feed=self.feed_created): + for char in template.render(feed=asdict(self.feed_created)): if char == "\n": - continue + # convert new lines to spaces to preserve sentence structure + char = " " if char == " " and prev_char == " ": prev_char = char continue prev_char = char fifeed_created.write(char) - with out_feed_updated.open(mode="w", encoding="UTF8") as fifeed_updated: - for char in template.render(feed=self.feed_updated): + # -- Feed sorted by last update date + logger.debug("Fill update dates and dump udpated feed into RSS template.") + # set pub date as updated + for page in self.feed_updated.entries: + page.pub_date = format_datetime(dt=page.updated) + + # write file + with out_feed_updated.open(mode="w", encoding="UTF8") as fifeed_updated: + if pretty_print: + fifeed_updated.write(template.render(feed=self.feed_updated)) + else: + prev_char = "" + for char in template.render(feed=asdict(self.feed_updated)): if char == "\n": - prev_char = char - continue + # convert new lines to spaces to preserve sentence structure + char = " " if char == " " and prev_char == " ": prev_char = char continue @@ -450,7 +506,7 @@ def on_post_build(self, config: config_options.Config) -> None: with out_json_updated.open(mode="w", encoding="UTF8") as fp: json.dump( - self.util.feed_to_json(self.feed_updated, updated=True), + self.util.feed_to_json(self.feed_updated), fp, indent=4 if self.config.pretty_print else None, ) diff --git a/mkdocs_rss_plugin/templates/default.xsl b/mkdocs_rss_plugin/templates/default.xsl new file mode 100644 index 00000000..3a11d00c --- /dev/null +++ b/mkdocs_rss_plugin/templates/default.xsl @@ -0,0 +1,202 @@ + + + + + + + + + + + + <xsl:value-of select="rss/channel/title"/> + + + + + + +
+ + + + + + 64 + + + +

+ +

+ +

+ +

+ +

+ + + + + Visit website + +

+
+ + + By + + + + — Published: + + + + + — Updated: + + + +
+
+ + + +
+ +

+ + + + + + +

+ +
+ + Par + + + — + +
+ + + + + + + + + + +

+ +

+ +
+ + + + + +
+ +
+ +
+ + + +
+ +
diff --git a/mkdocs_rss_plugin/templates/rss.xml.jinja2 b/mkdocs_rss_plugin/templates/rss.xml.jinja2 index 2d63af46..53286720 100644 --- a/mkdocs_rss_plugin/templates/rss.xml.jinja2 +++ b/mkdocs_rss_plugin/templates/rss.xml.jinja2 @@ -1,4 +1,5 @@ +{% if feed.stylesheet is not none %}{% endif -%} {# Mandatory elements #} @@ -8,7 +9,7 @@ {% if feed.rss_url is not none %}{% endif %} {# Optional elements #} - {% if feed.author is not none %}{{ feed.author }}{% endif %} + {% if feed.author is not none %}{{ feed.author | e }}{% endif %} {% if feed.repo_url is not none %}{{ feed.repo_url }}{% endif %} {% if feed.language is not none %}{{ feed.language }}{% endif %} @@ -47,7 +48,7 @@ {% endif %} {{ item.description|e }} {% if item.link is not none %}{{ item.link|e }}{% endif %} - {{ item.pubDate }} + {{ item.pub_date }} {% if item.link is not none %}{{ feed.title }}{% endif %} {% if item.comments_url is not none %}{{ item.comments_url|e }}{% endif %} {% if item.guid is not none %}{{ item.guid }}{% endif %} diff --git a/mkdocs_rss_plugin/timezoner.py b/mkdocs_rss_plugin/timezoner.py index 098f0134..23a75ae1 100644 --- a/mkdocs_rss_plugin/timezoner.py +++ b/mkdocs_rss_plugin/timezoner.py @@ -38,14 +38,13 @@ def set_datetime_zoneinfo( ) -> datetime: """Apply timezone to a naive datetime. - :param input_datetime: offset-naive datetime - :type input_datetime: datetime - :param config_timezone: name of timezone as registered in IANA database, - defaults to "UTC". Example : Europe/Paris. - :type config_timezone: str, optional - - :return: offset-aware datetime - :rtype: datetime + Args: + input_datetime (datetime): offset-naive datetime + config_timezone (str, optional): name of timezone as registered in IANA + database. Defaults to "UTC". Example : Europe/Paris. + + Returns: + datetime: offset-aware datetime """ if input_datetime.tzinfo: return input_datetime diff --git a/mkdocs_rss_plugin/timezoner_pre39.py b/mkdocs_rss_plugin/timezoner_pre39.py deleted file mode 100644 index 36e309d9..00000000 --- a/mkdocs_rss_plugin/timezoner_pre39.py +++ /dev/null @@ -1,56 +0,0 @@ -#! python3 # noqa: E265 - - -""" -Manage timezones for pages date(time)s using pytz module. -Meant to be dropped when Python 3.8 reaches EOL. -""" - -# ############################################################################ -# ########## Libraries ############# -# ################################## - -# standard library -from datetime import datetime - -# 3rd party -import pytz -from mkdocs.plugins import get_plugin_logger - -# package -from mkdocs_rss_plugin.constants import MKDOCS_LOGGER_NAME - -# ############################################################################ -# ########## Globals ############# -# ################################ - - -logger = get_plugin_logger(MKDOCS_LOGGER_NAME) - - -# ############################################################################ -# ########## Functions ########### -# ################################ - - -def set_datetime_zoneinfo( - input_datetime: datetime, config_timezone: str = "UTC" -) -> datetime: - """Apply timezone to a naive datetime. - - :param input_datetime: offset-naive datetime - :type input_datetime: datetime - :param config_timezone: name of timezone as registered in IANA database, - defaults to "UTC". Example : Europe/Paris. - :type config_timezone: str, optional - - :return: offset-aware datetime - :rtype: datetime - """ - if input_datetime.tzinfo: - return input_datetime - elif not config_timezone: - return input_datetime.replace(tzinfo=pytz.utc) - else: - config_tz = pytz.timezone(config_timezone) - return config_tz.localize(input_datetime) diff --git a/mkdocs_rss_plugin/util.py b/mkdocs_rss_plugin/util.py index df00c72a..0f6d8525 100644 --- a/mkdocs_rss_plugin/util.py +++ b/mkdocs_rss_plugin/util.py @@ -5,15 +5,12 @@ # ################################## # standard library -import logging -import sys from collections.abc import Iterable from datetime import date, datetime -from email.utils import format_datetime from functools import lru_cache from mimetypes import guess_type from pathlib import Path -from typing import Any, List, Tuple, Union +from typing import Any, Literal from urllib.parse import urlencode, urlparse, urlunparse # 3rd party @@ -42,16 +39,14 @@ REMOTE_REQUEST_HEADERS, ) from mkdocs_rss_plugin.git_manager.ci import CiHandler +from mkdocs_rss_plugin.integrations.theme_material_blog_plugin import ( + IntegrationMaterialBlog, +) from mkdocs_rss_plugin.integrations.theme_material_social_plugin import ( IntegrationMaterialSocialCards, ) -from mkdocs_rss_plugin.models import PageInformation - -# conditional imports -if sys.version_info < (3, 9): - from mkdocs_rss_plugin.timezoner_pre39 import set_datetime_zoneinfo -else: - from mkdocs_rss_plugin.timezoner import set_datetime_zoneinfo +from mkdocs_rss_plugin.models import MkdocsPageSubset, PageInformation, RssFeedBase +from mkdocs_rss_plugin.timezoner import set_datetime_zoneinfo # ############################################################################ # ########## Globals ############# @@ -73,20 +68,28 @@ class Util: def __init__( self, cache_dir: Path = DEFAULT_CACHE_FOLDER, + integration_material_blog: Optional[IntegrationMaterialBlog] = None, integration_material_social_cards: Optional[ IntegrationMaterialSocialCards ] = None, mkdocs_command_is_on_serve: bool = False, path: str = ".", use_git: bool = True, - ): + ) -> None: """Class hosting the plugin logic. Args: - path (str, optional): path to the git repository to use. Defaults to ".". - use_git (bool, optional): flag to use git under the hood or not. Defaults to True. + cache_dir: _description_. Defaults to DEFAULT_CACHE_FOLDER. + integration_material_blog (bool, optional): option to enable + integration with Blog plugin from Material theme. \ + Defaults to None. integration_material_social_cards (bool, optional): option to enable - integration with Social Cards plugin from Material theme. Defaults to True. + integration with Social Cards plugin from Material theme. \ + Defaults to None. + mkdocs_command_is_on_serve: _description_. Defaults to False. + path (str, optional): path to the git repository to use. Defaults to ".". + use_git (bool, optional): flag to use git under the hood or not. \ + Defaults to True. """ self.mkdocs_command_is_on_serve = mkdocs_command_is_on_serve if self.mkdocs_command_is_on_serve: @@ -126,14 +129,14 @@ def __init__( else: self.git_is_valid = False logger.debug( - "Git use is disabled. " - "Only page.meta (YAML frontmatter will be used). " + "Git use is disabled. Only page.meta (YAML frontmatter will be used). " ) # save git enable/disable status self.use_git = use_git # save integrations + self.material_blog = integration_material_blog self.social_cards = integration_material_social_cards # http/s session @@ -173,7 +176,7 @@ def build_url( url_parts[4] = urlencode(args_dict) return urlunparse(url_parts) - def get_value_from_dot_key(self, data: dict, dot_key: Union[str, bool]) -> Any: + def get_value_from_dot_key(self, data: dict, dot_key: str | bool) -> Any: """Retrieves a value from a dictionary using a dot notation key. Args: @@ -202,7 +205,7 @@ def get_file_dates( meta_datetime_format: str, meta_default_time: datetime, meta_default_timezone: str, - ) -> Tuple[datetime, datetime]: + ) -> tuple[datetime, datetime]: """Extract creation and update dates from page metadata (yaml frontmatter) or git log for given file. @@ -295,20 +298,20 @@ def get_file_dates( format="%at", ) except GitCommandError as err: - logging.warning( + logger.info( f"Unable to read git logs of '{in_page.file.abs_src_path}'. " "Is git log readable? Falling back to build date. " "To disable this warning, set 'use_git: false' in plugin options. " f"Trace: {err}" ) except GitCommandNotFound as err: - logging.error( + logger.warning( "Unable to perform command 'git log'. Is git installed? " "Falling back to build date. " "To disable this warning, set 'use_git: false' in plugin options. " f"Trace: {err}" ) - self.git_is_valid = 0 + self.git_is_valid = False # convert timestamps into datetimes if isinstance(dt_created, (str, float, int)) and dt_created: dt_created = set_datetime_zoneinfo( @@ -358,7 +361,7 @@ def get_file_dates( get_build_datetime(), ) - def get_authors_from_meta(self, in_page: Page) -> Optional[Tuple[str]]: + def get_authors_from_meta(self, in_page: Page) -> Optional[tuple[str]]: """Returns authors from page meta. It handles 'author' and 'authors' for keys, \ str and iterable as values types. @@ -375,7 +378,7 @@ def get_authors_from_meta(self, in_page: Page) -> Optional[Tuple[str]]: elif isinstance(in_page.meta.get("author"), (list, tuple)): return tuple(in_page.meta.get("author")) else: - logging.warning( + logger.warning( "Type of author value in page.meta " f"({in_page.file.abs_src_path}) is not valid. " "It should be str, list or tuple, " @@ -386,9 +389,18 @@ def get_authors_from_meta(self, in_page: Page) -> Optional[Tuple[str]]: if isinstance(in_page.meta.get("authors"), str): return (in_page.meta.get("authors"),) elif isinstance(in_page.meta.get("authors"), (list, tuple)): - return tuple(in_page.meta.get("authors")) + if ( + self.material_blog.IS_ENABLED + and self.material_blog.is_page_a_blog_post(in_page) + ): + return [ + self.material_blog.author_name_from_id(author_id) + for author_id in in_page.meta.get("authors") + ] + else: + return tuple(in_page.meta.get("authors")) else: - logging.warning( + logger.warning( "Type of authors value in page.meta (%s) is not valid. " "It should be str, list or tuple, not: %s." % in_page.file.abs_src_path, @@ -542,29 +554,59 @@ def get_description_or_abstract( ) return "" - def get_image(self, in_page: Page, base_url: str) -> Optional[Tuple[str, str, int]]: + def load_images_for_pages( + self, + pages: list[PageInformation], + base_url: str, + processed_refs: Optional[set] = None, + ) -> None: + """Load images for a list of pages (mutation in-place). + + Args: + pages: list of PageInformation + base_url: final website base URL + processed_refs: deduplication set + """ + if processed_refs is None: + processed_refs = set() + + for page_info in pages: + if ( + page_info._mkdocs_page_ref + and id(page_info._mkdocs_page_ref) not in processed_refs + ): + logger.debug( + f"Get image for '{page_info.title}' ({page_info.abs_path})" + ) + page_info.image = self.get_image( + in_page=page_info._mkdocs_page_ref, base_url=base_url + ) + processed_refs.add(id(page_info._mkdocs_page_ref)) + + def get_image( + self, in_page: MkdocsPageSubset, base_url: str + ) -> Optional[tuple[str, str, int]]: """Get page's image from page meta or social cards and returns properties. Args: - in_page (Page): page to parse - base_url (str): website URL to resolve absolute URLs for images referenced + in_page: page to parse + base_url: website URL to resolve absolute URLs for images referenced with local path. Returns: - Optional[Tuple[str, str, int]]: (image url, mime type, image length) or None if - there is no image set + (image url, mime type, image length) or None if there is no image set """ if in_page.meta.get("image"): img_url = in_page.meta.get("image").strip() logger.debug( f"Image found ({img_url}) in page.meta.image for page: " - f"{in_page.file.src_uri}" + f"{in_page.src_uri}" ) elif in_page.meta.get("illustration"): img_url = in_page.meta.get("illustration").strip() logger.debug( f"Image found ({img_url}) in page.meta.illustration for page: " - f"{in_page.file.src_uri}" + f"{in_page.src_uri}" ) elif ( isinstance(self.social_cards, IntegrationMaterialSocialCards) @@ -613,7 +655,7 @@ def get_image(self, in_page: Page, base_url: str) -> Optional[Tuple[str, str, in # if path, resolve absolute url if not img_url.startswith("http"): img_length = self.get_local_image_length( - page_path=in_page.file.abs_src_path, path_to_append=img_url + page_path=in_page.abs_src_path, path_to_append=img_url ) img_url = self.build_url(base_url=base_url, path=img_url) else: @@ -647,6 +689,7 @@ def get_remote_image_length( image_url: str, http_method: str = "HEAD", attempt: int = 0, + req_timeout: tuple[float, float] = (5, 30), ssl_verify: bool = True, ) -> Optional[int]: """Retrieve length for remote images (starting with 'http'). @@ -659,6 +702,8 @@ def get_remote_image_length( http_method (str, optional): HTTP method to use for the request. Defaults to "HEAD". attempt (int, optional): request tries counter. Defaults to 0. + req_timeout (tuple[float, float], optional): (connect, read) timeout in \ + secondes. Defaults to (5, 30). ssl_verify (bool, optional): option to perform SSL verification or not. Defaults to True. @@ -676,7 +721,10 @@ def get_remote_image_length( f"Sending {http_method} request to {image_url}" ) req_response = self.req_session.request( - method=http_method, url=image_url, verify=ssl_verify + method=http_method, + timeout=req_timeout, + url=image_url, + verify=ssl_verify, ) req_response.raise_for_status() img_length = req_response.headers.get("content-length") @@ -697,7 +745,7 @@ def get_remote_image_length( ) return None - return int(img_length) + return int(img_length) if img_length else None @staticmethod def get_site_url(mkdocs_config: MkDocsConfig) -> Optional[str]: @@ -710,8 +758,8 @@ def get_site_url(mkdocs_config: MkDocsConfig) -> Optional[str]: Returns: str | None: site url """ - # this method exists because the following line returns an empty string instead of \ - # None (because the key always exists) + # this method exists because the following line returns an empty string instead + # of None (because the key always exists) defined_site_url = mkdocs_config.site_url # cases @@ -782,75 +830,57 @@ def guess_locale(self, mkdocs_config: MkDocsConfig) -> Optional[str]: return None @staticmethod - def filter_pages(pages: List[PageInformation], attribute: str, length: int) -> list: - """Filter and return pages into a friendly RSS structure. + def filter_pages( + pages: list[PageInformation], + filter_attribute: Literal["created", "updated"], + length: int, + ) -> list[PageInformation]: + """Filter pages based on an attribute and a max number of items. Args: - pages (list): pages to filter - attribute (str): page attribute as filter variable - length (int): max number of pages to return + pages: pages to filter + filter_attribute: page attribute to use as filter variable + length: max number of pages to return Returns: - list: list of filtered pages + list of filtered pages """ - filtered_pages = [] - for page in sorted( - pages, key=lambda page: getattr(page, attribute), reverse=True - )[:length]: - pub_date: datetime = getattr(page, attribute) - filtered_pages.append( - { - "authors": page.authors, - "categories": page.categories, - "comments_url": page.url_comments, - "description": page.description, - "guid": page.guid, - "image": page.image, - "link": page.url_full, - "pubDate": format_datetime(dt=pub_date), - "pubDate3339": pub_date.isoformat("T"), - "title": page.title, - } - ) - - return filtered_pages + return sorted( + pages, key=lambda page: getattr(page, filter_attribute), reverse=True + )[:length] @staticmethod - def feed_to_json(feed: dict, *, updated: bool = False) -> dict: + def feed_to_json(feed: RssFeedBase) -> dict: """Format internal feed representation as a JSON Feed compliant dict. Args: feed (dict): internal feed structure, i. e. GitRssPlugin.feed_created or feed_updated value - updated (bool, optional): True if this is a feed_updated. Defaults to False. Returns: dict: dict that can be passed to json.dump """ - entry_date_key = "date_modified" if updated else "date_published" - return { - "version": "https://jsonfeed.org/version/1", - "title": feed.get("title"), - "home_page_url": feed.get("html_url"), - "feed_url": feed.get("json_url"), - "description": feed.get("description"), - "icon": feed.get("logo_url"), - "authors": ( - [{"name": feed.get("author")}] if feed.get("author") is not None else [] - ), - "language": str(feed.get("language")), + "version": "https://jsonfeed.org/version/1.1", + "title": feed.title, + "home_page_url": feed.html_url, + "feed_url": feed.json_url, + "description": feed.description, + "icon": feed.logo_url, + "authors": ([{"name": feed.author}] if feed.author is not None else []), + "language": str(feed.language), "items": [ { - "id": item.get("guid"), - "url": item.get("link"), - "title": item.get("title"), - "content_html": item.get("description"), - "image": (item.get("image") or (None,))[0], - entry_date_key: item.get("pubDate3339"), - "authors": [{"name": name} for name in (item.get("authors") or ())], - "tags": item.get("categories"), + "id": item.guid, + "url": item.link, + "title": item.title, + "content_html": item.description, + "image": (item.image or (None,))[0], + "date_modified": item.updated.isoformat("T"), + "date_published": item.created.isoformat("T"), + "authors": [{"name": name} for name in (item.authors or ())], + "tags": item.categories, } - for item in feed.get("entries", ()) + for item in feed.entries ], } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..a6f91753 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,236 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools>=64", "setuptools-scm>=8"] + +[project] +name = "mkdocs-rss-plugin" +description = "MkDocs plugin to generate RSS and JSON feeds using Mkdocs site configuration, git log and Mkdocs pages'meta." +dynamic = ["version"] +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.10" +authors = [{ name = "Julien Moura", email = "dev@ingeoveritas.com" }] +maintainers = [{ name = "Julien Moura", email = "dev@ingeoveritas.com" }] +keywords = [ + "atom", + "documentation", + "feed", + "git", + "json-feed", + "mkdocs", + "plugin", + "rss", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Topic :: Documentation", + "Topic :: Software Development :: Documentation", + "Topic :: Text Processing :: Markup :: Markdown", +] + +dependencies = [ + "CacheControl[filecache]>=0.14.3,<1", + "GitPython>=3.1.45,<3.2", + "mkdocs==1.6.1", + "properdocs >=1.6.5,<2", + "requests>=2.32.5,<3", + "tzdata>=2024,<2026 ; sys_platform == 'win32'", +] + +[project.optional-dependencies] +dev = [ + "black>=25.9", + "flake8>=7.1", + "flake8-bugbear>=24.10", + "flake8-builtins>=2.5", + "flake8-eradicate>=1.5", + "flake8-isort>=6.1.1", + "Flake8-pyproject>=1.2.3", + "pre-commit>=4,<5", +] +docs = [ + "mkdocs-git-committers-plugin-2>=2.4.1,<2.6", + "mkdocs-git-revision-date-localized-plugin>=1.3,<1.6", + "mkdocs-material[imaging]>=9.7,<9.8", + "mkdocs-minify-plugin>=0.8,<0.9", + "mkdocstrings-python>=1.16.2,<2.1", + "termynal>=0.12.2,<0.15", +] +test = [ + "feedparser>=6.0.12,<6.1", + "jsonfeed-util>=1.2,<2", + "mkdocs-material[imaging]>=9.7,<9.8", + "mkdocs-materialx[imaging]>=10.1.3,<11", + "pytest-cov>=6.9.1,<8", + "validator-collection>=1.5,<1.6", +] + +[project.entry-points."mkdocs.plugins"] +rss = "mkdocs_rss_plugin.plugin:GitRssPlugin" + +[project.urls] +Changelog = "https://github.com/guts/mkdocs-rss-plugin/blob/main/CHANGELOG.md" +Documentation = "https://guts.github.io/mkdocs-rss-plugin/" +Homepage = "https://guts.github.io/mkdocs-rss-plugin/" +Issues = "https://github.com/guts/mkdocs-rss-plugin/issues/" +releasenotes = "https://github.com/guts/mkdocs-rss-plugin/releases/latest" +Repository = "https://github.com/guts/mkdocs-rss-plugin/" + + +# -- TOOLING CONFIGURATION -- + +# Black configuration +[tool.black] +target-version = ["py310", "py311", "py312", "py313", "py314"] + +# Coverage configuration +[tool.coverage.report] +exclude_also = [ + # Don't complain about abstract methods, they aren't run: + "@(abc\\.)?abstractmethod", + "class .*\\bProtocol\\):", + # Don't complain about missing debug-only code: + "def __repr__", + "if self.debug:", + # Don't complain if non-runnable code isn't run: + "if 0:", + "if __name__ == .__main__.:", + # Don't complain about imports for type checking: + "if TYPE_CHECKING:", + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", + "raise NotImplementedError", + # ignore lines marked with a pragma + "pragma: no cover", +] +ignore_errors = true +show_missing = true + +[tool.coverage.run] +branch = true +omit = ["*/tests/*", "*/__pycache__/*", "*/.venv/*", "*/_version.py"] + +[tool.flake8] +count = true +exclude = [ + ".git", + "__pycache__", + "docs/conf.py", + "old", + "build", + "dist", + ".venv*", + "tests", +] +ignore = ["E121", "E123", "E126", "E203", "E226", "E24", "E704", "W503", "W504"] +max-complexity = 15 +max-doc-length = 130 +max-line-length = 100 +statistics = true +tee = true + +[tool.isort] +profile = "black" +ensure_newline_before_comments = true +force_grid_wrap = 0 +include_trailing_comma = true +line_length = 88 +multi_line_output = 3 +use_parentheses = true + +[tool.ruff] +line-length = 88 +target-version = "py310" +exclude = [".git", "__pycache__", "old", "build", "dist", "tests/dev", ".venv*"] + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = "dynamic" +indent-style = "space" +line-ending = "auto" +quote-style = "double" +skip-magic-trailing-comma = false + +[tool.ruff.lint] +select = [ + "A", # flake8-builtins + "ANN", # flake8-annotations + "B", # flake8-bugbear + "C90", # mccabe complexity + "E", # pycodestyle errors + "ERA", # eradicate (commented code) + "F", # pyflakes + "I", # isort + "LOG", # flake8-logging + "N", # pep8-naming + "PTH", # flake8-pathlib + "S", # bandit security + "TC", # flake8-type-checking + "T20", # flake8-print + "W", # pycodestyle warnings +] + +ignore = [ + "ANN201", # Missing return type annotation for public function + "ANN204", # Missing return type annotation for special method `__init__` + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in `**kwargs` + "E501", # line too long (handled by black) + # "E203", # whitespace before ':' + # "E226", # missing whitespace around arithmetic operator + # "E24", # multiple spaces after ',' +] + +[tool.ruff.lint.per-file-ignores] +# Ignore `E402` (import violations) in all `__init__.py` files +"__init__.py" = ["E402"] +# Ignore rules in tests +"tests/test_*.py" = ["ANN001", "ANN2", "S101", "T20"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +# Pytest configuration +[tool.pytest.ini_options] +addopts = [ + "--junitxml=junit/test-results.xml", + "--cov=mkdocs_rss_plugin", + "--cov-config=pyproject.toml", + "--cov-report=html", + "--cov-report=term", + "--cov-report=xml", + "--ignore=tests/_wip/", +] +junit_family = "xunit2" +minversion = "7.0" +norecursedirs = ".* build dev development dist docs CVS fixtures _darcs {arch} *.egg venv _wip" +python_files = ["test_*.py"] +testpaths = ["tests"] + +# Setuptools configuration +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-data] +mkdocs_rss_plugin = ["templates/*.jinja2"] + +[tool.setuptools.packages.find] +include = ["mkdocs_rss_plugin"] +namespaces = false +where = ["."] + +# setuptools-scm configuration +[tool.setuptools_scm] +fallback_version = "0.0.0+unknown" +local_scheme = "no-local-version" +version_scheme = "guess-next-dev" +write_to = "mkdocs_rss_plugin/_version.py" diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 75766a0b..00000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ --r requirements/base.txt - --e "." diff --git a/requirements/base.txt b/requirements/base.txt deleted file mode 100644 index cb8de783..00000000 --- a/requirements/base.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Common requirements -# ----------------------- - -cachecontrol[filecache] >=0.14,<1 -GitPython>=3.1,<3.2 -mkdocs>=1.5,<2 -requests>=2.31,<3 -pytz==2024.* ; python_version < "3.9" -tzdata==2024.* ; python_version >= "3.9" and sys_platform == "win32" diff --git a/requirements/development.txt b/requirements/development.txt deleted file mode 100644 index f8626501..00000000 --- a/requirements/development.txt +++ /dev/null @@ -1,11 +0,0 @@ --r base.txt - -# Development -# ----------------------- -black -flake8>=6,<8 -flake8-bugbear>=23.12 -flake8-builtins>=2.1 -flake8-eradicate>=1 -flake8-isort>=6 -pre-commit>=3,<4 diff --git a/requirements/documentation.txt b/requirements/documentation.txt deleted file mode 100644 index 9a97807b..00000000 --- a/requirements/documentation.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Documentation -# ----------------------- - -mkdocs-git-committers-plugin-2>=1.2,<2.4 -mkdocs-git-revision-date-localized-plugin>=1,<1.3 -mkdocs-material[imaging]>=9.5.1,<10 -mkdocs-minify-plugin==0.8.* -mkdocstrings[python]>=0.18,<1 -termynal>=0.11.1,<0.13 diff --git a/requirements/testing.txt b/requirements/testing.txt deleted file mode 100644 index 9618fdd1..00000000 --- a/requirements/testing.txt +++ /dev/null @@ -1,10 +0,0 @@ --r base.txt - -# Testing -# ------- - -feedparser>=6.0.11,<6.1 -jsonfeed-util>=1.1.2,<2 -mkdocs-material[imaging]>=9 -pytest-cov>=4,<5.1 -validator-collection>=1.5,<1.6 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b2f1294a..00000000 --- a/setup.cfg +++ /dev/null @@ -1,79 +0,0 @@ -# -- Packaging -------------------------------------- -[bdist_wheel] -universal = 1 - -[metadata] -description-file = README.md - -# -- Code quality ------------------------------------ -[flake8] -count = True -docstring-convention = google -exclude = - # No need to traverse our git directory - .git, - # There's no value in checking cache directories - __pycache__, - # The conf file is mostly autogenerated, ignore it - docs/conf.py, - # The old directory contains Flake8 2.0 - old, - # This contains our built documentation - build, - # This contains builds of flake8 that we don't want to check - dist, - # This contains local virtual environments - .venv*, - # do not watch on tests - tests -ignore = E121,E123,E126,E203,E226,E24,E704,W503,W504 -max-complexity = 15 -max-doc-length = 130 -max-line-length = 100 -statistics = True -tee = True - -[isort] -ensure_newline_before_comments = True -force_grid_wrap = 0 -include_trailing_comma = True -line_length = 88 -multi_line_output = 3 -profile = black -use_parentheses = True - -# -- Tests ---------------------------------------------- -[tool:pytest] -addopts = - --junitxml=junit/test-results.xml - --cov-config=setup.cfg - --cov=mkdocs_rss_plugin - --cov-report=html - --cov-report=term - --cov-report=xml - --ignore=tests/_wip/ -junit_family = xunit2 -minversion = 5.0 -norecursedirs = .* build dev development dist docs CVS fixtures _darcs {arch} *.egg venv _wip -python_files = test_*.py -testpaths = tests - -[coverage:run] -disable_warnings=include-ignored -branch = True -include = - mkdocs_rss_plugin/* -omit = - .venv/* - docs/* - *tests* - -[coverage:report] -exclude_lines = - if self.debug: - pragma: no cover - raise NotImplementedError - if __name__ == .__main__.: - -ignore_errors = True -show_missing = True diff --git a/setup.py b/setup.py deleted file mode 100644 index 76b4b2a4..00000000 --- a/setup.py +++ /dev/null @@ -1,108 +0,0 @@ -#! python3 # noqa: E265 - -# ############################################################################ -# ########## Libraries ############# -# ################################## - -# standard library -from pathlib import Path -from typing import List, Union - -# 3rd party -from setuptools import find_packages, setup - -# package (to get version) -from mkdocs_rss_plugin import __about__ - -# ############################################################################ -# ########## Globals ############# -# ################################ - -HERE = Path(__file__).parent -README = (HERE / "README.md").read_text() - -# ############################################################################ -# ########### Functions ############ -# ################################## - - -def load_requirements(requirements_files: Union[Path, List[Path]]) -> list: - """Helper to load requirements list from a path or a list of paths. - - Args: - requirements_files (Union[Path, List[Path]]): path or list to paths of - requirements file(s) - - Returns: - list: list of requirements loaded from file(s) - """ - out_requirements = [] - - if isinstance(requirements_files, Path): - requirements_files = [ - requirements_files, - ] - - for requirements_file in requirements_files: - with requirements_file.open(encoding="UTF-8") as f: - out_requirements += [ - line - for line in f.read().splitlines() - if not line.startswith(("#", "-")) and len(line) - ] - - return out_requirements - - -# ############################################################################ -# ########## Setup ############# -# ############################## -setup( - name="mkdocs-rss-plugin", - version=__about__.__version__, - author=__about__.__author__, - author_email=__about__.__email__, - description=__about__.__summary__, - license=__about__.__license__, - long_description=README, - long_description_content_type="text/markdown", - project_urls={ - "Docs": "https://guts.github.io/mkdocs-rss-plugin/", - "Bug Reports": f"{__about__.__uri__}issues/", - "Source": __about__.__uri__, - }, - # packaging - packages=find_packages( - exclude=["contrib", "docs", "*.tests", "*.tests.*", "tests.*", "tests"] - ), - include_package_data=True, - # run - entry_points={"mkdocs.plugins": ["rss = mkdocs_rss_plugin.plugin:GitRssPlugin"]}, - # dependencies - python_requires=">=3.8, <4", - extras_require={ - # tooling - "dev": load_requirements(HERE / "requirements/development.txt"), - "doc": load_requirements(HERE / "requirements/documentation.txt"), - "test": load_requirements(HERE / "requirements/testing.txt"), - }, - install_requires=load_requirements(HERE / "requirements/base.txt"), - # metadata - keywords="mkdocs rss git plugin", - classifiers=[ - "Intended Audience :: Developers", - "Intended Audience :: Information Technology", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: Implementation :: CPython", - "Development Status :: 5 - Production/Stable", - "License :: OSI Approved :: MIT License", - "Operating System :: OS Independent", - "Topic :: Documentation", - "Topic :: Text Processing :: Markup :: Markdown", - ], -) diff --git a/sonar-project.properties b/sonar-project.properties index dd181754..a38b5a23 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,7 +6,7 @@ sonar.projectKey=Guts_mkdocs-rss-plugin # only=main # Python versions -sonar.python.version=3.8, 3.9, 3.10, 3.11, 3.12 +sonar.python.version=3.10, 3.11, 3.12, 3.13, 3.14 # Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. sonar.sources=mkdocs_rss_plugin diff --git a/tests/base.py b/tests/base.py index a339c765..e68dd6c2 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,4 +1,4 @@ -#! python3 # noqa E265 +#! python3 # noqa: E265 """Base class for unit tests.""" @@ -131,10 +131,8 @@ def setup_clean_mkdocs_folder( # Create empty 'testproject' folder if testproject_path.exists(): - logging.warning( - """This command does not work on windows. - Refactor your test to use setup_clean_mkdocs_folder() only once""" - ) + logging.warning("""This command does not work on windows. + Refactor your test to use setup_clean_mkdocs_folder() only once""") shutil.rmtree(testproject_path) # Copy correct mkdocs.yml file and our test 'docs/' diff --git a/tests/fixtures/docs/blog/.authors.yml b/tests/fixtures/docs/blog/.authors.yml new file mode 100644 index 00000000..5c64a707 --- /dev/null +++ b/tests/fixtures/docs/blog/.authors.yml @@ -0,0 +1,16 @@ +authors: + squidfunk: + name: Martin Donath + description: Creator + avatar: https://github.com/squidfunk.png + alexvoss: + name: Alex Voss + description: Weltenwanderer + avatar: https://github.com/alexvoss.png + guts: + avatar: https://cdn.geotribu.fr/img/internal/contributeurs/jmou.jfif + description: GIS Watchman + name: Julien Moura + slug: julien-moura + url: https://geotribu.fr/team/julien-moura/ + email: guts@gmail.com diff --git a/tests/fixtures/docs/blog/index.md b/tests/fixtures/docs/blog/index.md new file mode 100644 index 00000000..c58f16c5 --- /dev/null +++ b/tests/fixtures/docs/blog/index.md @@ -0,0 +1,2 @@ +# Blog + diff --git a/tests/fixtures/docs/blog/posts/firstpost.md b/tests/fixtures/docs/blog/posts/firstpost.md new file mode 100644 index 00000000..730fc513 --- /dev/null +++ b/tests/fixtures/docs/blog/posts/firstpost.md @@ -0,0 +1,32 @@ +--- +authors: + - alexvoss + - guts +date: 2023-10-11 +categories: + - meta +--- + +# My first blog post + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque nec +maximus ex. Sed consequat, nulla quis malesuada dapibus, elit metus vehicula +erat, ut egestas tellus eros at risus. In hac habitasse platea dictumst. +Phasellus id lacus pulvinar erat consequat pretium. Morbi malesuada arcu mauris +Nam vel justo sem. Nam placerat purus non varius luctus. Integer pretium leo in +sem rhoncus, quis gravida orci mollis. Proin id aliquam est. Vivamus in nunc ac +metus tristique pellentesque. Suspendisse viverra urna in accumsan aliquet. + + + +Donec volutpat, elit ac volutpat laoreet, turpis dolor semper nibh, et dictum +massa ex pulvinar elit. Curabitur commodo sit amet dolor sed mattis. Etiam +tempor odio eu nisi gravida cursus. Maecenas ante enim, fermentum sit amet +molestie nec, mollis ac libero. Vivamus sagittis suscipit eros ut luctus. + +Nunc vehicula sagittis condimentum. Cras facilisis bibendum lorem et feugiat. +In auctor accumsan ligula, at consectetur erat commodo quis. Morbi ac nunc +pharetra, pellentesque risus in, consectetur urna. Nulla id enim facilisis +arcu tincidunt pulvinar. Vestibulum laoreet risus scelerisque porta congue. +In velit purus, dictum quis neque nec, molestie viverra risus. Nam pellentesque +tellus id elit ultricies, vel finibus erat cursus. diff --git a/tests/fixtures/docs/blog/posts/secondpost.md b/tests/fixtures/docs/blog/posts/secondpost.md new file mode 100644 index 00000000..352d9978 --- /dev/null +++ b/tests/fixtures/docs/blog/posts/secondpost.md @@ -0,0 +1,32 @@ +--- +authors: + - squidfunk +date: 2023-10-12 +categories: + - hello +--- + +# A second post + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque nec +maximus ex. Sed consequat, nulla quis malesuada dapibus, elit metus vehicula +erat, ut egestas tellus eros at risus. In hac habitasse platea dictumst. +Phasellus id lacus pulvinar erat consequat pretium. Morbi malesuada arcu mauris +Nam vel justo sem. Nam placerat purus non varius luctus. Integer pretium leo in +sem rhoncus, quis gravida orci mollis. Proin id aliquam est. Vivamus in nunc ac +metus tristique pellentesque. Suspendisse viverra urna in accumsan aliquet. + + + +Donec volutpat, elit ac volutpat laoreet, turpis dolor semper nibh, et dictum +massa ex pulvinar elit. Curabitur commodo sit amet dolor sed mattis. Etiam +tempor odio eu nisi gravida cursus. Maecenas ante enim, fermentum sit amet +molestie nec, mollis ac libero. Vivamus sagittis suscipit eros ut luctus. + +Nunc vehicula sagittis condimentum. Cras facilisis bibendum lorem et feugiat. +In auctor accumsan ligula, at consectetur erat commodo quis. Morbi ac nunc +pharetra, pellentesque risus in, consectetur urna. Nulla id enim facilisis +arcu tincidunt pulvinar. Vestibulum laoreet risus scelerisque porta congue. +In velit purus, dictum quis neque nec, molestie viverra risus. Nam pellentesque +tellus id elit ultricies, vel finibus erat cursus. + diff --git a/tests/fixtures/mkdocs_complete.yml b/tests/fixtures/mkdocs_complete.yml index 705fdcd3..66684e84 100644 --- a/tests/fixtures/mkdocs_complete.yml +++ b/tests/fixtures/mkdocs_complete.yml @@ -24,7 +24,7 @@ plugins: default_time: "09:30" enabled: true feed_ttl: 1440 - image: https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png + image: https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true json_feed_enabled: true length: 20 match_path: ".*" diff --git a/tests/fixtures/mkdocs_complete_no_git.yml b/tests/fixtures/mkdocs_complete_no_git.yml index ddda2b06..16cf0927 100644 --- a/tests/fixtures/mkdocs_complete_no_git.yml +++ b/tests/fixtures/mkdocs_complete_no_git.yml @@ -24,7 +24,7 @@ plugins: default_time: "09:30" enabled: true feed_ttl: 1440 - image: https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png + image: https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true length: 20 pretty_print: false match_path: ".*" diff --git a/tests/fixtures/mkdocs_item_image_social_cards_blog.yml b/tests/fixtures/mkdocs_item_image_social_cards_blog.yml new file mode 100644 index 00000000..d88b1736 --- /dev/null +++ b/tests/fixtures/mkdocs_item_image_social_cards_blog.yml @@ -0,0 +1,16 @@ +site_name: Test RSS Plugin +site_description: Test social cards support in RSS with blog plugin also enabled +site_url: https://guts.github.io/mkdocs-rss-plugin + +plugins: + - blog: + blog_dir: blog + - rss: + match_path: blog/posts/.* + pretty_print: true + - social: + enabled: true + cards: true + +theme: + name: material diff --git a/tests/fixtures/mkdocs_item_image_social_cards_blog_directory_url_disabled.yml b/tests/fixtures/mkdocs_item_image_social_cards_blog_directory_url_disabled.yml new file mode 100644 index 00000000..acefde92 --- /dev/null +++ b/tests/fixtures/mkdocs_item_image_social_cards_blog_directory_url_disabled.yml @@ -0,0 +1,19 @@ +site_name: Test RSS Plugin with social cards, blog plugin and directory URL disabled +site_description: Test RSS with social and blog plugins enabled but directory URLS disabled. Related to https://github.com/Guts/mkdocs-rss-plugin/issues/319. +site_url: https://guts.github.io/mkdocs-rss-plugin + +use_directory_urls: false + +plugins: + - blog: + blog_dir: blog + authors_profiles: true + - rss: + match_path: blog/posts/.* + pretty_print: true + - social: + enabled: true + cards: true + +theme: + name: material diff --git a/tests/fixtures/mkdocs_items_material_blog_enabled.yml b/tests/fixtures/mkdocs_items_material_blog_enabled.yml new file mode 100644 index 00000000..445ef73e --- /dev/null +++ b/tests/fixtures/mkdocs_items_material_blog_enabled.yml @@ -0,0 +1,12 @@ +site_name: Test RSS Plugin +site_description: Test RSS with blog plugin also enabled +site_url: https://guts.github.io/mkdocs-rss-plugin + +plugins: + - blog: + blog_dir: blog + authors_profiles: true + - rss + +theme: + name: material diff --git a/tests/fixtures/mkdocs_items_material_blog_enabled_but_integration_disabled.yml b/tests/fixtures/mkdocs_items_material_blog_enabled_but_integration_disabled.yml new file mode 100644 index 00000000..0ed22a63 --- /dev/null +++ b/tests/fixtures/mkdocs_items_material_blog_enabled_but_integration_disabled.yml @@ -0,0 +1,13 @@ +site_name: Test RSS Plugin +site_description: Test RSS with blog plugin also enabled +site_url: https://guts.github.io/mkdocs-rss-plugin + +plugins: + - blog: + blog_dir: blog + authors_profiles: true + - rss: + use_material_blog: false + +theme: + name: material diff --git a/tests/fixtures/mkdocs_materialx_item_image_social_cards_blog.yml b/tests/fixtures/mkdocs_materialx_item_image_social_cards_blog.yml new file mode 100644 index 00000000..dbb18ddc --- /dev/null +++ b/tests/fixtures/mkdocs_materialx_item_image_social_cards_blog.yml @@ -0,0 +1,16 @@ +site_name: Test RSS Plugin +site_description: Test social cards support in RSS with blog plugin also enabled +site_url: https://guts.github.io/mkdocs-rss-plugin + +plugins: + - blog: + blog_dir: blog + - rss: + match_path: blog/posts/.* + pretty_print: true + - social: + enabled: true + cards: true + +theme: + name: materialx diff --git a/tests/fixtures/mkdocs_simple.yml b/tests/fixtures/mkdocs_simple.yml index 9f2749cb..80a24294 100644 --- a/tests/fixtures/mkdocs_simple.yml +++ b/tests/fixtures/mkdocs_simple.yml @@ -22,7 +22,7 @@ plugins: default_time: "22:00" abstract_chars_count: 160 abstract_delimiter: - image: https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png + image: https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true match_path: ".*" pretty_print: false url_parameters: diff --git a/tests/fixtures/mkdocs_site_author_to_be_escaped.yml b/tests/fixtures/mkdocs_site_author_to_be_escaped.yml new file mode 100644 index 00000000..c5ec3256 --- /dev/null +++ b/tests/fixtures/mkdocs_site_author_to_be_escaped.yml @@ -0,0 +1,49 @@ +# Project information +site_name: OpenSavvy Playground +site_author: OpenSavvy & contributors +site_description: Test for sanitization of #360 +site_url: https://example.com/ + +# Repository +repo_name: "guts/mkdocs-rss-plugin" +repo_url: "https://github.com/guts/mkdocs-rss-plugin/" +edit_uri: "blob/main/docs/" + +docs_dir: "docs/" +use_directory_urls: true + +plugins: + - rss: + date_from_meta: + as_creation: "date" + datetime_format: "%Y-%m-%d %H:%M" + default_timezone: "Europe/Paris" + default_time: "22:00" + abstract_chars_count: 160 + abstract_delimiter: + image: https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true + match_path: ".*" + pretty_print: false + url_parameters: + utm_source: "documentation" + utm_medium: "RSS" + utm_campaign: "feed-syndication" + - search + +theme: + name: mkdocs + language: en + +# Extensions to enhance markdown +markdown_extensions: + - admonition + - attr_list + - codehilite + - meta + - toc: + permalink: "#" + +nav: + - "Home": "index.md" + - "Settings": "configuration.md" + - "Changelog": "changelog.md" diff --git a/tests/fixtures/mkdocs_stylesheet_disabled.yml b/tests/fixtures/mkdocs_stylesheet_disabled.yml new file mode 100644 index 00000000..704638c1 --- /dev/null +++ b/tests/fixtures/mkdocs_stylesheet_disabled.yml @@ -0,0 +1,12 @@ +site_name: Test RSS Plugin - Disable stylesheet +# site_description: Test RSS Plugin +site_url: https://guts.github.io/mkdocs-rss-plugin + +use_directory_urls: true + +plugins: + - rss: + stylesheet: "" + +theme: + name: mkdocs diff --git a/tests/fixtures/test-build-min-python.dockerfile b/tests/fixtures/test-build-min-python.dockerfile index bc87b0a3..1107b648 100644 --- a/tests/fixtures/test-build-min-python.dockerfile +++ b/tests/fixtures/test-build-min-python.dockerfile @@ -1,10 +1,9 @@ # Reference: https://squidfunk.github.io/mkdocs-material/getting-started/#with-docker -FROM python:3.8.19 +FROM python:3.10.19 COPY . . RUN python3 -m pip install --no-cache-dir -U pip setuptools wheel \ - && python3 -m pip install --no-cache-dir -U -r requirements/documentation.txt \ - && python3 -m pip install --no-cache-dir . + && python3 -m pip install --no-cache-dir .[docs] RUN mkdocs build diff --git a/tests/test_build.py b/tests/test_build.py index 78ac4773..9f9a961b 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,4 +1,4 @@ -#! python3 # noqa E265 +#! python3 # noqa: E265 """Usage from the repo root folder: @@ -50,7 +50,7 @@ class TestBuildRss(BaseTest): def setUpClass(cls): """Executed when module is loaded before any test.""" cls.config_files = sorted(Path("tests/fixtures/").glob("**/*.yml")) - cls.feed_image = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png" + cls.feed_image = "https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true" def setUp(self): """Executed before each test.""" @@ -398,8 +398,19 @@ def test_simple_build_item_length_unlimited(self): if feed_item.title not in ( "Page without meta with short text", "Blog sample", + "Blog", ): - self.assertGreater(len(feed_item.description), 150, feed_item.title) + self.assertGreater( + len(feed_item.description), + 150, + f"Failed item title: {feed_item.title}", + ) + # check sentences split across multiple lines retain spacing + if feed_item.title in ["My first blog post", "A second post"]: + self.assertIn( + "Pellentesque nec maximus ex.", + feed_item.summary, + ) def test_simple_build_item_delimiter(self): with tempfile.TemporaryDirectory() as tmpdirname: @@ -755,16 +766,20 @@ def test_json_feed_validation(self): self.assertIsNone(cli_result.exception) # created items - with Path(tmpdirname).joinpath(OUTPUT_JSON_FEED_CREATED).open( - "r", encoding="UTF-8" - ) as in_json: + with ( + Path(tmpdirname) + .joinpath(OUTPUT_JSON_FEED_CREATED) + .open("r", encoding="UTF-8") as in_json + ): json_feed_created_data = json.load(in_json) jsonfeed.Feed.parse(json_feed_created_data) # updated items - with Path(tmpdirname).joinpath(OUTPUT_JSON_FEED_UPDATED).open( - "r", encoding="UTF-8" - ) as in_json: + with ( + Path(tmpdirname) + .joinpath(OUTPUT_JSON_FEED_UPDATED) + .open("r", encoding="UTF-8") as in_json + ): json_feed_updated_data = json.load(in_json) jsonfeed.Feed.parse(json_feed_updated_data) @@ -895,6 +910,30 @@ def test_not_git_repo(self): # restore name git_dir_tmp.replace(git_dir) + def test_xml_escaping_in_author(self): + """Test that XML special characters in author field are properly escaped.""" + with tempfile.TemporaryDirectory() as tmpdirname: + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path( + "tests/fixtures/mkdocs_site_author_to_be_escaped.yml" + ), + output_path=tmpdirname, + strict=False, + ) + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + feed_parsed = feedparser.parse(Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED) + self.assertEqual(feed_parsed.bozo, 0, "Feed should parse without errors") + feed_xml = (Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED).read_text( + encoding="utf-8" + ) + + # Verify the author field contains the escaped ampersand + self.assertIn("OpenSavvy & contributors", feed_xml) + self.assertNotIn("OpenSavvy & contributors", feed_xml) + # ############################################################################## # ##### Stand alone program ######## diff --git a/tests/test_build_no_git.py b/tests/test_build_no_git.py index 0fa32722..a66b4bbc 100644 --- a/tests/test_build_no_git.py +++ b/tests/test_build_no_git.py @@ -1,4 +1,4 @@ -#! python3 # noqa E265 +#! python3 # noqa: E265 """Usage from the repo root folder: diff --git a/tests/test_config.py b/tests/test_config.py index 47b9f68f..8db2ef2e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,4 @@ -#! python3 # noqa E265 +#! python3 # noqa: E265 """Usage from the repo root folder: @@ -41,8 +41,8 @@ class TestConfig(BaseTest): @classmethod def setUpClass(cls): """Executed when module is loaded before any test.""" - cls.config_files = sorted(Path("tests/fixtures/").glob("**/*.yml")) - cls.feed_image = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png" + cls.config_files = sorted(Path("tests/fixtures/").glob("**/mkdocs_*.yml")) + cls.feed_image = "https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true" def setUp(self): """Executed before each test.""" @@ -88,9 +88,11 @@ def test_plugin_config_defaults(self): "rss_updated": "feed_rss_updated.xml", }, "pretty_print": False, + "stylesheet": "auto", "rss_feed_enabled": True, "url_parameters": None, "use_git": True, + "use_material_blog": True, "use_material_social_cards": True, } @@ -133,9 +135,11 @@ def test_plugin_config_image(self): "rss_updated": "feed_rss_updated.xml", }, "pretty_print": False, + "stylesheet": "auto", "rss_feed_enabled": True, "url_parameters": None, "use_git": True, + "use_material_blog": True, "use_material_social_cards": True, } @@ -153,8 +157,8 @@ def test_plugin_config_image(self): def test_plugin_config_through_mkdocs(self): for config_filepath in self.config_files: - plg_cfg = self.get_plugin_config_from_mkdocs(config_filepath, "rss") print(config_filepath) + plg_cfg = self.get_plugin_config_from_mkdocs(config_filepath, "rss") self.assertIsInstance(plg_cfg, Config) diff --git a/tests/test_integrations_material.py b/tests/test_integrations_material.py new file mode 100644 index 00000000..6c5ba7df --- /dev/null +++ b/tests/test_integrations_material.py @@ -0,0 +1,71 @@ +#! python3 # noqa: E265 + +"""Usage from the repo root folder: + +.. code-block:: python + + # for whole test + python -m unittest tests.test_build + +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import unittest +from logging import DEBUG, getLogger +from pathlib import Path + +# 3rd party +from mkdocs.config import load_config + +# package +from mkdocs_rss_plugin.integrations.theme_material_base import ( + IntegrationMaterialThemeBase, +) + +# test suite +from tests.base import BaseTest + +# ############################################################################# +# ########## Classes ############### +# ################################## + +logger = getLogger(__name__) +logger.setLevel(DEBUG) + + +class TestRssPluginIntegrationsMaterialThem(BaseTest): + """Test integration of Material theme.""" + + # -- TESTS --------------------------------------------------------- + def test_plugin_config_theme_material(self): + # default reference + cfg_mkdocs = load_config( + str(Path("tests/fixtures/mkdocs_language_specific_material.yml").resolve()) + ) + + integration_social_cards = IntegrationMaterialThemeBase( + mkdocs_config=cfg_mkdocs + ) + self.assertTrue(integration_social_cards.IS_THEME_MATERIAL) + + def test_plugin_config_theme_not_material(self): + # default reference + cfg_mkdocs = load_config( + str(Path("tests/fixtures/mkdocs_complete.yml").resolve()) + ) + + integration_social_cards = IntegrationMaterialThemeBase( + mkdocs_config=cfg_mkdocs + ) + self.assertFalse(integration_social_cards.IS_THEME_MATERIAL) + + +# ############################################################################## +# ##### Stand alone program ######## +# ################################## +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integrations_material_blog.py b/tests/test_integrations_material_blog.py new file mode 100644 index 00000000..5a7bfe07 --- /dev/null +++ b/tests/test_integrations_material_blog.py @@ -0,0 +1,132 @@ +#! python3 # noqa: E265 + +"""Usage from the repo root folder: + +.. code-block:: python + + # for whole test + python -m unittest tests.test_build + +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import tempfile +import unittest +from logging import DEBUG, getLogger +from pathlib import Path +from traceback import format_exception + +# 3rd party +import feedparser +from mkdocs.config import load_config + +# package +from mkdocs_rss_plugin.integrations.theme_material_blog_plugin import ( + IntegrationMaterialBlog, +) + +# test suite +from tests.base import BaseTest + +# ############################################################################# +# ########## Classes ############### +# ################################## + +logger = getLogger(__name__) +logger.setLevel(DEBUG) + + +class TestRssPluginIntegrationsMaterialBlog(BaseTest): + """Test integration of Material Blog plugin with RSS plugin.""" + + # -- TESTS --------------------------------------------------------- + def test_plugin_config_social_cards_enabled_but_integration_disabled(self): + # default reference + cfg_mkdocs = load_config( + str( + Path( + "tests/fixtures/mkdocs_items_material_blog_enabled_but_integration_disabled.yml" + ).resolve() + ) + ) + + integration_social_cards = IntegrationMaterialBlog( + mkdocs_config=cfg_mkdocs, + switch_force=cfg_mkdocs.plugins.get("rss").config.use_material_blog, + ) + self.assertTrue(integration_social_cards.IS_THEME_MATERIAL) + self.assertTrue(integration_social_cards.IS_BLOG_PLUGIN_ENABLED) + self.assertFalse(integration_social_cards.IS_ENABLED) + + def test_plugin_config_blog_enabled(self): + # default reference + cfg_mkdocs = load_config( + str(Path("tests/fixtures/mkdocs_items_material_blog_enabled.yml").resolve()) + ) + + integration_social_cards = IntegrationMaterialBlog(mkdocs_config=cfg_mkdocs) + self.assertTrue(integration_social_cards.IS_THEME_MATERIAL) + self.assertTrue(integration_social_cards.IS_BLOG_PLUGIN_ENABLED) + self.assertTrue(integration_social_cards.IS_ENABLED) + + def test_simple_build(self): + with tempfile.TemporaryDirectory() as tmpdirname: + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path( + "tests/fixtures/mkdocs_items_material_blog_enabled.yml" + ), + output_path=tmpdirname, + strict=False, + ) + + if cli_result.exception is not None: + e = cli_result.exception + logger.debug(format_exception(type(e), e, e.__traceback__)) + + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # created items + feed_parsed = feedparser.parse(Path(tmpdirname) / "feed_rss_created.xml") + self.assertEqual(feed_parsed.bozo, 0) + + # updated items + feed_parsed = feedparser.parse(Path(tmpdirname) / "feed_rss_updated.xml") + self.assertEqual(feed_parsed.bozo, 0) + + with tempfile.TemporaryDirectory() as tmpdirname: + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path( + "tests/fixtures/mkdocs_items_material_blog_enabled_but_integration_disabled.yml" + ), + output_path=tmpdirname, + strict=False, + ) + + if cli_result.exception is not None: + e = cli_result.exception + logger.debug(format_exception(type(e), e, e.__traceback__)) + + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # created items + feed_parsed = feedparser.parse(Path(tmpdirname) / "feed_rss_created.xml") + self.assertEqual(feed_parsed.bozo, 0) + + # updated items + feed_parsed = feedparser.parse(Path(tmpdirname) / "feed_rss_updated.xml") + self.assertEqual(feed_parsed.bozo, 0) + + +# ############################################################################## +# ##### Stand alone program ######## +# ################################## +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_integrations_material_social_cards.py b/tests/test_integrations_material_social_cards.py index 4bde30aa..18299e38 100644 --- a/tests/test_integrations_material_social_cards.py +++ b/tests/test_integrations_material_social_cards.py @@ -1,4 +1,4 @@ -#! python3 # noqa E265 +#! python3 # noqa: E265 """Usage from the repo root folder: @@ -25,6 +25,7 @@ from mkdocs.config import load_config # package +from mkdocs_rss_plugin.__about__ import __title_clean__ from mkdocs_rss_plugin.integrations.theme_material_social_plugin import ( IntegrationMaterialSocialCards, ) @@ -117,6 +118,95 @@ def test_plugin_config_social_plugin_enabled_but_cards_disabled(self): self.assertFalse(integration_social_cards.IS_SOCIAL_PLUGIN_CARDS_ENABLED) self.assertFalse(integration_social_cards.IS_ENABLED) + def test_plugin_config_social_cards_enabled_with_blog_plugin(self): + # default reference + cfg_mkdocs = load_config( + str( + Path("tests/fixtures/mkdocs_item_image_social_cards_blog.yml").resolve() + ) + ) + + integration_social_cards = IntegrationMaterialSocialCards( + mkdocs_config=cfg_mkdocs + ) + self.assertTrue(integration_social_cards.IS_THEME_MATERIAL) + self.assertTrue(integration_social_cards.IS_SOCIAL_PLUGIN_ENABLED) + self.assertTrue(integration_social_cards.IS_SOCIAL_PLUGIN_CARDS_ENABLED) + self.assertTrue( + integration_social_cards.integration_material_blog.IS_BLOG_PLUGIN_ENABLED + ) + self.assertTrue(integration_social_cards.IS_ENABLED) + + def test_plugin_config_materialx_social_cards_enabled_with_blog_plugin(self): + # default reference + cfg_mkdocs = load_config( + str( + Path( + "tests/fixtures/mkdocs_materialx_item_image_social_cards_blog.yml" + ).resolve() + ) + ) + + integration_social_cards = IntegrationMaterialSocialCards( + mkdocs_config=cfg_mkdocs + ) + self.assertTrue(integration_social_cards.IS_THEME_MATERIAL) + self.assertTrue(integration_social_cards.IS_SOCIAL_PLUGIN_ENABLED) + self.assertTrue(integration_social_cards.IS_SOCIAL_PLUGIN_CARDS_ENABLED) + self.assertTrue( + integration_social_cards.integration_material_blog.IS_BLOG_PLUGIN_ENABLED + ) + self.assertTrue(integration_social_cards.IS_ENABLED) + + def test_plugin_config_social_cards_enabled_with_directory_urls_disabled(self): + """Test case described in https://github.com/Guts/mkdocs-rss-plugin/issues/319.""" + # default reference + cfg_mkdocs = load_config( + str( + Path( + "tests/fixtures/mkdocs_item_image_social_cards_blog_directory_url_disabled.yml" + ).resolve() + ) + ) + + integration_social_cards = IntegrationMaterialSocialCards( + mkdocs_config=cfg_mkdocs + ) + self.assertTrue(integration_social_cards.IS_THEME_MATERIAL) + self.assertTrue(integration_social_cards.IS_SOCIAL_PLUGIN_ENABLED) + self.assertTrue(integration_social_cards.IS_SOCIAL_PLUGIN_CARDS_ENABLED) + self.assertIsInstance(integration_social_cards.social_cards_dir, str) + # self.assertTrue(integration_social_cards.social_cards_cache_dir.is_dir()) + + with tempfile.TemporaryDirectory( + prefix=f"{__title_clean__.lower()}_" + ) as tmpdirname: + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path( + "tests/fixtures/mkdocs_item_image_social_cards_blog_directory_url_disabled.yml" + ), + output_path=tmpdirname, + strict=False, + ) + print(tmpdirname) + if cli_result.exception is not None: + e = cli_result.exception + logger.debug(format_exception(type(e), e, e.__traceback__)) + + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # created items + feed_parsed = feedparser.parse(Path(tmpdirname) / "feed_rss_created.xml") + self.assertEqual(feed_parsed.bozo, 0) + for feed_item in feed_parsed.entries: + self.assertTrue(hasattr(feed_item, "enclosures")) + + # updated items + feed_parsed = feedparser.parse(Path(tmpdirname) / "feed_rss_updated.xml") + self.assertEqual(feed_parsed.bozo, 0) + def test_simple_build(self): with tempfile.TemporaryDirectory() as tmpdirname: cli_result = self.build_docs_setup( diff --git a/tests/test_no_material.py b/tests/test_no_material.py new file mode 100644 index 00000000..66088e68 --- /dev/null +++ b/tests/test_no_material.py @@ -0,0 +1,257 @@ +#! python3 # noqa: E265 + +"""Test build without Material theme installed. + +Usage from the repo root folder: + +.. code-block:: python + + # for whole test + python -m unittest tests.test_build_without_material + # for specific test + python -m unittest tests.test_build_without_material.TestBuildWithoutMaterial.test_build_without_material_theme +""" + +# ############################################################################# +# ########## Libraries ############# +# ################################## + +# Standard library +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +# 3rd party +import feedparser + +# test suite +from tests.base import BaseTest + +# ############################################################################# +# ########## Globals ############### +# ################################## + +OUTPUT_RSS_FEED_CREATED = "feed_rss_created.xml" +OUTPUT_RSS_FEED_UPDATED = "feed_rss_updated.xml" +OUTPUT_JSON_FEED_CREATED = "feed_json_created.json" +OUTPUT_JSON_FEED_UPDATED = "feed_json_updated.json" + + +# ############################################################################# +# ########## Classes ############### +# ################################## + + +class TestBuildWithoutMaterial(BaseTest): + """Test MkDocs build with RSS plugin when Material theme is not available.""" + + # -- Standard methods -------------------------------------------------------- + @classmethod + def setUpClass(cls): + """Executed when module is loaded before any test.""" + cls.feed_image = "https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true" + + def setUp(self): + """Executed before each test.""" + pass + + def tearDown(self): + """Executed after each test.""" + pass + + @classmethod + def tearDownClass(cls): + """Executed after the last test.""" + pass + + # -- TESTS --------------------------------------------------------- + def test_build_without_material_theme(self): + """Test that the plugin works correctly when Material theme is not installed. + + This test simulates the absence of the Material theme by temporarily + blocking its import, ensuring the RSS plugin gracefully handles the missing + dependency. + """ + # Mock the material module to simulate it not being installed + with patch.dict(sys.modules, {"material": None}): + # Also need to mock the submodules + with patch.dict( + sys.modules, + { + "material.plugins": None, + "material.plugins.blog": None, + "material.plugins.blog.plugin": None, + "material.plugins.blog.structure": None, + }, + ): + with tempfile.TemporaryDirectory() as tmpdirname: + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_minimal.yml"), + output_path=tmpdirname, + strict=True, + ) + + if cli_result.exception is not None: + e = cli_result.exception + return e + + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # Verify RSS feeds were created + self.assertTrue( + Path(tmpdirname).joinpath(OUTPUT_RSS_FEED_CREATED).exists() + ) + self.assertTrue( + Path(tmpdirname).joinpath(OUTPUT_RSS_FEED_UPDATED).exists() + ) + + # Verify feeds are valid + feed_created = feedparser.parse( + Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED + ) + self.assertEqual(feed_created.bozo, 0) + + feed_updated = feedparser.parse( + Path(tmpdirname) / OUTPUT_RSS_FEED_UPDATED + ) + self.assertEqual(feed_updated.bozo, 0) + + def test_build_with_material_config_but_theme_not_installed(self): + """Test build with Material-specific config when the theme is not installed. + + This test uses a configuration file that references Material theme features + (like blog plugin) but simulates the theme not being installed. The plugin + should handle this gracefully without crashing. + """ + with patch.dict(sys.modules, {"material": None}): + with patch.dict( + sys.modules, + { + "material.plugins": None, + "material.plugins.blog": None, + "material.plugins.blog.plugin": None, + "material.plugins.blog.structure": None, + }, + ): + with tempfile.TemporaryDirectory() as tmpdirname: + # Use a config that would normally use Material features + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_complete.yml"), + output_path=tmpdirname, + strict=False, # Don't fail on warnings + ) + + if cli_result.exception is not None: + e = cli_result.exception + return e + + # Build should succeed even without Material + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # Verify feeds were created and are valid + feed_created = feedparser.parse( + Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED + ) + self.assertEqual(feed_created.bozo, 0) + self.assertGreater(len(feed_created.entries), 0) + + def test_page_processing_without_material(self): + """Test that page processing works correctly without Material theme. + + Ensures that pages are processed correctly and their metadata is extracted + even when Material-specific features are not available. + """ + with patch.dict(sys.modules, {"material": None}): + with patch.dict( + sys.modules, + { + "material.plugins": None, + "material.plugins.blog": None, + "material.plugins.blog.plugin": None, + "material.plugins.blog.structure": None, + }, + ): + with tempfile.TemporaryDirectory() as tmpdirname: + # Use complete config which includes pages with authors + cli_result = self.build_docs_setup( + testproject_path="docs", + mkdocs_yml_filepath=Path("tests/fixtures/mkdocs_complete.yml"), + output_path=tmpdirname, + strict=False, # Material theme not available + ) + + self.assertEqual(cli_result.exit_code, 0) + self.assertIsNone(cli_result.exception) + + # Parse the created feed + feed_parsed = feedparser.parse( + Path(tmpdirname) / OUTPUT_RSS_FEED_CREATED + ) + + # Verify feed has entries + self.assertGreater( + len(feed_parsed.entries), + 0, + "Feed should contain at least one entry", + ) + + # Verify entries have required fields + for entry in feed_parsed.entries: + self.assertIn( + "title", + entry, + f"Entry '{entry.get('title', 'UNKNOWN')}' missing title", + ) + self.assertIn( + "link", entry, f"Entry '{entry.title}' missing link" + ) + self.assertIn( + "description", + entry, + f"Entry '{entry.title}' missing description", + ) + self.assertIn( + "published", + entry, + f"Entry '{entry.title}' missing published date", + ) + + # Look for the specific page with complete metadata + page_with_complete_meta = next( + ( + e + for e in feed_parsed.entries + if e.title == "Page with complete meta" + ), + None, + ) + + # This page should exist and have author metadata + self.assertIsNotNone( + page_with_complete_meta, + "Page 'Page with complete meta' should be in the feed. " + f"Available pages: {', '.join([e.title for e in feed_parsed.entries])}", + ) + + self.assertIn( + "author", + page_with_complete_meta, + "Page 'Page with complete meta' should have author metadata", + ) + self.assertIsNotNone( + page_with_complete_meta.description, + "Page 'Page with complete meta' should have a description", + ) + + +# ############################################################################## +# ##### Stand alone program ######## +# ################################## +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rss_util.py b/tests/test_rss_util.py index 2ee9aeac..30b61889 100644 --- a/tests/test_rss_util.py +++ b/tests/test_rss_util.py @@ -1,4 +1,4 @@ -#! python3 # noqa E265 +#! python3 # noqa: E265 """Usage from the repo root folder: @@ -35,7 +35,7 @@ class TestRssUtil(unittest.TestCase): def setUpClass(cls): """Executed when module is loaded before any test.""" cls.config_files = sorted(Path("tests/fixtures/").glob("**/*.yml")) - cls.feed_image = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/43/Feed-icon.svg/128px-Feed-icon.svg.png" + cls.feed_image = "https://github.com/Guts/mkdocs-rss-plugin/blob/main/docs/assets/logo_rss_plugin_mkdocs.png?raw=true" cls.plg_utils = Util() def setUp(self): diff --git a/tests/test_timezoner.py b/tests/test_timezoner.py index f4a0d398..f9c91735 100644 --- a/tests/test_timezoner.py +++ b/tests/test_timezoner.py @@ -1,4 +1,4 @@ -#! python3 # noqa E265 +#! python3 # noqa: E265 """Usage from the repo root folder: