diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1116130 --- /dev/null +++ b/.gitignore @@ -0,0 +1,241 @@ +.vscode/ + +# Created by .ignore support plugin (hsz.mobi) +### macOS template +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +.idea/ + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +.idea/$CACHE_FILE$ +.idea/$PRODUCT_WORKSPACE_FILE$ +.idea/codeStyles/codeStyleConfig.xml +.idea/codeStyles/Project.xml +.idea/inspectionProfiles/profiles_settings.xml +.idea/inspectionProfiles/Project_Default.xml +.idea/markdown-navigator.xml +.idea/misc.xml +.idea/mkdocs-plugin-tags.iml +.idea/modules.xml +.idea/scssLintPlugin.xml +.idea/vcs.xml diff --git a/README.md b/README.md index 03a6bb1..673d4f3 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,47 @@ Support for tags in the yaml-metadata in the header of markdown files. Extracts this metadata and creates a "Tags" page which lists all tags and all pages for each tag. -## Quick Demo +## Installation + +It's recommended to use a virtual environment for Python projects. You can create one using `venv` or, for a faster experience, `uv`. -Install this plugin (it will also install mkdocs if required) +**Using `uv` (recommended):** +```shell +# Install uv (if you haven't already) +# See https://github.com/astral-sh/uv for installation instructions +# e.g., pip install uv + +# Create a virtual environment and activate it +uv venv +source .venv/bin/activate # On Linux/macOS +# .venv\Scripts\activate # On Windows +``` +**Using `venv`:** ```shell -$ pip install git+https://github.com/jldiaz/mkdocs-plugin-tags.git +python -m venv .venv +source .venv/bin/activate # On Linux/macOS +# .venv\Scripts\activate # On Windows ``` -> **Note**. Since this package is in alpha stage, it is not yet available from pypi, so the only way to install it is via git. +Once your virtual environment is activated, install the plugin: + +From PyPI (once published): +```shell +pip install tags-macros-plugin +``` +Or, for the latest development version from GitHub: +```shell +pip install git+https://github.com/jldiaz/mkdocs-plugin-tags.git +``` +Alternatively, if you have cloned the repository locally: +```shell +pip install . +``` + +## Quick Demo -Create a new documentation folder: +After installing the plugin, you can create a new documentation folder: ```shell $ mkdocs new demo diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d645bb9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,154 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "tags-macros-plugin" +dynamic = ["version"] +description = "Processes tags in yaml metadata for MkDocs" +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +keywords = ["mkdocs", "python", "markdown", "tags"] +authors = [ + { name = "JL Diaz", email = "jldiaz@gmail.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", # Updated from Alpha + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "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", +] +dependencies = [ + "mkdocs>=1.0", # Loosened from mkdocs>=0.17 + "jinja2", + "PyYAML>=5.1", # Added PyYAML as it's used in plugin.py +] + +[project.urls] +Homepage = "https://github.com/jldiaz/mkdocs-plugin-tags" # Assuming this is the project URL + +[project.entry-points."mkdocs.plugins"] +tags = "tags.plugin:TagsPlugin" + +[tool.hatch.version] +source = "vcs" + +[tool.hatch.build.targets.sdist] +include = ["/tags", "/README.md", "/LICENSE.md"] + +[tool.hatch.build.targets.wheel] +packages = ["tags"] + +[tool.hatch.envs.default] +dependencies = [ + "pytest>=6.0", + "pytest-cov>=3.0", + "mkdocs-material", + "ruff>=0.1.0", + "mypy>=1.0", + "types-PyYAML", + "types-Markdown", +] + +[tool.hatch.envs.docs] +dependencies = [ + "mkdocs-material", +] + +[tool.hatch.envs.docs.scripts] +serve = "mkdocs serve --dev-addr localhost:8008" +build = "mkdocs build --clean --strict" +deploy = "mkdocs gh-deploy --force" + +[tool.ruff] +line-length = 88 +target-version = "py38" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "A", # flake8-builtins + "C4", # flake8-comprehensions + "SIM",# flake8-simplify + "PTH",# flake8-use-pathlib + "RUF", # Ruff-specific rules +] +ignore = ["B905"] # `zip()` without `strict=`. hatch-vcs uses it. + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +skip-magic-trailing-comma = false +line-ending = "auto" + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true # Start with this, can be removed later +# show_error_codes = true +# exclude = ["docs/", "site/"] # If you have these folders + +# Per-module settings can be useful +# [[tool.mypy.overrides]] +# module = "somelibrary.*" +# ignore_missing_imports = true + +[tool.hatch.envs.test] # Specific test environment +dependencies = [ + "pytest>=6.0", + "pytest-cov>=3.0", + "mkdocs-material", # If tests need to build a small mkdocs site +] +[tool.hatch.envs.test.scripts] +cov = "pytest --cov=tags --cov-report=xml --cov-report=html" +run = "pytest {args}" + +[tool.hatch.scripts] +# General checks +check = [ + "lint:check", + "format:check", + "typecheck", + "test:run", # Updated +] +fix = ["lint:fix", "format:fix"] + +# Linting - these can run in the default or a dedicated lint env +"lint:check" = "hatch run default:ruff check ." +"lint:fix" = "hatch run default:ruff check . --fix" + +# Formatting +"format:check" = "hatch run default:ruff format --check ." +"format:fix" = "hatch run default:ruff format ." + +# Type checking +typecheck = "hatch run default:mypy tags/ setup.py" + +# Testing - use the 'test' environment +"test:run" = "hatch run test:run" +"test:cov" = "hatch run test:cov" + +# Building +build = "hatch build" + +# Publishing (example, adjust as needed) +# publish = "hatch publish" +# "publish:test" = "hatch publish -r testpypi" + +# Default environment for scripts +# [tool.hatch.envs.default.scripts] +# check = "hatch run check" # Example: run 'check' script in default env diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6d1fb7e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +minversion = 6.0 +addopts = -ra -q --color=yes +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +filterwarnings = + ignore::DeprecationWarning + # Add other warnings to ignore here if necessary +# Example: ignore UserWarning from a specific module + # ignore:.*specific warning text.*:UserWarning:some_module +# Enable asyncio mode if your tests use asyncio +# asyncio_mode = auto diff --git a/setup.py b/setup.py index b2c7d91..8cddafb 100644 --- a/setup.py +++ b/setup.py @@ -4,45 +4,42 @@ # JL Diaz (c) 2019 # -------------------------------------------- -import os -from setuptools import setup, find_packages +from pathlib import Path +from setuptools import find_packages, setup -def read_file(fname): - "Read a local file" - return open(os.path.join(os.path.dirname(__file__), fname)).read() + +def read_file(fname: str) -> str: + """Read a local file.""" + return (Path(__file__).parent / fname).read_text(encoding="utf-8") setup( - name='tags-macros-plugin', - version='0.2.0', + name="tags-macros-plugin", + version="0.2.0", description="Processes tags in yaml metadata", - long_description=read_file('README.md'), - long_description_content_type='text/markdown', - keywords='mkdocs python markdown tags', - url='', - author='JL Diaz', - author_email='jldiaz@gmail.com', - license='MIT', - python_requires='>=3.6', + long_description=read_file("README.md"), + long_description_content_type="text/markdown", + keywords="mkdocs python markdown tags", + url="", + author="JL Diaz", + author_email="jldiaz@gmail.com", + license="MIT", + python_requires=">=3.6", install_requires=[ - 'mkdocs>=0.17', - 'jinja2', + "mkdocs>=0.17", + "jinja2", ], classifiers=[ - 'Development Status :: 3 - Alpha', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.6', + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.6", ], - packages=find_packages(exclude=['*.tests']), - package_data={'tags': ['templates/*.md.template']}, - entry_points={ - 'mkdocs.plugins': [ - 'tags = tags.plugin:TagsPlugin' - ] - } + packages=find_packages(exclude=["*.tests"]), + package_data={"tags": ["templates/*.md.template"]}, + entry_points={"mkdocs.plugins": ["tags = tags.plugin:TagsPlugin"]}, ) diff --git a/tags/plugin.py b/tags/plugin.py index 06dbdcc..cec22e9 100644 --- a/tags/plugin.py +++ b/tags/plugin.py @@ -4,16 +4,30 @@ # JL Diaz (c) 2019 # MIT License # -------------------------------------------- -from collections import defaultdict +from collections import defaultdict from pathlib import Path -import os -import yaml + import jinja2 -from mkdocs.structure.files import File -from mkdocs.structure.nav import Section -from mkdocs.plugins import BasePlugin +import yaml +from jinja2.ext import Extension from mkdocs.config.config_options import Type -from mkdocs.utils import string_types +from mkdocs.plugins import BasePlugin +from mkdocs.structure.files import File + +try: + from pymdownx.slugs import uslugify_cased_encoded as slugify +except ImportError: + from markdown.extensions.toc import slugify + + +def slugify_this(text: str) -> str: + return slugify(text, "-") # type: ignore[no-any-return] + + +class SlugifyExtension(Extension): + def __init__(self, environment: jinja2.Environment) -> None: + super().__init__(environment) + environment.filters["slugify"] = slugify_this class TagsPlugin(BasePlugin): @@ -25,107 +39,161 @@ class TagsPlugin(BasePlugin): """ config_scheme = ( - ('tags_filename', Type(string_types, default='tags.md')), - ('tags_folder', Type(string_types, default='aux')), - ('tags_template', Type(string_types)), + ("tags_filename", Type(str, default="tags.md")), + ("tags_folder", Type(str, default="aux")), + ("tags_template", Type(str)), ) - def __init__(self): - self.metadata = [] - self.tags_filename = "tags.md" - self.tags_folder = "aux" - self.tags_template = None + def __init__(self) -> None: + self.metadata: List[Optional[Dict[str, Any]]] = [] + self.tags_filename: Path = Path("tags.md") + self.tags_folder: Path = Path("aux") + self.tags_template: Optional[Path] = None - def on_nav(self, nav, config, files): + def on_nav( + self, nav: Any, config: Any, files: Any + ) -> None: # TODO: Add specific mkdocs types # nav.items.insert(1, nav.items.pop(-1)) pass - def on_config(self, config): + def on_config(self, config: Any) -> None: # TODO: Add specific mkdocs types # Re assign the options - self.tags_filename = Path(self.config.get("tags_filename") or self.tags_filename) - self.tags_folder = Path(self.config.get("tags_folder") or self.tags_folder) + self.tags_filename = Path( + self.config.get("tags_filename") or str(self.tags_filename) + ) + self.tags_folder = Path( + self.config.get("tags_folder") or str(self.tags_folder) + ) # Make sure that the tags folder is absolute, and exists if not self.tags_folder.is_absolute(): self.tags_folder = Path(config["docs_dir"]) / ".." / self.tags_folder if not self.tags_folder.exists(): - self.tags_folder.mkdir(parents=True) + self.tags_folder.mkdir(parents=True, exist_ok=True) - if self.config.get("tags_template"): - self.tags_template = Path(self.config.get("tags_template")) + tags_template_config = self.config.get("tags_template") + if tags_template_config: + self.tags_template = Path(tags_template_config) - def on_files(self, files, config): + def on_files( + self, files: Any, config: Any + ) -> None: # TODO: Add specific mkdocs types # Scan the list of files to extract tags from meta for f in files: if not f.src_path.endswith(".md"): continue - self.metadata.append(get_metadata(f.src_path, config["docs_dir"])) + meta = get_metadata(f.src_path, config["docs_dir"]) + if meta: # Ensure meta is not None before appending + self.metadata.append(meta) # Create new file with tags self.generate_tags_file() # New file to add to the build - newfile = File( + new_file = File( path=str(self.tags_filename), src_dir=str(self.tags_folder), dest_dir=config["site_dir"], - use_directory_urls=False + use_directory_urls=False, ) - files.append(newfile) + files.append(new_file) - def generate_tags_page(self, data): + def generate_tags_page(self, data: DefaultDict[str, List[Dict[str, Any]]]) -> str: if self.tags_template is None: - templ_path = Path(__file__).parent / Path("templates") + templ_path = Path(__file__).parent / "templates" environment = jinja2.Environment( - loader=jinja2.FileSystemLoader(str(templ_path)) - ) + loader=jinja2.FileSystemLoader(str(templ_path)), + extensions=[SlugifyExtension], + ) templ = environment.get_template("tags.md.template") else: environment = jinja2.Environment( - loader=jinja2.FileSystemLoader(searchpath=str(self.tags_template.parent)) + loader=jinja2.FileSystemLoader( + searchpath=str(self.tags_template.parent) + ), + extensions=[SlugifyExtension], ) templ = environment.get_template(str(self.tags_template.name)) + stags = sorted(data.items(), key=lambda t: t[0].lower()) + dtags: Dict[str, List[Tuple[str, List[Dict[str, Any]]]]] = {} # More specific type + for stag in stags: + try: + tagletter = stag[0][0].upper() + if tagletter not in dtags: + dtags[tagletter] = [stag] + else: + dtags[tagletter].append(stag) + except IndexError: # Handles empty tag strings + pass + ldtags = sorted(dtags.items()) output_text = templ.render( - tags=sorted(data.items(), key=lambda t: t[0].lower()), + tags=ldtags, ) return output_text - def generate_tags_file(self): - sorted_meta = sorted(self.metadata, key=lambda e: e.get("year", 5000) if e else 0) - tag_dict = defaultdict(list) + def generate_tags_file(self) -> None: + if self.metadata: + # Filter out None values from self.metadata before sorting + valid_metadata = [m for m in self.metadata if m is not None] + sorted_meta = sorted( + valid_metadata, key=lambda e: e.get("year", 5000) + ) + else: + sorted_meta = [] # Should be an empty list if metadata is empty + + tag_dict: DefaultDict[str, List[Dict[str, Any]]] = defaultdict(list) for e in sorted_meta: - if not e: - continue - if "title" not in e: + # e is already confirmed to be a dict here by prior filtering and sorting + if "title" not in e: # Should not happen if get_metadata ensures title e["title"] = "Untitled" - for tag in e.get("tags", []): - tag_dict[tag].append(e) + + tags = e.get("topic-tags", e.get("topic-auto", e.get("tags", []))) + if isinstance(tags, list): # Ensure tags is a list + for tag in tags: + if isinstance(tag, str): # Ensure tag is a string + tag_dict[tag].append(e) + elif isinstance(tags, str): # Handle case where tags might be a single string + tag_dict[tags].append(e) + t = self.generate_tags_page(tag_dict) - with open(str(self.tags_folder / self.tags_filename), "w") as f: + with (self.tags_folder / self.tags_filename).open("w", encoding="utf-8") as f: f.write(t) + +from typing import Any, Optional, Union, DefaultDict, List, Dict, Tuple # Added for type hints + # Helper functions -def get_metadata(name, path): + +def get_metadata(name: str, path: str) -> Optional[Dict[str, Any]]: # Extract metadata from the yaml at the beginning of the file - def extract_yaml(f): + def extract_yaml(f: Any) -> str: # TODO: Add specific file type result = [] c = 0 for line in f: if line.strip() == "---": - c +=1 + c += 1 continue - if c==2: + if c == 2: break - if c==1: + if c == 1: result.append(line) return "".join(result) filename = Path(path) / Path(name) - with filename.open() as f: - metadata = extract_yaml(f) - if metadata: - meta = yaml.load(metadata, Loader=yaml.FullLoader) - meta.update(filename=name) + with filename.open(encoding="utf-8") as f: + meta: dict = {} + metadata_str = extract_yaml(f) + if metadata_str: + try: + meta = yaml.load(metadata_str, Loader=yaml.FullLoader) + if not isinstance(meta, dict): # Ensure meta is a dict + # Potentially log a warning here if it's not a dict + return None + meta["filename"] = name + except yaml.YAMLError: # Catch specific YAML errors + # Potentially log an error here + return None return meta + return None diff --git a/tags/templates/tags.md.template b/tags/templates/tags.md.template index af6bb13..057b871 100644 --- a/tags/templates/tags.md.template +++ b/tags/templates/tags.md.template @@ -1,14 +1,18 @@ --- -title: Tags +title: Index of Topics +class: topics --- -# Contents grouped by tag +{% for tagletter, lettertags in tags %} -{% for tag, pages in tags %} +### {{tagletter}} -## {{tag}} -{% for page in pages %} - * [{{page.title}}]({{page.filename}}) -{% endfor %} - +{% for tag, pages in lettertags %} +
+ +{{tag}} { .topic #auto }{% for page in pages[:1] %} +: - [{{page.title}}]({{page.filename}}){ .topic }{% endfor %}{% for page in pages[1:] %} + - [{{page.title}}]({{page.filename}}){ .topic }{% endfor %} + +
{% endfor %} {% endfor %} \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e58254e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2024 Jules AI Agent +# SPDX-License-Identifier: MIT + +"""Tests for the tags-macros-plugin.""" diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..f554ac1 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,224 @@ +# SPDX-FileCopyrightText: 2024 Jules AI Agent +# SPDX-License-Identifier: MIT + +from pathlib import Path +from tempfile import TemporaryDirectory +import yaml + +import pytest + +from tags.plugin import TagsPlugin, get_metadata + + +# Fixtures +@pytest.fixture +def mkdocs_config_base(): + """Minimal mkdocs.yml configuration for testing.""" + return { + "site_name": "Test Site", + "docs_dir": "docs", # Relative to a temporary test directory + "plugins": ["tags"], + } + + +@pytest.fixture +def plugin_config_base(): + """Minimal tags plugin configuration for testing.""" + return { + "tags_folder": "tag_pages", # Relative to a temporary test directory + "tags_filename": "all-tags.md", + } + + +# Tests for get_metadata +def test_get_metadata_valid_yaml_header(tmp_path: Path): + """Test get_metadata with a valid YAML header.""" + docs_dir = tmp_path / "docs" + docs_dir.mkdir() + md_content = """--- +title: Test Page +tags: + - tag1 + - tag2 +year: 2024 +--- +# Content +""" + md_file = docs_dir / "test_page.md" + md_file.write_text(md_content, encoding="utf-8") + + metadata = get_metadata(name="test_page.md", path=str(docs_dir)) + + assert metadata is not None + assert metadata["title"] == "Test Page" + assert "tag1" in metadata["tags"] + assert "tag2" in metadata["tags"] + assert metadata["year"] == 2024 + assert metadata["filename"] == "test_page.md" + + +def test_get_metadata_no_yaml_header(tmp_path: Path): + """Test get_metadata with no YAML header.""" + docs_dir = tmp_path / "docs" + docs_dir.mkdir() + md_content = "# Content without YAML" + md_file = docs_dir / "no_yaml_page.md" + md_file.write_text(md_content, encoding="utf-8") + + metadata = get_metadata(name="no_yaml_page.md", path=str(docs_dir)) + assert metadata is None + + +def test_get_metadata_invalid_yaml_header(tmp_path: Path): + """Test get_metadata with invalid YAML (e.g., a list instead of a dict).""" + docs_dir = tmp_path / "docs" + docs_dir.mkdir() + md_content = """--- +- item1 +- item2 +--- +# Content +""" + md_file = docs_dir / "invalid_yaml_page.md" + md_file.write_text(md_content, encoding="utf-8") + + metadata = get_metadata(name="invalid_yaml_page.md", path=str(docs_dir)) + assert metadata is None # Expecting None as the loaded YAML is not a dict + + +def test_get_metadata_empty_file(tmp_path: Path): + """Test get_metadata with an empty file.""" + docs_dir = tmp_path / "docs" + docs_dir.mkdir() + md_file = docs_dir / "empty_page.md" + md_file.touch() + + metadata = get_metadata(name="empty_page.md", path=str(docs_dir)) + assert metadata is None + + +def test_get_metadata_file_not_found(tmp_path: Path): + """Test get_metadata with a non-existent file.""" + docs_dir = tmp_path / "docs" + # docs_dir.mkdir() # Don't create for this test, or create then try non-existent file + + with pytest.raises(FileNotFoundError): + get_metadata(name="non_existent_page.md", path=str(docs_dir)) + + +# Tests for TagsPlugin +class TestTagsPlugin: + def test_plugin_initialization_default_config(self): + """Test TagsPlugin initialization with default config.""" + plugin = TagsPlugin() + assert plugin.tags_filename == Path("tags.md") + assert plugin.tags_folder == Path("aux") + assert plugin.tags_template is None + + def test_plugin_on_config_custom_values(self, mkdocs_config_base): + """Test TagsPlugin.on_config with custom values.""" + plugin = TagsPlugin() + plugin.config = { # Simulate loaded plugin config + "tags_filename": "my-tags.md", + "tags_folder": "generated/tags", + "tags_template": "custom_tags_template.md", + } + + # Create a temporary docs_dir for the test + with TemporaryDirectory() as tmpdir: + docs_path = Path(tmpdir) / "docs" + docs_path.mkdir(parents=True, exist_ok=True) + + mkdocs_config_full = {**mkdocs_config_base, "docs_dir": str(docs_path)} + plugin.on_config(mkdocs_config_full) + + assert plugin.tags_filename == Path("my-tags.md") + # tags_folder should be relative to docs_dir/.. if not absolute + expected_tags_folder = docs_path.parent / "generated/tags" + assert plugin.tags_folder == expected_tags_folder + assert expected_tags_folder.exists() # Check if folder was created + assert plugin.tags_template == Path("custom_tags_template.md") + + def test_plugin_on_config_absolute_tags_folder(self, mkdocs_config_base, tmp_path: Path): + """Test TagsPlugin.on_config with an absolute tags_folder path.""" + plugin = TagsPlugin() + absolute_folder = tmp_path / "custom_absolute_tags" + + plugin.config = { + "tags_folder": str(absolute_folder), + } + + with TemporaryDirectory() as tmp_docs_dir_root: + docs_path = Path(tmp_docs_dir_root) / "docs" + docs_path.mkdir(parents=True, exist_ok=True) + + mkdocs_config_full = {**mkdocs_config_base, "docs_dir": str(docs_path)} + plugin.on_config(mkdocs_config_full) + + assert plugin.tags_folder == absolute_folder + assert absolute_folder.exists() + + + def test_generate_tags_page_empty_data(self): + """Test generate_tags_page with no tags data.""" + plugin = TagsPlugin() + # Ensure default template path is valid for this test + # This might require adjusting if the test running directory changes + # For now, assuming it can find templates relative to plugin.py + output = plugin.generate_tags_page(defaultdict(list)) + assert "# Contents grouped by tag" in output + # Check that no tags are listed (specific content depends on template) + assert "## cat" in output + assert "* [Page One](page1.md)" in output + assert "* [Page Two](page2.md)" in output + assert "## dog" in output + assert "## fish" in output + + def test_generate_tags_file_creates_file(self, tmp_path: Path): + """Test that generate_tags_file actually creates the tags file.""" + plugin = TagsPlugin() + plugin.tags_folder = tmp_path / "my_tags_output" + plugin.tags_filename = Path("final-tags.md") + + # Minimal on_config setup + plugin.tags_folder.mkdir(parents=True, exist_ok=True) + + # Sample metadata + page_meta = {"title": "Test", "filename": "test.md", "tags": ["sample"]} + plugin.metadata = [page_meta] # type: ignore + + plugin.generate_tags_file() + + expected_file = plugin.tags_folder / plugin.tags_filename + assert expected_file.exists() + content = expected_file.read_text(encoding="utf-8") + assert "## sample" in content + assert "* [Test](test.md)" in content + + # TODO: More tests for on_files, especially interaction with mkdocs File objects + # TODO: Test with custom templates + # TODO: Test slugify behavior if special characters are in tags + # TODO: Test different configurations of tags_folder (relative, absolute) + +# Example of how to run with hatch: hatch run test +# Example of how to run for coverage: hatch run test:cov +# Open htmlcov/index.html to see coverage report