diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index ac9f882e..df6e6f61 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -17,3 +17,5 @@ db241feaa445375dc05e189e69287000ffe5fa8e 4af40e80772a58eac8969360e5caeb99e3e26e78 # Ruff UP031: Use F-string format specifiers instead of percent format 30bde3823f50b9ba8ac5996c1c46bb72031aa6b8 +# Upgrade Ruff to 0.12.x & comply with new rules +4dedf1795831db99d18941c707923ba48cc28ce7 diff --git a/.github/ISSUE_TEMPLATE/---everything-else.md b/.github/ISSUE_TEMPLATE/---everything-else.md deleted file mode 100644 index 23a3f7bd..00000000 --- a/.github/ISSUE_TEMPLATE/---everything-else.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: "\U0001F5C3 Everything Else" -about: Do you have a question/issue that does not fall into any of the other categories? -title: '' -labels: question -assignees: '' - ---- - - - - -- [ ] I have searched the [issues](https://github.com/getpelican/pelican/issues?q=is%3Aissue) (including closed ones) and believe that this is not a duplicate. -- [ ] I have searched the [documentation](https://docs.getpelican.com/) and believe that my question is not covered. -- [ ] I have carefully read the [How to Get Help](https://docs.getpelican.com/en/latest/contribute.html#how-to-get-help) section of the documentation. - -## Issue - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index a07de4ab..356d6322 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,6 +2,10 @@ # Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser blank_issues_enabled: false contact_links: +- name: '🗃️ Everything Else' + url: https://github.com/getpelican/pelican/discussions + about: | + Do you have a question/issue that does not fall into any of the other categories? - name: '💬 Pelican IRC Channel' url: https://web.libera.chat/?#pelican about: | diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a0a90b94..02edf983 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,10 @@ # See https://docs.github.com/en/free-pro-team@latest/ # github/administering-a-repository/enabling-and-disabling-version-updates - +--- version: 2 updates: - - package-ecosystem: "pip" - directory: "/" - schedule: - interval: "monthly" - open-pull-requests-limit: 10 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" + open-pull-requests-limit: 0 diff --git a/.github/workflows/github_pages.yml b/.github/workflows/github_pages.yml index 9c279ab1..c17919e9 100644 --- a/.github/workflows/github_pages.yml +++ b/.github/workflows/github_pages.yml @@ -22,10 +22,15 @@ on: default: "" description: "The GitHub repo URL of a custom theme to use, for example: 'https://github.com/seanh/sidecar.git'" type: string + theme-checkout: + required: false + default: "" + description: "Git ref (branch, tag or commit) of the theme repo to checkout. This can be used to pin the version of your theme. If not specified defaults to the theme repo's default branch." + type: string python: required: false - default: "3.12" - description: "The version of Python to use, for example: '3.12' (to use the most recent version of Python 3.12, this is faster) or '3.12.1' (to use an exact version, slower)" + default: "3.14" + description: "The version of Python to use, for example: '3.14' (to use the most recent version of Python 3.14, this is faster) or '3.14.0' (to use an exact version, slower)" type: string siteurl: required: false @@ -42,6 +47,11 @@ on: default: true description: "Whether to deploy the site. If true then build the site and deploy it. If false then just test that the site builds successfully but don't deploy anything." type: boolean + stork: + required: false + default: false + description: "Whether to add Stork search tool. If true, it will be installed on runner." + type: boolean permissions: contents: read pages: write @@ -51,17 +61,23 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ inputs.python }} - - name: Checkout theme + - name: Clone theme if: ${{ inputs.theme }} run: git clone '${{ inputs.theme }}' .theme + - name: Checkout theme ref + if: ${{ inputs.theme && inputs.theme-checkout }} + run: git -C .theme checkout '${{ inputs.theme-checkout }}' - name: Configure GitHub Pages id: pages uses: actions/configure-pages@v5 + - name: Install Stork + if: ${{ inputs.stork }} + run: cargo install stork-search --locked - name: Install requirements run: pip install ${{ inputs.requirements }} - name: Build Pelican site @@ -82,13 +98,31 @@ jobs: subprocess.run(cmd, shell=True, check=True) - name: Fix permissions run: | - chmod -c -R +rX "${{ inputs.output-path }}" | while read line; do + chmod -c -R +rX "${{ inputs.output-path }}" | while read -r line; do echo "::warning title=Invalid file permissions automatically fixed::$line" done + - name: Archive artifact + shell: sh + run: | + echo "::group::Archive artifact" + tar \ + --dereference \ + --hard-dereference \ + --directory "$OUTPUT_PATH" \ + -cvf "$RUNNER_TEMP/artifact.tar" \ + --exclude=.git \ + --exclude=.github \ + . + echo "::endgroup::" + env: + OUTPUT_PATH: ${{ inputs.output-path }} - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-artifact@v5 with: - path: ${{ inputs.output-path }} + name: github-pages + path: ${{ runner.temp }}/artifact.tar + retention-days: 1 + if-no-files-found: error deploy: concurrency: group: "pages" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e07caa72..06d90677 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -15,17 +15,12 @@ jobs: strategy: matrix: os: [ubuntu, macos, windows] - python: ["3.10", "3.11", "3.12"] - include: - - os: ubuntu - python: "3.8" - - os: ubuntu - python: "3.9" + python: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} cache: "pip" @@ -52,7 +47,7 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pdm-project/setup-pdm@v4 with: python-version: "3.11" @@ -70,7 +65,7 @@ jobs: name: Test build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: pdm-project/setup-pdm@v4 with: python-version: "3.11" @@ -88,9 +83,9 @@ jobs: runs-on: ubuntu-latest 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.11" cache: "pip" @@ -100,7 +95,7 @@ jobs: - name: Check run: tox -e docs - name: cache the docs for inspection - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: docs path: docs/_build/html/ @@ -117,12 +112,12 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: token: ${{ secrets.GH_TOKEN }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 716ddd89..b5291afb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,14 +16,14 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # ruff version should match the one in pyproject.toml - rev: v0.7.2 + rev: v0.12.7 hooks: - - id: ruff + - id: ruff-check args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/rtts/djhtml - rev: '3.0.7' + rev: '3.0.8' hooks: - id: djhtml - id: djcss diff --git a/.readthedocs.yaml b/.readthedocs.yaml index b18ff005..cd5f2060 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -9,7 +9,7 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.10" + python: "3.11" # Build HTML & PDF formats formats: diff --git a/docs/_static/theme-basic.zip b/docs/_static/theme-basic.zip deleted file mode 100644 index d1e4754a..00000000 Binary files a/docs/_static/theme-basic.zip and /dev/null differ diff --git a/docs/_templates/page.html b/docs/_templates/page.html index 0fbfdf7d..c0c31744 100644 --- a/docs/_templates/page.html +++ b/docs/_templates/page.html @@ -4,14 +4,16 @@ {{ super() }} {% include "partials/icons.html" %} - - - - + + + + + + + {%- trans -%} + Skip to content + {%- endtrans -%} + {% if theme_announcement -%}
Custom footer
+ {{ super() }} + {% endblock %} + +1. On the first line, we extend the ``base.html`` template from the ``simple`` + theme, so we don't have to rewrite the entire file. +2. On the third line, we open the ``footer`` block which has already been + defined in the ``simple`` theme. +3. On the fourth line, we insert the extra footer content. +4. On the fifth line, the function ``super()`` keeps the content previously + inserted in the ``head`` block. +5. On the last line, we close the ``footer`` block. + +This file will be extended by all the other templates, so the custom footer +will appear on all pages. .. Links diff --git a/docs/tips.rst b/docs/tips.rst index 4df20ae3..83d09a5e 100644 --- a/docs/tips.rst +++ b/docs/tips.rst @@ -205,53 +205,63 @@ the workflow, for example: Here's the complete list of workflow inputs: -+------------------+----------+--------------------------------------------+--------+---------------+ -| Name | Required | Description | Type | Default | -+==================+==========+============================================+========+===============+ -| ``settings`` | Yes | The path to your Pelican settings | string | | -| | | file (``pelican``'s | | | -| | | ``--settings`` option), | | | -| | | for example: ``"publishconf.py"`` | | | -+------------------+----------+--------------------------------------------+--------+---------------+ -| ``requirements`` | No | The Python requirements to | string | ``"pelican"`` | -| | | install, for example to enable | | | -| | | markdown and typogrify use: | | | -| | | ``"pelican[markdown] typogrify"`` | | | -| | | or if you have a requirements | | | -| | | file: ``"-r requirements.txt"`` | | | -+------------------+----------+--------------------------------------------+--------+---------------+ -| ``output-path`` | No | Where to output the generated | string | ``"output/"`` | -| | | files (``pelican``'s ``--output`` | | | -| | | option) | | | -+------------------+----------+--------------------------------------------+--------+---------------+ -| ``theme`` | No | The GitHub repo URL of a custom | string | ``""`` | -| | | theme to use, for example: | | | -| | | ``"https://github.com/seanh/sidecar.git"`` | | | -+------------------+----------+--------------------------------------------+--------+---------------+ -| ``python`` | No | The version of Python to use to build the | string | ``"3.12"`` | -| | | site, for example: ``"3.12"`` (to use the | | | -| | | most recent version of Python 3.12, this | | | -| | | is faster) or ``"3.12.1"`` (to use an | | | -| | | exact version, slower) | | | -+------------------+----------+--------------------------------------------+--------+---------------+ -| ``siteurl`` | No | The base URL of your web site (Pelican's | string | The URL of | -| | | ``SITEURL`` setting). If not passed this | | your GitHub | -| | | will default to the URL of your GitHub | | Pages site. | -| | | Pages site, which is correct in most | | | -| | | cases. | | | -+------------------+----------+--------------------------------------------+--------+---------------+ -| ``feed_domain`` | No | The domain to be prepended to feed URLs | string | The URL of | -| | | (Pelican's ``FEED_DOMAIN`` setting). If | | your GitHub | -| | | not passed this will default to the URL of | | Pages site. | -| | | your GitHub Pages site, which is correct | | | -| | | in most cases. | | | -+------------------+----------+--------------------------------------------+--------+---------------+ -| ``deploy`` | No | This is used to determine whether you will | bool | ``true`` | -| | | deploy the site or not to GitHub Pages. | | | -| | | This is most useful if you want to test a | | | -| | | change to your website in a pull request | | | -| | | before deploying those change. | | | -+------------------+----------+--------------------------------------------+--------+---------------+ ++--------------------+----------+--------------------------------------------+--------+---------------+ +| Name | Required | Description | Type | Default | ++====================+==========+============================================+========+===============+ +| ``settings`` | Yes | The path to your Pelican settings | string | | +| | | file (``pelican``'s | | | +| | | ``--settings`` option), | | | +| | | for example: ``"publishconf.py"`` | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``requirements`` | No | The Python requirements to | string | ``"pelican"`` | +| | | install, for example to enable | | | +| | | markdown and typogrify use: | | | +| | | ``"pelican[markdown] typogrify"`` | | | +| | | or if you have a requirements | | | +| | | file: ``"-r requirements.txt"`` | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``output-path`` | No | Where to output the generated | string | ``"output/"`` | +| | | files (``pelican``'s ``--output`` | | | +| | | option) | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``theme`` | No | The GitHub repo URL of a custom | string | ``""`` | +| | | theme to use, for example: | | | +| | | ``"https://github.com/seanh/sidecar.git"`` | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``theme-checkout`` | No | Git ref (branch, tag or commit) of the | string | ``""`` | +| | | theme repo to checkout. This can be used | | | +| | | to pin the version of your theme. If not | | | +| | | specified defaults to the theme repo's | | | +| | | default branch. | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``python`` | No | The version of Python to use to build the | string | ``"3.12"`` | +| | | site, for example: ``"3.12"`` (to use the | | | +| | | most recent version of Python 3.12, this | | | +| | | is faster) or ``"3.12.1"`` (to use an | | | +| | | exact version, slower) | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``siteurl`` | No | The base URL of your web site (Pelican's | string | The URL of | +| | | ``SITEURL`` setting). If not passed this | | your GitHub | +| | | will default to the URL of your GitHub | | Pages site. | +| | | Pages site, which is correct in most | | | +| | | cases. | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``feed_domain`` | No | The domain to be prepended to feed URLs | string | The URL of | +| | | (Pelican's ``FEED_DOMAIN`` setting). If | | your GitHub | +| | | not passed this will default to the URL of | | Pages site. | +| | | your GitHub Pages site, which is correct | | | +| | | in most cases. | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``deploy`` | No | This is used to determine whether you will | bool | ``true`` | +| | | deploy the site or not to GitHub Pages. | | | +| | | This is most useful if you want to test a | | | +| | | change to your website in a pull request | | | +| | | before deploying those change. | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ +| ``stork`` | No | This is used to determine whether Stork | bool | ``false`` | +| | | will be installed on the runner to be able | | | +| | | to build a site with Stork search enabled | | | ++--------------------+----------+--------------------------------------------+--------+---------------+ Testing Your Build in a GitHub Pull Request """"""""""""""""""""""""""""""""""""""""""" diff --git a/pelican/__init__.py b/pelican/__init__.py index 89a63e84..a69e574b 100644 --- a/pelican/__init__.py +++ b/pelican/__init__.py @@ -37,7 +37,7 @@ from pelican.writers import Writer try: __version__ = importlib.metadata.version("pelican") -except Exception: +except importlib.metadata.PackageNotFoundError: __version__ = "unknown" DEFAULT_CONFIG_NAME = "pelicanconf.py" @@ -78,11 +78,10 @@ class Pelican: try: plugin.register() self.plugins.append(plugin) - except Exception as e: - logger.error( - "Cannot register plugin `%s`\n%s", + except Exception: + logger.exception( + "Cannot register plugin `%s`", name, - e, stacklevel=2, ) if self.settings.get("DEBUG", False): @@ -252,12 +251,13 @@ class Pelican: class PrintSettings(argparse.Action): def __call__(self, parser, namespace, values, option_string): + del option_string # Unused argument init_logging(name=__name__) try: instance, settings = get_instance(namespace) except Exception as e: - logger.critical("%s: %s", e.__class__.__name__, e) + logger.critical("%s", e.__class__.__name__, exc_info=True) console.print_exception() sys.exit(getattr(e, "exitcode", 1)) @@ -266,7 +266,7 @@ class PrintSettings(argparse.Action): for setting in values: if setting in settings: # Only add newline between setting name and value if dict - if isinstance(settings[setting], (dict, tuple, list)): + if isinstance(settings[setting], dict | tuple | list): setting_format = "\n{}:\n{}" else: setting_format = "\n{}: {}" @@ -287,6 +287,7 @@ class PrintSettings(argparse.Action): class ParseOverrides(argparse.Action): def __call__(self, parser, namespace, values, option_string=None): + del parser, option_string # Unused arguments overrides = {} for item in values: try: @@ -402,8 +403,7 @@ def parse_arguments(argv=None): "--autoreload", dest="autoreload", action="store_true", - help="Relaunch pelican each time a modification occurs" - " on the content files.", + help="Relaunch pelican each time a modification occurs on the content files.", ) parser.add_argument( @@ -446,8 +446,7 @@ def parse_arguments(argv=None): choices=("errors", "warnings"), default="", help=( - "Exit the program with non-zero status if any " - "errors/warnings encountered." + "Exit the program with non-zero status if any errors/warnings encountered." ), ) @@ -621,7 +620,8 @@ def listen(server, port, output, excqueue=None): except Exception as e: if excqueue is not None: excqueue.put(traceback.format_exception_only(type(e), e)[-1]) - return + else: + logging.exception("Listening aborted unexpectedly.") except KeyboardInterrupt: httpd.socket.close() @@ -680,7 +680,7 @@ def main(argv=None): except KeyboardInterrupt: logger.warning("Keyboard interrupt received. Exiting.") except Exception as e: - logger.critical("%s: %s", e.__class__.__name__, e) + logger.critical("%s: %s", e.__class__.__name__, e, exc_info=True) if args.verbosity == logging.DEBUG: console.print_exception() diff --git a/pelican/cache.py b/pelican/cache.py index d1f8550e..8bd34268 100644 --- a/pelican/cache.py +++ b/pelican/cache.py @@ -1,3 +1,4 @@ +import gzip import hashlib import logging import os @@ -22,8 +23,6 @@ class FileDataCacher: self._cache_path = os.path.join(self.settings["CACHE_PATH"], cache_name) self._cache_data_policy = caching_policy if self.settings["GZIP_CACHE"]: - import gzip - self._cache_open = gzip.open else: self._cache_open = open diff --git a/pelican/contents.py b/pelican/contents.py index 2abdf492..11459f77 100644 --- a/pelican/contents.py +++ b/pelican/contents.py @@ -4,9 +4,8 @@ import locale import logging import os import re -from datetime import timezone from html import unescape -from typing import Any, Dict, Optional, Set, Tuple +from typing import Any from urllib.parse import ParseResult, unquote, urljoin, urlparse, urlunparse try: @@ -28,6 +27,7 @@ from pelican.utils import ( sanitised_join, set_date_tzinfo, slugify, + strip_toc_elements_from_html, truncate_html_paragraphs, truncate_html_words, ) @@ -46,8 +46,8 @@ class Content: """ - default_template: Optional[str] = None - mandatory_properties: Tuple[str, ...] = () + default_template: str | None = None + mandatory_properties: tuple[str, ...] = () @deprecated_attribute(old="filename", new="source_path", since=(3, 2, 0)) def filename(): @@ -56,10 +56,10 @@ class Content: def __init__( self, content: str, - metadata: Optional[Dict[str, Any]] = None, - settings: Optional[Settings] = None, - source_path: Optional[str] = None, - context: Optional[Dict[Any, Any]] = None, + metadata: dict[str, Any] | None = None, + settings: Settings | None = None, + source_path: str | None = None, + context: dict[Any, Any] | None = None, ): if metadata is None: metadata = {} @@ -226,7 +226,7 @@ class Content: ) @property - def url_format(self) -> Dict[str, Any]: + def url_format(self) -> dict[str, Any]: """Returns the URL, formatted with the proper values""" metadata = copy.copy(self.metadata) path = self.metadata.get("path", self.get_relative_source_path()) @@ -242,7 +242,7 @@ class Content: ) return metadata - def _expand_settings(self, key: str, klass: Optional[str] = None) -> str: + def _expand_settings(self, key: str, klass: str | None = None) -> str: if not klass: klass = self.__class__.__name__ fq_key = (f"{klass}_{key}").upper() @@ -282,10 +282,10 @@ class Content: # XXX Put this in a different location. if what in {"filename", "static", "attach"}: - def _get_linked_content(key: str, url: ParseResult) -> Optional[Content]: + def _get_linked_content(key: str, url: ParseResult) -> Content | None: nonlocal value - def _find_path(path: str) -> Optional[Content]: + def _find_path(path: str) -> Content | None: if path.startswith("/"): path = path[1:] else: @@ -343,8 +343,7 @@ class Content: value.geturl(), extra={ "limit_msg": ( - "Other resources were not found " - "and their urls not replaced" + "Other resources were not found and their urls not replaced" ) }, ) @@ -397,7 +396,7 @@ class Content: hrefs = self._get_intrasite_link_regex() return hrefs.sub(lambda m: self._link_replacer(siteurl, m), content) - def get_static_links(self) -> Set[str]: + def get_static_links(self) -> set[str]: static_links = set() hrefs = self._get_intrasite_link_regex() for m in hrefs.finditer(self._content): @@ -433,7 +432,7 @@ class Content: return self.get_content(self.get_siteurl()) @memoized - def get_summary(self, siteurl: str) -> str: + def get_summary(self, _siteurl: str) -> str: """Returns the summary of an article. This is based on the summary metadata if set, otherwise truncate the @@ -448,13 +447,19 @@ class Content: content = truncate_html_paragraphs(self.content, max_paragraphs) if self.settings["SUMMARY_MAX_LENGTH"] is None: - return content + summary = content + else: + summary = truncate_html_words( + content, + self.settings["SUMMARY_MAX_LENGTH"], + self.settings["SUMMARY_END_SUFFIX"], + ) - return truncate_html_words( - content, - self.settings["SUMMARY_MAX_LENGTH"], - self.settings["SUMMARY_END_SUFFIX"], - ) + # Strip TOC elements that would contain broken links in summary context + # TOC anchors only work in full article view, not in summaries/excerpts + summary = strip_toc_elements_from_html(summary) + + return summary @property def summary(self) -> str: @@ -496,9 +501,7 @@ class Content: else: return self.default_template - def get_relative_source_path( - self, source_path: Optional[str] = None - ) -> Optional[str]: + def get_relative_source_path(self, source_path: str | None = None) -> str | None: """Return the relative path (from the content path) to the given source_path. @@ -554,6 +557,7 @@ class SkipStub(Content): def __init__( self, content, metadata=None, settings=None, source_path=None, context=None ): + del content, metadata, settings, context # Unused arguments self.source_path = source_path def is_valid(self): @@ -580,7 +584,7 @@ class Page(Content): class Article(Content): - mandatory_properties = ("title", "date", "category") + mandatory_properties = ("title", "date") allowed_statuses = ("published", "hidden", "draft", "skip") default_status = "published" default_template = "article" @@ -593,7 +597,7 @@ class Article(Content): if self.date.tzinfo is None: now = datetime.datetime.now() else: - now = datetime.datetime.utcnow().replace(tzinfo=timezone.utc) + now = datetime.datetime.now(datetime.UTC) if self.date > now: self.status = "draft" diff --git a/pelican/generators.py b/pelican/generators.py index 207c9296..515257c4 100644 --- a/pelican/generators.py +++ b/pelican/generators.py @@ -7,7 +7,6 @@ from collections import defaultdict from functools import partial from itertools import chain, groupby from operator import attrgetter -from typing import List, Optional, Set from jinja2 import ( BaseLoader, @@ -31,6 +30,7 @@ from pelican.utils import ( posixize_path, process_translations, ) +from pelican.writers import FileOverwriteFailedError logger = logging.getLogger(__name__) @@ -158,8 +158,8 @@ class Generator: return False def get_files( - self, paths, exclude: Optional[List[str]] = None, extensions=None - ) -> Set[str]: + self, paths, exclude: list[str] | None = None, extensions=None + ) -> set[str]: """Return a list of files to use, based on rules :param paths: the list pf paths to search (relative to self.path) @@ -253,7 +253,7 @@ class Generator: # return the name of the class for logging purposes return self.__class__.__name__ - def _check_disabled_readers(self, paths, exclude: Optional[List[str]]) -> None: + def _check_disabled_readers(self, paths, exclude: list[str] | None) -> None: """Log warnings for files that would have been processed by disabled readers.""" for fil in self.get_files( paths, exclude=exclude, extensions=self.readers.disabled_extensions @@ -296,6 +296,7 @@ class _FileLoader(BaseLoader): self.fullpath = os.path.join(basedir, path) def get_source(self, environment, template): + del environment # Unused argument if template != self.path or not os.path.exists(self.fullpath): raise TemplateNotFound(template) mtime = os.path.getmtime(self.fullpath) @@ -512,7 +513,7 @@ class ArticlesGenerator(CachingGenerator): self.get_template(article.template), self.context, article=article, - category=article.category, + category=getattr(article, "category", None), override_output=hasattr(article, "override_save_as"), url=article.url, blog=True, @@ -570,57 +571,93 @@ class ArticlesGenerator(CachingGenerator): tag_template = self.get_template("tag") for tag, articles in self.tags.items(): dates = [article for article in self.dates if article in articles] - write( - tag.save_as, - tag_template, - self.context, - tag=tag, - url=tag.url, - articles=articles, - dates=dates, - template_name="tag", - blog=True, - page_name=tag.page_name, - all_articles=self.articles, - ) + try: + write( + tag.save_as, + tag_template, + self.context, + tag=tag, + url=tag.url, + articles=articles, + dates=dates, + template_name="tag", + blog=True, + page_name=tag.page_name, + all_articles=self.articles, + ) + except FileOverwriteFailedError: + if not tag.slug: + logger.info( + 'Tag "%s" has an invalid slug; skipping writing tag page...', + tag, + extra={"limit_msg": "Further tags with invalid slugs."}, + ) + continue + else: + logger.error('Failed to write Tag page for "%s".', tag) + raise def generate_categories(self, write): """Generate category pages.""" category_template = self.get_template("category") for cat, articles in self.categories: dates = [article for article in self.dates if article in articles] - write( - cat.save_as, - category_template, - self.context, - url=cat.url, - category=cat, - articles=articles, - dates=dates, - template_name="category", - blog=True, - page_name=cat.page_name, - all_articles=self.articles, - ) + try: + write( + cat.save_as, + category_template, + self.context, + url=cat.url, + category=cat, + articles=articles, + dates=dates, + template_name="category", + blog=True, + page_name=cat.page_name, + all_articles=self.articles, + ) + except FileOverwriteFailedError: + if not cat.slug: + logger.info( + 'Category "%s" has an invalid slug; skipping writing category page...', + cat, + extra={"limit_msg": "Further categories with invalid slugs."}, + ) + continue + else: + logger.error('Failed to write Category page for "%s".', cat) + raise def generate_authors(self, write): """Generate Author pages.""" author_template = self.get_template("author") for aut, articles in self.authors: dates = [article for article in self.dates if article in articles] - write( - aut.save_as, - author_template, - self.context, - url=aut.url, - author=aut, - articles=articles, - dates=dates, - template_name="author", - blog=True, - page_name=aut.page_name, - all_articles=self.articles, - ) + try: + write( + aut.save_as, + author_template, + self.context, + url=aut.url, + author=aut, + articles=articles, + dates=dates, + template_name="author", + blog=True, + page_name=aut.page_name, + all_articles=self.articles, + ) + except FileOverwriteFailedError: + if not aut.slug: + logger.info( + 'Author "%s" has an invalid slug; skipping writing author page...', + aut, + extra={"limit_msg": "Further authors with invalid slugs."}, + ) + continue + else: + logger.error('Failed to write Author page for "%s".', aut) + raise def generate_drafts(self, write): """Generate drafts pages.""" @@ -680,11 +717,10 @@ class ArticlesGenerator(CachingGenerator): context_signal=signals.article_generator_context, context_sender=self, ) - except Exception as e: - logger.error( - "Could not process %s\n%s", + except Exception: + logger.exception( + "Could not process %s", f, - e, exc_info=self.settings.get("DEBUG", False), ) self._add_failed_source_path(f) @@ -728,7 +764,8 @@ class ArticlesGenerator(CachingGenerator): for article in self.articles: # only main articles are listed in categories and tags # not translations or hidden articles - self.categories[article.category].append(article) + if hasattr(article, "category"): + self.categories[article.category].append(article) if hasattr(article, "tags"): for tag in article.tags: self.tags[tag].append(article) @@ -895,11 +932,10 @@ class PagesGenerator(CachingGenerator): context_signal=signals.page_generator_context, context_sender=self, ) - except Exception as e: - logger.error( - "Could not process %s\n%s", + except Exception: + logger.exception( + "Could not process %s", f, - e, exc_info=self.settings.get("DEBUG", False), ) self._add_failed_source_path(f) @@ -1022,6 +1058,7 @@ class StaticGenerator(Generator): signals.static_generator_finalized.send(self) def generate_output(self, writer): + del writer # Unused argument self._copy_paths( self.settings["THEME_STATIC_PATHS"], self.theme, @@ -1131,6 +1168,7 @@ class SourceFileGenerator(Generator): copy(obj.source_path, dest) def generate_output(self, writer=None): + del writer # Unused argument logger.info("Generating source files...") for obj in chain(self.context["articles"], self.context["pages"]): self._create_source(obj) diff --git a/pelican/log.py b/pelican/log.py index edf2f182..7d39d0fa 100644 --- a/pelican/log.py +++ b/pelican/log.py @@ -1,4 +1,5 @@ import logging +import warnings from collections import defaultdict from rich.console import Console @@ -9,6 +10,10 @@ __all__ = ["init"] console = Console() +class FilteredMessage(Exception): + """An exception to signal whether a message was filtered or not.""" + + class LimitFilter(logging.Filter): """ Remove duplicates records, and limit the number of records in the same @@ -82,44 +87,35 @@ class LimitLogger(logging.Logger): class FatalLogger(LimitLogger): - warnings_fatal = False - errors_fatal = False + fatal_lvl = logging.CRITICAL + 1 # i.e. No levels by default - def warning(self, *args, stacklevel=1, **kwargs): - """ - Displays a logging warning. + def filter(self, record): + """A hack to let _log() know whether a message was logged or not.""" + result = super().filter(record) + if not result: + raise FilteredMessage + return result - Wrapping it here allows Pelican to filter warnings, and conditionally - make warnings fatal. + def _log( + self, + level, + msg, + args, + exc_info=None, + extra=None, + stack_info=False, + stacklevel=1, + ): + try: + super()._log(level, msg, args, exc_info, extra, stack_info, stacklevel + 1) + except FilteredMessage: + # Avoid raising RuntimeError below if no log was emitted. + return - Args: - stacklevel (int): the stacklevel that would be used to display the - calling location, except for this function. Adjusting the - stacklevel allows you to see the "true" calling location of the - warning, rather than this wrapper location. - """ - stacklevel += 1 - super().warning(*args, stacklevel=stacklevel, **kwargs) - if FatalLogger.warnings_fatal: - raise RuntimeError("Warning encountered") - - def error(self, *args, stacklevel=1, **kwargs): - """ - Displays a logging error. - - Wrapping it here allows Pelican to filter errors, and conditionally - make errors non-fatal. - - Args: - stacklevel (int): the stacklevel that would be used to display the - calling location, except for this function. Adjusting the - stacklevel allows you to see the "true" calling location of the - error, rather than this wrapper location. - """ - stacklevel += 1 - super().error(*args, stacklevel=stacklevel, **kwargs) - if FatalLogger.errors_fatal: - raise RuntimeError("Error encountered") + # __init__.py:main() catches this exception then does its own critical log. + # We need to avoid throwing the exception a second time here. + if level >= FatalLogger.fatal_lvl and level != logging.CRITICAL: + raise RuntimeError("Warning or error encountered") logging.setLoggerClass(FatalLogger) @@ -136,8 +132,10 @@ def init( name=None, logs_dedup_min_level=None, ): - FatalLogger.warnings_fatal = fatal.startswith("warning") - FatalLogger.errors_fatal = bool(fatal) + if fatal: + FatalLogger.fatal_lvl = ( + logging.WARNING if fatal.startswith("warning") else logging.ERROR + ) LOG_FORMAT = "%(message)s" logging.basicConfig( @@ -156,8 +154,6 @@ def init( def log_warnings(): - import warnings - logging.captureWarnings(True) warnings.simplefilter("default", DeprecationWarning) init(logging.DEBUG, name="py.warnings") diff --git a/pelican/paginator.py b/pelican/paginator.py index 4a7c1aa2..9dcbb9d7 100644 --- a/pelican/paginator.py +++ b/pelican/paginator.py @@ -53,7 +53,7 @@ class Paginator: "Returns the total number of pages." if self._num_pages is None: hits = max(1, self.count - self.orphans) - self._num_pages = int(ceil(hits / (float(self.per_page) or 1))) + self._num_pages = ceil(hits / (float(self.per_page) or 1)) return self._num_pages num_pages = property(_get_num_pages) diff --git a/pelican/plugins/_utils.py b/pelican/plugins/_utils.py index 9dfc8f81..e21201a7 100644 --- a/pelican/plugins/_utils.py +++ b/pelican/plugins/_utils.py @@ -19,7 +19,7 @@ def iter_namespace(ns_pkg): def get_namespace_plugins(ns_pkg=None): if ns_pkg is None: - import pelican.plugins as ns_pkg + import pelican.plugins as ns_pkg # noqa: PLC0415 return { name: importlib.import_module(name) @@ -29,7 +29,7 @@ def get_namespace_plugins(ns_pkg=None): def list_plugins(ns_pkg=None): - from pelican.log import init as init_logging + from pelican.log import init as init_logging # noqa: PLC0415 init_logging(logging.INFO) ns_plugins = get_namespace_plugins(ns_pkg) diff --git a/pelican/readers.py b/pelican/readers.py index 59aa7ca3..508d655f 100644 --- a/pelican/readers.py +++ b/pelican/readers.py @@ -45,15 +45,15 @@ DUPLICATES_DEFINITIONS_ALLOWED = { METADATA_PROCESSORS = { "tags": lambda x, y: ([Tag(tag, y) for tag in ensure_metadata_list(x)] or _DISCARD), - "date": lambda x, y: get_date(x.replace("_", " ")), - "modified": lambda x, y: get_date(x), - "status": lambda x, y: x.strip() or _DISCARD, + "date": lambda x, _y: get_date(x.replace("_", " ")), + "modified": lambda x, _y: get_date(x), + "status": lambda x, _y: x.strip() or _DISCARD, "category": lambda x, y: _process_if_nonempty(Category, x, y), "author": lambda x, y: _process_if_nonempty(Author, x, y), "authors": lambda x, y: ( [Author(author, y) for author in ensure_metadata_list(x)] or _DISCARD ), - "slug": lambda x, y: x.strip() or _DISCARD, + "slug": lambda x, _y: x.strip() or _DISCARD, } logger = logging.getLogger(__name__) @@ -121,6 +121,7 @@ class BaseReader: def read(self, source_path): "No-op parser" + del source_path # Unused argument content = None metadata = {} return content, metadata @@ -165,6 +166,7 @@ class PelicanHTMLTranslator(HTMLTranslator): self.body.append(self.starttag(node, "abbr", "", **attrs)) def depart_abbreviation(self, node): + del node # Unused argument self.body.append("") def visit_image(self, node): @@ -266,9 +268,11 @@ class RstReader(BaseReader): extra_params.update(user_params) pub = docutils.core.Publisher( - writer=self.writer_class(), destination_class=docutils.io.StringOutput + reader="standalone", + parser="restructuredtext", + writer=self.writer_class(), + destination_class=docutils.io.StringOutput, ) - pub.set_components("standalone", "restructuredtext", "html") pub.process_programmatic_settings(None, extra_params, None) pub.set_source(source_path=source_path) pub.publish() @@ -630,8 +634,9 @@ class Readers(FileStampDataCacher): # eventually filter the content with typogrify if asked so if self.settings["TYPOGRIFY"]: - import smartypants - from typogrify.filters import typogrify + # typogrify is an optional feature, user may not have it installed + import smartypants # noqa: PLC0415 + from typogrify.filters import typogrify # noqa: PLC0415 typogrify_dashes = self.settings["TYPOGRIFY_DASHES"] if typogrify_dashes == "oldschool": @@ -648,11 +653,22 @@ class Readers(FileStampDataCacher): smartypants.Attr.default |= smartypants.Attr.w def typogrify_wrapper(text): - """Ensures ignore_tags feature is backward compatible""" + """Ensure compatibility with older versions of Typogrify. + + The 'TYPOGRIFY_IGNORE_TAGS' and/or 'TYPOGRIFY_OMIT_FILTERS' + settings will be ignored if the installed version of Typogrify + doesn't have the corresponding features.""" try: - return typogrify(text, self.settings["TYPOGRIFY_IGNORE_TAGS"]) + return typogrify( + text, + self.settings["TYPOGRIFY_IGNORE_TAGS"], + **dict.fromkeys(self.settings["TYPOGRIFY_OMIT_FILTERS"], False), + ) except TypeError: - return typogrify(text) + try: + typogrify(text, self.settings["TYPOGRIFY_IGNORE_TAGS"]) + except TypeError: + return typogrify(text) if content: content = typogrify_wrapper(content) @@ -730,7 +746,7 @@ def default_metadata(settings=None, process=None): if process: value = process(name, value) metadata[name] = value - if "DEFAULT_CATEGORY" in settings: + if "DEFAULT_CATEGORY" in settings and settings.get("CATEGORY_SAVE_AS"): value = settings["DEFAULT_CATEGORY"] if process: value = process("category", value) @@ -795,7 +811,7 @@ def parse_path_metadata(source_path, settings=None, process=None): checks = [] for key, data in [("FILENAME_METADATA", base), ("PATH_METADATA", source_path)]: checks.append((settings.get(key, None), data)) - if settings.get("USE_FOLDER_AS_CATEGORY", None): + if settings.get("USE_FOLDER_AS_CATEGORY") and settings.get("CATEGORY_SAVE_AS"): checks.append(("(?P 8def run(self):
self.assert_has_content()
10 try:
lexer = get_lexer_by_name(self.arguments[0])
12 except ValueError:
# no lexer found - use the text one instead of an exception
14 lexer = TextLexer()
16 if ('linenos' in self.options and
self.options['linenos'] not in ('table', 'inline')):
18 self.options['linenos'] = 'table'
20 for flag in ('nowrap', 'nobackground', 'anchorlinenos'):
if flag in self.options:
22 self.options[flag] = True
24 # noclasses should already default to False, but just in case...
formatter = HtmlFormatter(noclasses=False, **self.options)
26 parsed = highlight('\n'.join(self.content), lexer, formatter)
return [nodes.raw('', parsed, format='html')]
8def run(self):
self.assert_has_content()
10 try:
lexer = get_lexer_by_name(self.arguments[0])
12 except ValueError:
# no lexer found - use the text one instead of an exception
14 lexer = TextLexer()
16 if ('linenos' in self.options and
self.options['linenos'] not in ('table', 'inline')):
18 self.options['linenos'] = 'table'
20 for flag in ('nowrap', 'nobackground', 'anchorlinenos'):
if flag in self.options:
22 self.options[flag] = True
24 # noclasses should already default to False, but just in case...
formatter = HtmlFormatter(noclasses=False, **self.options)
26 parsed = highlight('\n'.join(self.content), lexer, formatter)
return [nodes.raw('', parsed, format='html')]
Lovely.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8def run(self):
self.assert_has_content()
10 try:
lexer = get_lexer_by_name(self.arguments[0])
12 except ValueError:
# no lexer found - use the text one instead of an exception
14 lexer = TextLexer()
16 if ('linenos' in self.options and
self.options['linenos'] not in ('table', 'inline')):
18 self.options['linenos'] = 'table'
20 for flag in ('nowrap', 'nobackground', 'anchorlinenos'):
if flag in self.options:
22 self.options[flag] = True
24 # noclasses should already default to False, but just in case...
formatter = HtmlFormatter(noclasses=False, **self.options)
26 parsed = highlight('\n'.join(self.content), lexer, formatter)
return [nodes.raw('', parsed, format='html')]
8def run(self):
self.assert_has_content()
10 try:
lexer = get_lexer_by_name(self.arguments[0])
12 except ValueError:
# no lexer found - use the text one instead of an exception
14 lexer = TextLexer()
16 if ('linenos' in self.options and
self.options['linenos'] not in ('table', 'inline')):
18 self.options['linenos'] = 'table'
20 for flag in ('nowrap', 'nobackground', 'anchorlinenos'):
if flag in self.options:
22 self.options[flag] = True
24 # noclasses should already default to False, but just in case...
formatter = HtmlFormatter(noclasses=False, **self.options)
26 parsed = highlight('\n'.join(self.content), lexer, formatter)
return [nodes.raw('', parsed, format='html')]
Lovely.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 8def run(self):
self.assert_has_content()
10 try:
lexer = get_lexer_by_name(self.arguments[0])
12 except ValueError:
# no lexer found - use the text one instead of an exception
14 lexer = TextLexer()
16 if ('linenos' in self.options and
self.options['linenos'] not in ('table', 'inline')):
18 self.options['linenos'] = 'table'
20 for flag in ('nowrap', 'nobackground', 'anchorlinenos'):
if flag in self.options:
22 self.options[flag] = True
24 # noclasses should already default to False, but just in case...
formatter = HtmlFormatter(noclasses=False, **self.options)
26 parsed = highlight('\n'.join(self.content), lexer, formatter)
return [nodes.raw('', parsed, format='html')]
8def run(self):
self.assert_has_content()
10 try:
lexer = get_lexer_by_name(self.arguments[0])
12 except ValueError:
# no lexer found - use the text one instead of an exception
14 lexer = TextLexer()
16 if ('linenos' in self.options and
self.options['linenos'] not in ('table', 'inline')):
18 self.options['linenos'] = 'table'
20 for flag in ('nowrap', 'nobackground', 'anchorlinenos'):
if flag in self.options:
22 self.options[flag] = True
24 # noclasses should already default to False, but just in case...
formatter = HtmlFormatter(noclasses=False, **self.options)
26 parsed = highlight('\n'.join(self.content), lexer, formatter)
return [nodes.raw('', parsed, format='html')]
Lovely.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Table of contents
" + '' + "First paragraph of real content.
" + ) + page_kwargs["content"] = toc_content + settings["SUMMARY_MAX_LENGTH"] = None + page = Page(**page_kwargs) + self.assertNotIn('First paragraph of real content.
", page.summary) + def test_summary_get_summary_warning(self): """calling ._get_summary() should issue a warning""" page_kwargs = self._copy_page_kwargs() @@ -310,18 +326,16 @@ class TestPage(TestBase): # I doubt this can work on all platforms ... if platform == "win32": - locale = "jpn" + the_locale = "jpn" else: - locale = "ja_JP.utf8" - page_kwargs["settings"]["DATE_FORMATS"] = {"jp": (locale, "%Y-%m-%d(%a)")} + the_locale = "ja_JP.utf8" + page_kwargs["settings"]["DATE_FORMATS"] = {"jp": (the_locale, "%Y-%m-%d(%a)")} page_kwargs["metadata"]["lang"] = "jp" - import locale as locale_module - try: page = Page(**page_kwargs) self.assertEqual(page.locale_date, "2015-09-13(\u65e5)") - except locale_module.Error: + except locale.Error: # The constructor of ``Page`` will try to set the locale to # ``ja_JP.utf8``. But this attempt will failed when there is no # such locale in the system. You can see which locales there are @@ -329,7 +343,7 @@ class TestPage(TestBase): # # Until we find some other method to test this functionality, we # will simply skip this test. - unittest.skip(f"There is no locale {locale} in this system.") + unittest.skip(f"There is no locale {the_locale} in this system.") def test_template(self): # Pages default to page, metadata overwrites @@ -341,7 +355,7 @@ class TestPage(TestBase): self.assertEqual("custom", custom_page.template) def test_signal(self): - def receiver_test_function(sender): + def receiver_test_function(_sender): receiver_test_function.has_been_called = True receiver_test_function.has_been_called = False @@ -406,8 +420,7 @@ class TestPage(TestBase): # fragment args["content"] = ( - "A simple test, with a " - 'link' + 'A simple test, with a link' ) content = Page(**args).get_content("http://notmyidea.org") self.assertEqual( @@ -687,8 +700,7 @@ class TestPage(TestBase): } args["content"] = ( - "A simple test, with a link to a" - 'poster' + 'A simple test, with a link to aposter' ) content = Page(**args).get_content("http://notmyidea.org") self.assertEqual( diff --git a/pelican/tests/test_generators.py b/pelican/tests/test_generators.py index e739e180..f566bc9c 100644 --- a/pelican/tests/test_generators.py +++ b/pelican/tests/test_generators.py @@ -1,5 +1,4 @@ import os -import sys from shutil import copy, rmtree from tempfile import mkdtemp from unittest.mock import MagicMock @@ -789,7 +788,9 @@ class TestArticlesGenerator(unittest.TestCase): theme=settings["THEME"], output_path=None, ) - self.assertRaises(Exception, generator.get_template, "not_a_template") + self.assertRaises( + PelicanTemplateNotFound, generator.get_template, "not_a_template" + ) def test_generate_authors(self): """Check authors generation.""" @@ -917,10 +918,7 @@ class TestArticlesGenerator(unittest.TestCase): "This is a super article !", "This is a super article !", "This is an article with category !", - ( - "This is an article with multiple authors in lastname, " - "firstname format!" - ), + ("This is an article with multiple authors in lastname, firstname format!"), "This is an article with multiple authors in list format!", "This is an article with multiple authors!", "This is an article with multiple authors!", @@ -1528,18 +1526,9 @@ class TestStaticGenerator(unittest.TestCase): self.generator.generate_context() self.generator.generate_output(None) self.assertTrue(os.path.islink(self.endfile)) - - # os.path.realpath is broken on Windows before python3.8 for symlinks. - # This is a (ugly) workaround. - # see: https://bugs.python.org/issue9949 - if os.name == "nt" and sys.version_info < (3, 8): - - def get_real_path(path): - return os.readlink(path) if os.path.islink(path) else path - else: - get_real_path = os.path.realpath - - self.assertEqual(get_real_path(self.endfile), get_real_path(self.startfile)) + self.assertEqual( + os.path.realpath(self.endfile), os.path.realpath(self.startfile) + ) def test_delete_existing_file_before_mkdir(self): with open(self.startfile, "w") as f: diff --git a/pelican/tests/test_importer.py b/pelican/tests/test_importer.py index 10b0f7f6..2df56e0d 100644 --- a/pelican/tests/test_importer.py +++ b/pelican/tests/test_importer.py @@ -552,7 +552,7 @@ class TestWordpressXMLAttachements(TestCaseWithCLocale): class TestTumblrImporter(TestCaseWithCLocale): @patch("pelican.tools.pelican_import._get_tumblr_posts") def test_posts(self, get): - def get_posts(api_key, blogname, offset=0): + def get_posts(_api_key, _blogname, offset=0): if offset > 0: return [] @@ -603,7 +603,7 @@ class TestTumblrImporter(TestCaseWithCLocale): @patch("pelican.tools.pelican_import._get_tumblr_posts") def test_video_embed(self, get): - def get_posts(api_key, blogname, offset=0): + def get_posts(_api_key, _blogname, offset=0): if offset > 0: return [] @@ -657,7 +657,7 @@ class TestTumblrImporter(TestCaseWithCLocale): @patch("pelican.tools.pelican_import._get_tumblr_posts") def test_broken_video_embed(self, get): - def get_posts(api_key, blogname, offset=0): + def get_posts(_api_key, _blogname, offset=0): if offset > 0: return [] diff --git a/pelican/tests/test_log.py b/pelican/tests/test_log.py index 8791fc7c..a7e1316b 100644 --- a/pelican/tests/test_log.py +++ b/pelican/tests/test_log.py @@ -73,3 +73,45 @@ class TestLog(unittest.TestCase): self.assertEqual( self.handler.count_logs("Another log \\d", logging.WARNING), 0 ) + + def test_filtered_warning_no_raise_with_fatal_warnings(self): + log.FatalLogger.fatal_lvl = logging.WARNING + try: + log.LimitFilter._ignore.add((logging.WARNING, "Filtered %s")) + # Should not raise because the message is filtered out. + self.logger.warning("Filtered %s", "msg") + finally: + log.FatalLogger.fatal_lvl = logging.CRITICAL + 1 + + def test_unfiltered_warning_raises_with_fatal_warnings(self): + log.FatalLogger.fatal_lvl = logging.WARNING + try: + with self.assertRaises(RuntimeError): + self.logger.warning("Unfiltered warning") + finally: + log.FatalLogger.fatal_lvl = logging.CRITICAL + 1 + + def test_filtered_error_no_raise_with_fatal_errors(self): + log.FatalLogger.fatal_lvl = logging.ERROR + try: + log.LimitFilter._ignore.add((logging.WARNING, "Filtered error %s")) + # Errors go through LimitFilter (levelno > WARNING returns True), + # but we can test with a duplicate message which gets filtered. + self.logger.warning("Some warning") + self._reset_limit_filter() + # Use _ignore to filter an error-level record by lowering dedup level. + log.LimitFilter.LOGS_DEDUP_MIN_LEVEL = logging.ERROR + log.LimitFilter._ignore.add((logging.ERROR, "Filtered error %s")) + self.logger.error("Filtered error %s", "msg") + finally: + log.FatalLogger.fatal_lvl = logging.CRITICAL + 1 + log.LimitFilter.LOGS_DEDUP_MIN_LEVEL = logging.WARNING + + def test_critical_never_raises(self): + log.FatalLogger.fatal_lvl = logging.WARNING + try: + # CRITICAL should not raise even though it's above fatal_lvl, + # because main() catches RuntimeError and logs its own critical. + self.logger.critical("Critical message") + finally: + log.FatalLogger.fatal_lvl = logging.CRITICAL + 1 diff --git a/pelican/tests/test_paginator.py b/pelican/tests/test_paginator.py index 6a7dbe02..2f98231d 100644 --- a/pelican/tests/test_paginator.py +++ b/pelican/tests/test_paginator.py @@ -3,7 +3,7 @@ import locale from jinja2.utils import generate_lorem_ipsum from pelican.contents import Article, Author -from pelican.paginator import Paginator +from pelican.paginator import PaginationRule, Paginator from pelican.settings import DEFAULT_CONFIG from pelican.tests.support import get_settings, unittest @@ -35,8 +35,6 @@ class TestPage(unittest.TestCase): def test_save_as_preservation(self): settings = get_settings() # fix up pagination rules - from pelican.paginator import PaginationRule - pagination_rules = [ PaginationRule(*r) for r in settings.get( @@ -56,8 +54,6 @@ class TestPage(unittest.TestCase): self.assertEqual(page.save_as, "foobar.foo") def test_custom_pagination_pattern(self): - from pelican.paginator import PaginationRule - settings = get_settings() settings["PAGINATION_PATTERNS"] = [ PaginationRule(*r) @@ -81,8 +77,6 @@ class TestPage(unittest.TestCase): self.assertEqual(page2.url, "//blog.my.site/2/") def test_custom_pagination_pattern_last_page(self): - from pelican.paginator import PaginationRule - settings = get_settings() settings["PAGINATION_PATTERNS"] = [ PaginationRule(*r) diff --git a/pelican/tests/test_plugins.py b/pelican/tests/test_plugins.py index 69a0384c..f67c5fe4 100644 --- a/pelican/tests/test_plugins.py +++ b/pelican/tests/test_plugins.py @@ -23,7 +23,7 @@ def tmp_namespace_path(path): """ # This avoids calls to internal `pelican.plugins.__path__._recalculate()` # as it should not be necessary - import pelican + import pelican # noqa: PLC0415 old_path = pelican.__path__[:] try: @@ -41,8 +41,8 @@ class PluginTest(unittest.TestCase): _NORMAL_PLUGIN_FOLDER = os.path.join(_PLUGIN_FOLDER, "normal_plugin") def test_namespace_path_modification(self): - import pelican - import pelican.plugins + import pelican # noqa: PLC0415 + import pelican.plugins # noqa: PLC0415 old_path = pelican.__path__[:] @@ -273,14 +273,14 @@ class PluginTest(unittest.TestCase): expected = [] for i in range(50): # function appends value of i to a list - def func(input, i=i): - input.append(i) + def func(dummy_input, i=i): + dummy_input.append(i) functions.append(func) # we expect functions to be run in the connection order dummy_signal.connect(func) expected.append(i) - input = [] - dummy_signal.send(input) - self.assertEqual(input, expected) + dummy_input = [] + dummy_signal.send(dummy_input) + self.assertEqual(dummy_input, expected) diff --git a/pelican/tests/test_readers.py b/pelican/tests/test_readers.py index 68938a83..751088f7 100644 --- a/pelican/tests/test_readers.py +++ b/pelican/tests/test_readers.py @@ -409,15 +409,98 @@ class RstReaderTest(ReaderTest): except ImportError: return unittest.skip("need the typogrify distribution") + def test_typogrify_ignore_filters(self): + try: + # typogrify should be able to ignore user specified filters. + page = self.read_file( + path="article_with_code_block.rst", + TYPOGRIFY=True, + TYPOGRIFY_OMIT_FILTERS=["amp"], + ) + expected = ( + "An article with some code
\n" + ''
+ 'x'
+ ' &'
+ ' y\nA block quote:
\n\nx " + "& y\n" + "
Normal:\nx & y
\n" + ) + self.assertEqual(page.content, expected) + + page = self.read_file( + path="article.rst", + TYPOGRIFY=True, + TYPOGRIFY_OMIT_FILTERS=["smartypants"], + ) + expected = ( + 'THIS is some content. ' + "With some stuff to "typogrify"...
\n" + 'Now with added support for TLA.
\n' + ) + self.assertEqual(page.content, expected) + + page = self.read_file( + path="article.rst", + TYPOGRIFY=True, + TYPOGRIFY_OMIT_FILTERS=["caps"], + ) + expected = ( + "THIS is some content. " + "With some stuff to “typogrify”…
\n" + 'Now with added support for TLA.
\n' + ) + self.assertEqual(page.content, expected) + + page = self.read_file( + path="article.rst", + TYPOGRIFY=True, + TYPOGRIFY_OMIT_FILTERS=["initial_quotes"], + ) + expected = ( + 'THIS is some content. ' + "With some stuff to “typogrify”…
\n" + 'Now with added support for TLA.
\n' + ) + self.assertEqual(page.content, expected) + + page = self.read_file( + path="article.rst", + TYPOGRIFY=True, + TYPOGRIFY_OMIT_FILTERS=["widont"], + ) + expected = ( + 'THIS is some content. ' + "With some stuff to " + "“typogrify”…
\nNow with added " + 'support for ' + 'TLA.
\n' + ) + self.assertEqual(page.content, expected) + + page = self.read_file( + path="article.rst", + TYPOGRIFY=True, + TYPOGRIFY_OMIT_FILTERS=["this-filter-does-not-exists"], + ) + self.assertRaises(TypeError) + except ImportError: + return unittest.skip("need the typogrify distribution") + except TypeError: + return unittest.skip("need typogrify version 2.1.0 or later") + def test_typogrify_ignore_tags(self): try: - # typogrify should be able to ignore user specified tags, - # but tries to be clever with widont extension + # typogrify should be able to ignore user specified tags. page = self.read_file( path="article.rst", TYPOGRIFY=True, TYPOGRIFY_IGNORE_TAGS=["p"] ) expected = ( - "THIS is some content. With some stuff to " + "
THIS is some content. With some stuff to " ""typogrify"...
\nNow with added " 'support for ' "TLA.
\n" diff --git a/pelican/tests/test_rstdirectives.py b/pelican/tests/test_rstdirectives.py index 46ed6f49..dfe55961 100644 --- a/pelican/tests/test_rstdirectives.py +++ b/pelican/tests/test_rstdirectives.py @@ -1,12 +1,11 @@ from unittest.mock import Mock +from pelican.rstdirectives import abbr_role from pelican.tests.support import unittest class Test_abbr_role(unittest.TestCase): def call_it(self, text): - from pelican.rstdirectives import abbr_role - rawtext = text lineno = 42 inliner = Mock(name="inliner") diff --git a/pelican/tests/test_server.py b/pelican/tests/test_server.py index fd616ef7..e9660a6a 100644 --- a/pelican/tests/test_server.py +++ b/pelican/tests/test_server.py @@ -8,7 +8,7 @@ from pelican.tests.support import unittest class MockRequest: - def makefile(self, *args, **kwargs): + def makefile(self, *_args, **_kwargs): return BytesIO(b"") @@ -22,17 +22,16 @@ class TestServer(unittest.TestCase): self.temp_output = mkdtemp(prefix="pelicantests.") self.old_cwd = os.getcwd() os.chdir(self.temp_output) + self.handler = ComplexHTTPRequestHandler( + MockRequest(), ("0.0.0.0", 8888), self.server + ) + self.handler.base_path = self.temp_output def tearDown(self): os.chdir(self.old_cwd) rmtree(self.temp_output) def test_get_path_that_exists(self): - handler = ComplexHTTPRequestHandler( - MockRequest(), ("0.0.0.0", 8888), self.server - ) - handler.base_path = self.temp_output - open(os.path.join(self.temp_output, "foo.html"), "a").close() os.mkdir(os.path.join(self.temp_output, "foo")) open(os.path.join(self.temp_output, "foo", "index.html"), "a").close() @@ -44,17 +43,29 @@ class TestServer(unittest.TestCase): for suffix in ["", "/"]: # foo.html has precedence over foo/index.html - path = handler.get_path_that_exists("foo" + suffix) + path = self.handler.get_path_that_exists("foo" + suffix) self.assertEqual(path, "foo.html") # folder with index.html should return folder/index.html - path = handler.get_path_that_exists("bar" + suffix) + path = self.handler.get_path_that_exists("bar" + suffix) self.assertEqual(path, "bar/index.html") # folder without index.html should return same as input - path = handler.get_path_that_exists("baz" + suffix) + path = self.handler.get_path_that_exists("baz" + suffix) self.assertEqual(path, "baz" + suffix) # not existing path should return None - path = handler.get_path_that_exists("quux" + suffix) + path = self.handler.get_path_that_exists("quux" + suffix) self.assertIsNone(path) + + def test_guess_type_method_returns_javascript_for_js_files(self): + js_file = os.path.join(self.temp_output, "script.js") + with open(js_file, "w") as f: + f.write("console.log('hello');") + + mimetype = self.handler.guess_type(js_file) + self.assertIn( + mimetype, + ("text/javascript", "application/javascript"), + f"Expected .js MIME type to be 'text/javascript' or 'application/javascript', got '{mimetype}'", + ) diff --git a/pelican/tests/test_settings.py b/pelican/tests/test_settings.py index 84f7a5c9..54167e63 100644 --- a/pelican/tests/test_settings.py +++ b/pelican/tests/test_settings.py @@ -1,8 +1,10 @@ import copy import locale +import logging import os from os.path import abspath, dirname, join +from pelican import log from pelican.settings import ( DEFAULT_CONFIG, DEFAULT_THEME, @@ -11,7 +13,7 @@ from pelican.settings import ( handle_deprecated_settings, read_settings, ) -from pelican.tests.support import unittest +from pelican.tests.support import LogCountHandler, unittest class TestSettingsConfiguration(unittest.TestCase): @@ -108,6 +110,39 @@ class TestSettingsConfiguration(unittest.TestCase): configure_settings(settings) self.assertEqual(settings["FEED_DOMAIN"], "http://feeds.example.com") + def _feeds_warning_settings(self, **overrides): + base = { + "LOCALE": "", + "PATH": os.curdir, + "THEME": DEFAULT_THEME, + "FEED_RSS": "feeds/all.rss.xml", + } + base.update(overrides) + handler = LogCountHandler() + logger = logging.getLogger() + logger.addHandler(handler) + saved = log.LimitFilter._raised_messages.copy() + log.LimitFilter._raised_messages = set() + try: + configure_settings(base) + return handler.count_logs( + "Feeds generated without SITEURL", logging.WARNING + ) + finally: + log.LimitFilter._raised_messages = saved + logger.removeHandler(handler) + + def test_feeds_warning_with_siteurl(self): + self.assertEqual(self._feeds_warning_settings(SITEURL="http://example.com"), 0) + + def test_feeds_warning_with_feed_domain(self): + self.assertEqual( + self._feeds_warning_settings(FEED_DOMAIN="http://feeds.example.com"), 0 + ) + + def test_feeds_warning_without_siteurl_or_feed_domain(self): + self.assertEqual(self._feeds_warning_settings(), 1) + def test_theme_settings_exceptions(self): settings = self.settings @@ -118,7 +153,7 @@ class TestSettingsConfiguration(unittest.TestCase): # Check that non-existent theme raises exception settings["THEME"] = "foo" - self.assertRaises(Exception, configure_settings, settings) + self.assertRaises(ValueError, configure_settings, settings) def test_deprecated_dir_setting(self): settings = self.settings @@ -155,17 +190,17 @@ class TestSettingsConfiguration(unittest.TestCase): # test that 'PATH' is set settings = {} - self.assertRaises(Exception, configure_settings, settings) + self.assertRaises(ValueError, configure_settings, settings) # Test that 'PATH' is valid settings["PATH"] = "" - self.assertRaises(Exception, configure_settings, settings) + self.assertRaises(ValueError, configure_settings, settings) # Test nonexistent THEME settings["PATH"] = os.curdir settings["THEME"] = "foo" - self.assertRaises(Exception, configure_settings, settings) + self.assertRaises(ValueError, configure_settings, settings) def test__printf_s_to_format_field(self): for s in ("%s", "{%s}", "{%s"): @@ -218,14 +253,14 @@ class TestSettingsConfiguration(unittest.TestCase): settings["EXTRA_TEMPLATES_PATHS"] = ["/ha"] settings["THEME_TEMPLATES_OVERRIDES"] = ["/foo/bar"] - self.assertRaises(Exception, handle_deprecated_settings, settings) + self.assertRaises(ValueError, handle_deprecated_settings, settings) def test_slug_and_slug_regex_substitutions_exception(self): settings = {} settings["SLUG_REGEX_SUBSTITUTIONS"] = [("C++", "cpp")] settings["TAG_SUBSTITUTIONS"] = [("C#", "csharp")] - self.assertRaises(Exception, handle_deprecated_settings, settings) + self.assertRaises(ValueError, handle_deprecated_settings, settings) def test_deprecated_slug_substitutions(self): default_slug_regex_subs = self.settings["SLUG_REGEX_SUBSTITUTIONS"] diff --git a/pelican/tests/test_theme.py b/pelican/tests/test_theme.py new file mode 100644 index 00000000..c4bd6f14 --- /dev/null +++ b/pelican/tests/test_theme.py @@ -0,0 +1,297 @@ +import os +import unittest +from shutil import rmtree +from tempfile import mkdtemp + +from pelican import Pelican +from pelican.settings import read_settings +from pelican.tests.support import LoggedTestCase, mute + +CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) +CONTENT_DIR = os.path.join(CURRENT_DIR, "simple_content") + + +class TestTemplateInheritance(LoggedTestCase): + def setUp(self): + super().setUp() + self.temp_output = mkdtemp(prefix="pelican_test_output.") + self.temp_theme = mkdtemp(prefix="pelican_test_theme.") + self.temp_cache = mkdtemp(prefix="pelican_test_cache.") + + # Create test theme directory structure + os.makedirs(os.path.join(self.temp_theme, "templates"), exist_ok=True) + + # Create base.html template that inherits from simple theme + template_content = """{% extends "!simple/base.html" %} + +{% block head %} +{{ super() }} + +{% endblock %} + +{% block footer %} +New footer
+{% endblock %} +""" + + with open(os.path.join(self.temp_theme, "templates", "base.html"), "w") as f: + f.write(template_content) + + def tearDown(self): + """Clean up temporary directories and files""" + for path in [self.temp_output, self.temp_theme, self.temp_cache]: + if os.path.exists(path): + rmtree(path) + + super().tearDown() + + def test_simple_theme(self): + """Test that when a template is missing from our theme, Pelican falls back + to using the template from the simple theme.""" + + settings = read_settings( + path=None, + override={ + "THEME": "simple", + "PATH": CONTENT_DIR, + "OUTPUT_PATH": self.temp_output, + "CACHE_PATH": self.temp_cache, + "SITEURL": "http://example.com", + # Disable unnecessary output that might cause failures + "ARCHIVES_SAVE_AS": "", + "CATEGORIES_SAVE_AS": "", + "TAGS_SAVE_AS": "", + "AUTHOR_SAVE_AS": "", + "AUTHORS_SAVE_AS": "", + }, + ) + + pelican = Pelican(settings=settings) + mute(True)(pelican.run)() + + output_file = os.path.join(self.temp_output, "test-md-file.html") + self.assertTrue(os.path.exists(output_file)) + + with open(output_file) as f: + content = f.read() + + # Verify file content is present + self.assertIn("Test md File", content) + + # Verify simple theme content is present + self.assertIn('', content) + self.assertIn("Proudly powered by", content) + + # Verify our custom theme additions are NOT present + # (since we should be using the simple theme's template directly) + self.assertNotIn( + '', content) # From simple theme + + # Verify super() maintained original head content + self.assertIn('' + 'Table of Contents
' + '' + "Some content here
" + ) + result = utils.strip_toc_elements_from_html(html_with_toc) + self.assertNotIn('Some content
" + ) + result = utils.strip_toc_elements_from_html(html_with_backref) + self.assertNotIn("toc-backref", result) + self.assertNotIn("Section Heading", result) + + # Test combined - remove both TOC div and backrefs + html_combined = ( + 'TOC here
" + "Article content
" + 'More content
" + ) + result = utils.strip_toc_elements_from_html(html_combined) + self.assertNotIn('Just some plain content
" + self.assertEqual(utils.strip_toc_elements_from_html(plain_html), plain_html) + + # Test case-insensitive matching + html_mixed_case = 'TOC
Content
' + result = utils.strip_toc_elements_from_html(html_mixed_case) + self.assertNotIn("CONTENTS", result) + self.assertIn("Content
", result) + def test_process_translations(self): fr_articles = [] en_articles = [] @@ -990,3 +1051,72 @@ class TestStringUtils(unittest.TestCase): self.assertEqual("", utils.file_suffix("")) self.assertEqual("", utils.file_suffix("foo")) self.assertEqual("md", utils.file_suffix("foo.md")) + + +class TestFileChangeFilter(unittest.TestCase): + ignore_file_patterns = DEFAULT_CONFIG["IGNORE_FILES"] + + def test_regular_files_not_filtered(self): + file_change_filter = utils.FileChangeFilter( + ignore_file_patterns=self.ignore_file_patterns + ) + basename = "article.rst" + full_path = os.path.join(os.path.dirname(__file__), "content", basename) + + for change in watchfiles.Change: + self.assertTrue(file_change_filter(change=change, path=basename)) + self.assertTrue(file_change_filter(change=change, path=full_path)) + + def test_dotfiles_filtered(self): + file_change_filter = utils.FileChangeFilter( + ignore_file_patterns=self.ignore_file_patterns + ) + basename = ".config" + full_path = os.path.join(os.path.dirname(__file__), "content", basename) + + # Testing with just the hidden file name and the full file path to the hidden file + for change in watchfiles.Change: + self.assertFalse(file_change_filter(change=change, path=basename)) + self.assertFalse(file_change_filter(change=change, path=full_path)) + + def test_default_filters(self): + # Testing a subset of the default filters + # For reference: https://watchfiles.helpmanual.io/api/filters/#watchfiles.DefaultFilter.ignore_dirs + file_change_filter = utils.FileChangeFilter(ignore_file_patterns=[]) + test_basenames = [ + "__pycache__", + ".git", + ".hg", + ".svn", + ".tox", + ".venv", + ".idea", + "node_modules", + ".mypy_cache", + ".pytest_cache", + ".hypothesis", + ".DS_Store", + "flycheck_file", + "test_file~", + ] + + for basename in test_basenames: + full_path = os.path.join(os.path.dirname(__file__), basename) + for change in watchfiles.Change: + self.assertFalse(file_change_filter(change=change, path=basename)) + self.assertFalse(file_change_filter(change=change, path=full_path)) + + def test_custom_ignore_pattern(self): + file_change_filter = utils.FileChangeFilter(ignore_file_patterns=["*.rst"]) + basename = "article.rst" + full_path = os.path.join(os.path.dirname(__file__), basename) + for change in watchfiles.Change: + self.assertFalse(file_change_filter(change=change, path=basename)) + self.assertFalse(file_change_filter(change=change, path=full_path)) + + # If the user changes `IGNORE_FILES` to only contain ['*.rst'], then dotfiles would not be filtered anymore + basename = ".config" + full_path = os.path.join(os.path.dirname(__file__), basename) + for change in watchfiles.Change: + self.assertTrue(file_change_filter(change=change, path=basename)) + self.assertTrue(file_change_filter(change=change, path=full_path)) diff --git a/pelican/themes/notmyidea/static/css/main.css b/pelican/themes/notmyidea/static/css/main.css index c1d86950..2e2aee48 100644 --- a/pelican/themes/notmyidea/static/css/main.css +++ b/pelican/themes/notmyidea/static/css/main.css @@ -27,6 +27,13 @@ body { text-align: left; } +@media (prefers-color-scheme: dark) { + body { + background: #070808; + color: #FFFEFE; + } +} + /* Headings */ h1 {font-size: 2em } h2 {font-size: 1.571em} /* 22px */ @@ -106,6 +113,12 @@ dd {margin-left: 1.5em;} pre{background-color: rgb(238, 238, 238); padding: 10px; margin: 10px; overflow: auto;} +@media (prefers-color-scheme: dark) { + pre { + background: rgb(38, 38, 38); + } +} + /* Quotes */ blockquote { margin: 20px; @@ -197,6 +210,12 @@ div.figure p.caption, figure p.caption { /* margin provided by figure */ #banner h1 strong {font-size: 0.36em; font-weight: normal;} +@media (prefers-color-scheme: dark) { + #banner h1 a:link, #banner h1 a:visited { + color: #FFFAF8; + } +} + /* Main Nav */ #banner nav { background: #000305; @@ -213,6 +232,12 @@ div.figure p.caption, figure p.caption { /* margin provided by figure */ -webkit-border-radius: 5px; } +@media (prefers-color-scheme: dark) { + #banner nav { + background: #121518; + } +} + #banner nav ul {list-style: none; margin: 0 auto; max-width: 800px;} #banner nav li {float: left; display: inline; margin: 0;} @@ -255,6 +280,12 @@ div.figure p.caption, figure p.caption { /* margin provided by figure */ -webkit-border-radius: 10px; } +@media (prefers-color-scheme: dark) { + #featured { + background: #151617; + } +} + #featured figure { border: 2px solid #eee; float: right; @@ -284,6 +315,12 @@ div.figure p.caption, figure p.caption { /* margin provided by figure */ -webkit-border-radius: 10px; } +@media (prefers-color-scheme: dark) { + #content { + background: #111; + } +} + /* Extras *****************/ @@ -306,6 +343,12 @@ div.figure p.caption, figure p.caption { /* margin provided by figure */ padding: .3em .25em; } +@media (prefers-color-scheme: dark) { + #extras a:link, #extras a:visited { + color: #888; + } +} + #extras a:hover, #extras a:active {color: #fff;} /* Blogroll */ @@ -339,6 +382,12 @@ div.figure p.caption, figure p.caption { /* margin provided by figure */ -webkit-border-radius: 10px; } +@media (prefers-color-scheme: dark) { + #about { + background: #222; + } +} + #about .primary {float: left; max-width: 165px;} #about .primary strong {color: #C64350; display: block; font-size: 1.286em;} #about .photo {float: left; margin: 5px 20px;} @@ -367,6 +416,12 @@ li:first-child .hentry, #content > .hentry {border: 0; margin: 0;} .entry-title a:link, .entry-title a:visited {text-decoration: none; color: #333;} .entry-title a:visited {background-color: #fff;} +@media (prefers-color-scheme: dark) { + .entry-title a:link, .entry-title a:visited { + color: #C74350; + } +} + .hentry .post-info * {font-style: normal;} /* Content */ diff --git a/pelican/themes/notmyidea/templates/github.html b/pelican/themes/notmyidea/templates/github.html index 8e256af7..bd79d621 100644 --- a/pelican/themes/notmyidea/templates/github.html +++ b/pelican/themes/notmyidea/templates/github.html @@ -1,9 +1,18 @@ {% if GITHUB_URL %} + {% if GITHUB_POSITION != "left" %} -
+
+ {{ SITESUBTITLE }}
{% endif %} - -{{ SITESUBTITLE }}
{% endif %} + {% endblock header %} + {% block nav %} + + {% endblock nav %} +