diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6a7695c..4776211 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,8 @@ updates: directory: "/" schedule: interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/python-package.yaml b/.github/workflows/python-package.yaml index fdccf0d..a938dac 100644 --- a/.github/workflows/python-package.yaml +++ b/.github/workflows/python-package.yaml @@ -20,19 +20,63 @@ jobs: - "3.8" - "3.9" - "3.10" + - "3.11" + - "3.12" + exclude: + # 3.8 on windows fails due to some pip issue + - os: windows-latest + python-version: "3.8" steps: - - uses: actions/checkout@v2 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Run tests - run: | + - run: | + make venv + - run: | make test - - name: Run linter - run: | + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - run: | + make venv + - run: | make lint + + mypy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - run: | + make venv + - run: | + make mypy + + + test-release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.x" + + - run: | + make venv + - run: | + make test-release diff --git a/.gitignore b/.gitignore index 4780a71..65fa4d1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,10 @@ build/ dist/ *.egg-info/ -docs/_build/ -docs/api/ +site/ htmlcov/ .coverage +.mypy_cache venv/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml index f076753..150d739 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,7 +1,14 @@ version: 2 +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +mkdocs: + configuration: mkdocs.yml + python: - version: 3.8 install: - requirements: requirements.txt - requirements: requirements-dev.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3009093..f4a94ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,113 @@ # Changelog -## unreleased +## [2.2.1] -- 2023-11-11 +* fixed `suggests` to blag-doc + +## [2.2.0] -- 2023-11-05 + +* switched from flake8 to ruff +* added missing docstrings +* fixed dev requirements in pyproject, still pointing to sphinx +* added Python3.12 to test suite +* removed debian/watch + +## [2.1.0] -- 2023-08-27 + +* default theme: `img` have now `max-width: 100%` by default to avoid very + large images overflowing +* packaging: explicitly list `templates`, `static` and `content` as packages + instead of relying on package-data for setuptools. additionally, created a + MANIFEST.in to add the contents of these directories here as well. the + automatic finding of namespace packages and packaga-data, currently does not + work as advertised in setuptools' docs +* updated dependencies +* created debian/watch + +## [2.0.0] - 2023-06-16 + +### Breaking + +* blag does not use default fallback templates anymore and will return an error + if it is unable to find required templates, e.g. in `templates/`. + + Users upgrading from older versions can either run `blag quickstart` (don't + forget to backup your `config.ini` or copy the templates from blag's + resources (the resource path is shown in the error message). + + New users are not affected as `blag quickstart` will generate the needed + templates. + +* Split former archive page which served as index.html into "index" and + "archive", each with their own template, respectively. Index is the landing + page and shows by default only the latest 10 articles. Archive shows the full + list of articles. + + If you used custom templates, + * you should create an "index.html"-template (take blag's default one as a + starting point) + * you may want to include the new "/archive.html" link somewhere in your + navigation + +### Changed + +* blag comes now with a simple yet good looking default theme that supports + syntax highlighting and a light- and dark theme. + +* apart from the generated configuration, `blag quickstart` will now also + create the initial directory structure, with the default template, the static + directory with the CSS files and the content directory with some initial + content to get the user started + +* Added a make target to update the pygments themes + +* updated dependencies: + * markdown 3.4.3 + * pygments 2.15.1 + * pytest 7.3.2 + * types-markdown 3.4.2.9 + * build 0.10.0 + +* Switched from sphinx to mkdocs + +### Fixed + +* fixed pyproject.toml to include tests/conftest.py + + +## [1.5.0] - 2023-04-16 + +* moved to pyproject.toml +* added python 3.11 to test suite +* break out lint and mypy from test matrix and only run on linux- and latest + stable python to make it a bit more efficient +* added dependabot check for github actions +* updated dependencies: + * mypy 1.2.0 + * types-markdown 3.4.2.1 + * pytest-cov 4.0.0 + * sphinx 5.3.0 + * pytest 7.3.0 + * flake8 6.0.0 + * twine 4.0.2 + * wheel 0.40.0 + +## [1.4.1] - 2022-09-29 + +* applied multi-arch fix by debian-janitor +* updated dependencies: + * pytest 7.1.3 + * sphinx 5.2.1 + * types-markdown 3.4.2 + +## [1.4.0] - 2022-09-01 + +* added type hints and mypy --strict to test suite * improved default template * updated dependencies: * markdown 3.4.1 - * flake 5.0.2 + * pygments 2.13.0 + * flake 5.0.4 * twine 4.0.1 * sphinx 5.1.1 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..dbb52ef --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include blag/content * +recursive-include blag/static * +recursive-include blag/templates * diff --git a/Makefile b/Makefile index d973043..13e2bfd 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ VENV = venv BIN=$(VENV)/bin DOCS_SRC = docs -DOCS_OUT = $(DOCS_SRC)/_build +DOCS_OUT = site ifeq ($(OS), Windows_NT) @@ -14,40 +14,56 @@ endif .PHONY: all -all: lint test +all: lint mypy test test-release -$(VENV): requirements.txt requirements-dev.txt setup.py +$(VENV): requirements.txt requirements-dev.txt pyproject.toml $(PY) -m venv $(VENV) $(BIN)/pip install --upgrade -r requirements.txt $(BIN)/pip install --upgrade -r requirements-dev.txt - $(BIN)/pip install -e . + $(BIN)/pip install -e .['dev'] touch $(VENV) .PHONY: test test: $(VENV) $(BIN)/pytest +.PHONY: mypy +mypy: $(VENV) + $(BIN)/mypy + .PHONY: lint lint: $(VENV) - $(BIN)/flake8 + $(BIN)/ruff check . + +.PHONY: build +build: $(VENV) + rm -rf dist + $(BIN)/python3 -m build + +.PHONY: test-release +test-release: $(VENV) build + $(BIN)/twine check dist/* .PHONY: release -release: $(VENV) - rm -rf dist - $(BIN)/python setup.py sdist bdist_wheel +release: $(VENV) build $(BIN)/twine upload dist/* +.PHONY: update-pygmentize +update-pygmentize: $(VENV) + $(BIN)/pygmentize -f html -S default > blag/static/code-light.css + $(BIN)/pygmentize -f html -S monokai > blag/static/code-dark.css + .PHONY: docs docs: $(VENV) - $(BIN)/sphinx-build $(DOCS_SRC) $(DOCS_OUT) + $(BIN)/mkdocs build .PHONY: clean clean: rm -rf build dist *.egg-info rm -rf $(VENV) rm -rf $(DOCS_OUT) - rm -rf $(DOCS_SRC)/api find . -type f -name *.pyc -delete find . -type d -name __pycache__ -delete # coverage rm -rf htmlcov .coverage + rm -rf .mypy_cache diff --git a/README.md b/README.md index 77848f4..1038bb8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ blag is named after [the blag of the webcomic xkcd][blagxkcd]. ## Features * Write content in [Markdown][] +* Good looking default theme: + ![Blag Screenshot](blag/static/blag.png) * Theming support using [Jinja2][] templates * Generation of Atom feeds for blog content * Fenced code blocks and syntax highlighting using [Pygments][] diff --git a/blag/__init__.py b/blag/__init__.py index 6527216..189ce64 100644 --- a/blag/__init__.py +++ b/blag/__init__.py @@ -1 +1 @@ -from blag.version import __VERSION__ # noqa +from blag.version import __VERSION__ as __VERSION__ # noqa diff --git a/blag/blag.py b/blag/blag.py index c47fc2b..2d72054 100644 --- a/blag/blag.py +++ b/blag/blag.py @@ -1,44 +1,47 @@ #!/usr/bin/env python3 -"""blag's core methods. +"""blag's core methods.""" -""" +# remove when we don't support py38 anymore +from __future__ import annotations import argparse +import configparser +import logging import os import shutil -import logging -import configparser import sys +from typing import Any -from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader import feedgenerator +from jinja2 import Environment, FileSystemLoader, Template, TemplateNotFound -from blag.markdown import markdown_factory, convert_markdown +import blag from blag.devserver import serve -from blag.version import __VERSION__ +from blag.markdown import convert_markdown, markdown_factory from blag.quickstart import quickstart +from blag.version import __VERSION__ logger = logging.getLogger(__name__) logging.basicConfig( - level=logging.INFO, - format='%(asctime)s %(levelname)s %(name)s %(message)s', + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", ) -def main(args=None): - """Main entrypoint for the CLI. +def main(arguments: list[str] | None = None) -> None: + """Run the CLI. This method parses the CLI arguments and executes the respective commands. Parameters ---------- - args : list[str] + arguments optional parameters, used for testing """ - args = parse_args(args) + args = parse_args(arguments) # set loglevel if args.verbose: logger.setLevel(logging.DEBUG) @@ -46,12 +49,12 @@ def main(args=None): args.func(args) -def parse_args(args=None): +def parse_args(args: list[str] | None = None) -> argparse.Namespace: """Parse command line arguments. Parameters ---------- - args : List[str] + args optional parameters, used for testing Returns @@ -61,142 +64,148 @@ def parse_args(args=None): """ parser = argparse.ArgumentParser() parser.add_argument( - '--version', - action='version', - version='%(prog)s '+__VERSION__, + "--version", + action="version", + version="%(prog)s " + __VERSION__, ) parser.add_argument( - '-v', '--verbose', - action='store_true', - help='Verbose output.', + "-v", + "--verbose", + action="store_true", + help="Verbose output.", ) - commands = parser.add_subparsers(dest='command') + commands = parser.add_subparsers(dest="command") commands.required = True build_parser = commands.add_parser( - 'build', - help='Build website.', + "build", + help="Build website.", ) build_parser.set_defaults(func=build) build_parser.add_argument( - '-i', '--input-dir', - default='content', - help='Input directory (default: content)', + "-i", + "--input-dir", + default="content", + help="Input directory (default: content)", ) build_parser.add_argument( - '-o', '--output-dir', - default='build', - help='Ouptut directory (default: build)', + "-o", + "--output-dir", + default="build", + help="Ouptut directory (default: build)", ) build_parser.add_argument( - '-t', '--template-dir', - default='templates', - help='Template directory (default: templates)', + "-t", + "--template-dir", + default="templates", + help="Template directory (default: templates)", ) build_parser.add_argument( - '-s', '--static-dir', - default='static', - help='Static directory (default: static)', + "-s", + "--static-dir", + default="static", + help="Static directory (default: static)", ) quickstart_parser = commands.add_parser( - 'quickstart', - help="Quickstart blag, creating necessary configuration.", + "quickstart", + help="Quickstart blag, creating necessary configuration.", ) quickstart_parser.set_defaults(func=quickstart) serve_parser = commands.add_parser( - 'serve', - help="Start development server.", + "serve", + help="Start development server.", ) serve_parser.set_defaults(func=serve) serve_parser.add_argument( - '-i', '--input-dir', - default='content', - help='Input directory (default: content)', + "-i", + "--input-dir", + default="content", + help="Input directory (default: content)", ) serve_parser.add_argument( - '-o', '--output-dir', - default='build', - help='Ouptut directory (default: build)', + "-o", + "--output-dir", + default="build", + help="Ouptut directory (default: build)", ) serve_parser.add_argument( - '-t', '--template-dir', - default='templates', - help='Template directory (default: templates)', + "-t", + "--template-dir", + default="templates", + help="Template directory (default: templates)", ) serve_parser.add_argument( - '-s', '--static-dir', - default='static', - help='Static directory (default: static)', + "-s", + "--static-dir", + default="static", + help="Static directory (default: static)", ) return parser.parse_args(args) -def get_config(configfile): +def get_config(configfile: str) -> configparser.SectionProxy: """Load site configuration from configfile. Parameters ---------- - configfile : str + configfile path to configuration file Returns ------- - dict + configparser.SectionProxy """ config = configparser.ConfigParser() config.read(configfile) # check for the mandatory options - for value in 'base_url', 'title', 'description', 'author': + for value in "base_url", "title", "description", "author": try: - config['main'][value] + config["main"][value] except Exception: - print(f'{value} is missing in {configfile}!') + print(f"{value} is missing in {configfile}!") sys.exit(1) - if not config['main']['base_url'].endswith('/'): - logger.warning('base_url does not end with a slash, adding it.') - config['main']['base_url'] += '/' + if not config["main"]["base_url"].endswith("/"): + logger.warning("base_url does not end with a slash, adding it.") + config["main"]["base_url"] += "/" - return config['main'] + return config["main"] -def environment_factory(template_dir=None, globals_=None): +def environment_factory( + template_dir: str, + globals_: dict[str, object] | None = None, +) -> Environment: """Environment factory. - Creates a Jinja2 Environment with the default templates and - additional templates from `template_dir` loaded. If `globals` are - provided, they are attached to the environment and thus available to - all contexts. + Creates a Jinja2 Environment with the templates from `template_dir` loaded. + If `globals` are provided, they are attached to the environment and thus + available to all contexts. Parameters ---------- - template_dir : str - globals_ : dict + template_dir + directory containing the templates + globals_ Returns ------- jinja2.Environment """ - # first we try the custom templates, and fall back the ones provided - # by blag - loaders = [] - if template_dir: - loaders.append(FileSystemLoader([template_dir])) - loaders.append(PackageLoader('blag', 'templates')) - env = Environment(loader=ChoiceLoader(loaders)) + env = Environment(loader=FileSystemLoader(template_dir)) if globals_: env.globals = globals_ return env -def build(args): +def build(args: argparse.Namespace) -> None: """Build the site. This is blag's main method that builds the site, generates the feed @@ -204,43 +213,57 @@ def build(args): Parameters ---------- - args : argparse.Namespace + args """ - os.makedirs(f'{args.output_dir}', exist_ok=True) + os.makedirs(f"{args.output_dir}", exist_ok=True) convertibles = [] for root, dirnames, filenames in os.walk(args.input_dir): for filename in filenames: - rel_src = os.path.relpath(f'{root}/{filename}', - start=args.input_dir) + rel_src = os.path.relpath( + f"{root}/{filename}", start=args.input_dir + ) # all non-markdown files are just copied over, the markdown # files are converted to html - if rel_src.endswith('.md'): + if rel_src.endswith(".md"): rel_dst = rel_src - rel_dst = rel_dst[:-3] + '.html' + rel_dst = rel_dst[:-3] + ".html" convertibles.append((rel_src, rel_dst)) else: - shutil.copy(f'{args.input_dir}/{rel_src}', - f'{args.output_dir}/{rel_src}') + shutil.copy( + f"{args.input_dir}/{rel_src}", + f"{args.output_dir}/{rel_src}", + ) for dirname in dirnames: # all directories are copied into the output directory - path = os.path.relpath(f'{root}/{dirname}', start=args.input_dir) - os.makedirs(f'{args.output_dir}/{path}', exist_ok=True) + path = os.path.relpath(f"{root}/{dirname}", start=args.input_dir) + os.makedirs(f"{args.output_dir}/{path}", exist_ok=True) # copy static files over - logger.info('Copying static files.') + logger.info("Copying static files.") if os.path.exists(args.static_dir): shutil.copytree(args.static_dir, args.output_dir, dirs_exist_ok=True) - config = get_config('config.ini') + config = get_config("config.ini") env = environment_factory(args.template_dir, dict(site=config)) - page_template = env.get_template('page.html') - article_template = env.get_template('article.html') - archive_template = env.get_template('archive.html') - tags_template = env.get_template('tags.html') - tag_template = env.get_template('tag.html') + try: + page_template = env.get_template("page.html") + article_template = env.get_template("article.html") + index_template = env.get_template("index.html") + archive_template = env.get_template("archive.html") + tags_template = env.get_template("tags.html") + tag_template = env.get_template("tag.html") + except TemplateNotFound as exc: + tmpl = os.path.join(blag.__path__[0], "templates") + logger.error( + f'Template "{exc.name}" not found in {args.template_dir}! ' + "Consider running `blag quickstart` or copying the " + f"missing template from {tmpl}." + ) + + sys.exit(1) articles, pages = process_markdown( convertibles, @@ -251,18 +274,25 @@ def build(args): ) generate_feed( - articles, args.output_dir, - base_url=config['base_url'], - blog_title=config['title'], - blog_description=config['description'], - blog_author=config['author'], + articles, + args.output_dir, + base_url=config["base_url"], + blog_title=config["title"], + blog_description=config["description"], + blog_author=config["author"], ) + generate_index(articles, index_template, args.output_dir) generate_archive(articles, archive_template, args.output_dir) generate_tags(articles, tags_template, tag_template, args.output_dir) -def process_markdown(convertibles, input_dir, output_dir, - page_template, article_template): +def process_markdown( + convertibles: list[tuple[str, str]], + input_dir: str, + output_dir: str, + page_template: Template, + article_template: Template, +) -> tuple[list[tuple[str, dict[str, Any]]], list[tuple[str, dict[str, Any]]]]: """Process markdown files. This method processes the convertibles, converts them to html and @@ -271,18 +301,21 @@ def process_markdown(convertibles, input_dir, output_dir, If a markdown file has a `date` metadata field it will be recognized as article otherwise as page. + Articles are sorted by date in descending order. + Parameters ---------- - convertibles : List[Tuple[str, str]] + convertibles relative paths to markdown- (src) html- (dest) files - input_dir : str - output_dir : str - page_template, archive_template : jinja2 template - templats for pages and articles + input_dir + output_dir + page_template, archive_template + templates for pages and articles Returns ------- - articles, pages : List[Tuple[str, Dict]] + list[tuple[str, dict[str, Any]]], list[tuple[str, dict[str, Any]]] + articles and pages, articles are sorted by date in descending order. """ logger.info("Converting Markdown files...") @@ -291,21 +324,21 @@ def process_markdown(convertibles, input_dir, output_dir, articles = [] pages = [] for src, dst in convertibles: - logger.debug(f'Processing {src}') + logger.debug(f"Processing {src}") # see first if the dst actually needs re-building. for that we compare # the mtimes and assume mtime_dst > mtime_src means that it needs not # to be rebuilt - if os.path.exists(f'{output_dir}/{dst}'): - mtime_src = os.stat(f'{input_dir}/{src}').st_mtime - mtime_dst = os.stat(f'{output_dir}/{dst}').st_mtime + if os.path.exists(f"{output_dir}/{dst}"): + mtime_src = os.stat(f"{input_dir}/{src}").st_mtime + mtime_dst = os.stat(f"{output_dir}/{dst}").st_mtime if mtime_dst >= mtime_src: logger.debug( - 'Skipping, as target exists and is newer than source.' + "Skipping, as target exists and is newer than source." ) continue - with open(f'{input_dir}/{src}', 'r') as fh: + with open(f"{input_dir}/{src}") as fh: body = fh.read() content, meta = convert_markdown(md, body) @@ -315,139 +348,180 @@ def process_markdown(convertibles, input_dir, output_dir, # if markdown has date in meta, we treat it as a blog article, # everything else are just pages - if meta and 'date' in meta: + if meta and "date" in meta: articles.append((dst, context)) result = article_template.render(context) else: pages.append((dst, context)) result = page_template.render(context) - with open(f'{output_dir}/{dst}', 'w') as fh_dest: + with open(f"{output_dir}/{dst}", "w") as fh_dest: fh_dest.write(result) # sort articles by date, descending - articles = sorted(articles, key=lambda x: x[1]['date'], reverse=True) + articles = sorted(articles, key=lambda x: x[1]["date"], reverse=True) return articles, pages def generate_feed( - articles, - output_dir, - base_url, - blog_title, - blog_description, - blog_author, -): + articles: list[tuple[str, dict[str, Any]]], + output_dir: str, + base_url: str, + blog_title: str, + blog_description: str, + blog_author: str, +) -> None: """Generate Atom feed. Parameters ---------- - articles : list[list[str, dict]] + articles list of relative output path and article dictionary - output_dir : str + output_dir where the feed is stored - base_url : str + base_url base url - blog_title : str + blog_title blog title - blog_description : str + blog_description blog description - blog_author : str + blog_author blog author """ - logger.info('Generating Atom feed.') + logger.info("Generating Atom feed.") feed = feedgenerator.Atom1Feed( - link=base_url, - title=blog_title, - description=blog_description, - feed_url=base_url + 'atom.xml', + link=base_url, + title=blog_title, + description=blog_description, + feed_url=base_url + "atom.xml", ) for dst, context in articles: # if article has a description, use that. otherwise fall back to # the title - description = context.get('description', context['title']) + description = context.get("description", context["title"]) feed.add_item( - title=context['title'], + title=context["title"], author_name=blog_author, link=base_url + dst, description=description, - content=context['content'], - pubdate=context['date'], + content=context["content"], + pubdate=context["date"], ) - with open(f'{output_dir}/atom.xml', 'w') as fh: - feed.write(fh, encoding='utf8') + with open(f"{output_dir}/atom.xml", "w") as fh: + feed.write(fh, encoding="utf8") -def generate_archive(articles, template, output_dir): - """Generate the archive page. +def generate_index( + articles: list[tuple[str, dict[str, Any]]], + template: Template, + output_dir: str, +) -> None: + """Generate the index page. + + This is used for the index (i.e. landing) page. Parameters ---------- - articles : list[list[str, dict]] + articles List of articles. Each article has the destination path and a dictionary with the content. - template : jinja2.Template instance - output_dir : str + template + output_dir """ archive = [] for dst, context in articles: entry = context.copy() - entry['dst'] = dst + entry["dst"] = dst archive.append(entry) result = template.render(dict(archive=archive)) - with open(f'{output_dir}/index.html', 'w') as fh: + with open(f"{output_dir}/index.html", "w") as fh: fh.write(result) -def generate_tags(articles, tags_template, tag_template, output_dir): +def generate_archive( + articles: list[tuple[str, dict[str, Any]]], + template: Template, + output_dir: str, +) -> None: + """Generate the archive page. + + This is used for the full archive. + + Parameters + ---------- + articles + List of articles. Each article has the destination path and a + dictionary with the content. + template + output_dir + + """ + archive = [] + for dst, context in articles: + entry = context.copy() + entry["dst"] = dst + archive.append(entry) + + result = template.render(dict(archive=archive)) + with open(f"{output_dir}/archive.html", "w") as fh: + fh.write(result) + + +def generate_tags( + articles: list[tuple[str, dict[str, Any]]], + tags_template: Template, + tag_template: Template, + output_dir: str, +) -> None: """Generate the tags page. Parameters ---------- - articles : list[list[str, dict]] + articles List of articles. Each article has the destination path and a dictionary with the content. - tags_template, tag_template : jinja2.Template instance - output_dir : str + tags_template, tag_template + output_dir """ logger.info("Generating Tag-pages.") - os.makedirs(f'{output_dir}/tags', exist_ok=True) - + os.makedirs(f"{output_dir}/tags", exist_ok=True) # get tags number of occurrences - all_tags = {} + all_tags: dict[str, int] = {} for _, context in articles: - tags = context.get('tags', []) + tags: list[str] = context.get("tags", []) for tag in tags: all_tags[tag] = all_tags.get(tag, 0) + 1 # sort by occurrence - all_tags = sorted(all_tags.items(), key=lambda x: x[1], reverse=True) + taglist: list[tuple[str, int]] = sorted( + all_tags.items(), key=lambda x: x[1], reverse=True + ) - result = tags_template.render(dict(tags=all_tags)) - with open(f'{output_dir}/tags/index.html', 'w') as fh: + result = tags_template.render(dict(tags=taglist)) + with open(f"{output_dir}/tags/index.html", "w") as fh: fh.write(result) # get tags and archive per tag - all_tags = {} + all_tags2: dict[str, list[dict[str, Any]]] = {} for dst, context in articles: - tags = context.get('tags', []) + tags = context.get("tags", []) for tag in tags: - archive = all_tags.get(tag, []) + archive: list[dict[str, Any]] = all_tags2.get(tag, []) entry = context.copy() - entry['dst'] = dst + entry["dst"] = dst archive.append(entry) - all_tags[tag] = archive + all_tags2[tag] = archive - for tag, archive in all_tags.items(): + for tag, archive in all_tags2.items(): result = tag_template.render(dict(archive=archive, tag=tag)) - with open(f'{output_dir}/tags/{tag}.html', 'w') as fh: + with open(f"{output_dir}/tags/{tag}.html", "w") as fh: fh.write(result) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/blag/content/about.md b/blag/content/about.md new file mode 100644 index 0000000..4f01ccf --- /dev/null +++ b/blag/content/about.md @@ -0,0 +1,8 @@ +title: About Me +description: Short description of this page. + + +## About Me + +This is a regular page, i.e. not a blog post. Feel free to delete this page, +populate it with more content or generate more [pages like this](testpage.md). diff --git a/blag/content/hello-world.md b/blag/content/hello-world.md new file mode 100644 index 0000000..7039dd9 --- /dev/null +++ b/blag/content/hello-world.md @@ -0,0 +1,51 @@ +Title: Hello World! +Description: Hello there, this is the first blog post. You should read me first. +Date: 2023-01-01 12:00 +Tags: blag, pygments + + +## Hello World + +This is an example blog post. Internally, blag differentiates between **pages** +and **articles**. Intuitively, pages are simple pages and articles are blog +posts. The decision whether a document is a page or an article is made +depending on the presence of the `date` metadata element: Any document that +contains the `date` metadata element is an article, everything else a page. + +This differentiation has consequences: + +* blag uses different templates: `page.html` and `article.html` +* only articles are collected in the Atom feed +* only articles are aggregated in the tag pages + +For more detailed information, please refer to the [documentation][doc] + +[doc]: https://blag.readthedocs.io + + +### Syntax Highlighting + +```python +def foo(bar): + """This is a docstring. + + """ + # comment + return bar +``` + +Syntax highlighting is done via [Pygments][pygments]. For code blocks, blag +generates the necessary CSS classes by default, which you can use to style your +code using CSS. It provides you with a default light- and dark theme, for more +information on how to generate a different theme, please refer to [Pygments' +documentation][pygments]. + +[pygments]: https://pygments.org + + +### Next Steps + +* Adapt the files in `templates` to your needs +* Check out the files in `static` and modify as needed +* Add some content +* Change the [favicon.ico](favicon.ico) diff --git a/blag/content/second-post.md b/blag/content/second-post.md new file mode 100644 index 0000000..354fac5 --- /dev/null +++ b/blag/content/second-post.md @@ -0,0 +1,11 @@ +Title: Second Post +Description: This is the second blog post, so you can see how it looks like on the front page. +Date: 2023-01-02 12:00 +Tags: blag + + +## Second Post + +This page serves no purpose :) + +![Blag Screenshot](blag.png) diff --git a/blag/content/testpage.md b/blag/content/testpage.md new file mode 100644 index 0000000..24fe02b --- /dev/null +++ b/blag/content/testpage.md @@ -0,0 +1,46 @@ +# This Is A Headline + +This is some **bold text** with some `code` inside. This is _some_underlined_ +text with some `code` inside. This is some text with some `code` inside. This +is some text with some `code` inside. This is some text with some `code` +inside. This is some text with some `code` inside. This is some text with some +`code` inside. This is some text with some `code` inside. + +This is some [link](https://example.com) inside the text -- it does not really +lead anywhere! This is some [link](https://example.com) inside the text -- it +does not really lead anywhere! This is some [link](https://example.com) inside +the text -- it does not really lead anywhere! + + +* some bullets +* some other + * bullets + * foo + +```python +# this is some python code + +class Foo: + + def __init__(self, foo, bar): + self.foo = foo + self.bar = bar + + def do_something(): + """This is the docstring of this method. + + """ + return foo +``` + + +## Some other headline + +This is some other text + +```makefile + +# some comment +foo: + ls -lh +``` diff --git a/blag/devserver.py b/blag/devserver.py index dbc7386..4f3ec50 100644 --- a/blag/devserver.py +++ b/blag/devserver.py @@ -6,20 +6,24 @@ site if necessary. """ -import os +# remove when we don't support py38 anymore +from __future__ import annotations + +import argparse import logging -import time import multiprocessing -from http.server import SimpleHTTPRequestHandler, HTTPServer +import os +import time from functools import partial +from http.server import HTTPServer, SimpleHTTPRequestHandler +from typing import NoReturn from blag import blag - logger = logging.getLogger(__name__) -def get_last_modified(dirs): +def get_last_modified(dirs: list[str]) -> float: """Get the last modified time. This method recursively goes through `dirs` and returns the most @@ -27,16 +31,16 @@ def get_last_modified(dirs): Parameters ---------- - dirs : list[str] + dirs list of directories to search Returns ------- - int + float most recent modification time found in `dirs` """ - last_mtime = 0 + last_mtime = 0.0 for dir in dirs: for root, dirs, files in os.walk(dir): @@ -48,7 +52,7 @@ def get_last_modified(dirs): return last_mtime -def autoreload(args): +def autoreload(args: argparse.Namespace) -> NoReturn: """Start the autoreloader. This method monitors the given directories for changes (i.e. the @@ -60,33 +64,37 @@ def autoreload(args): Parameters ---------- - args : argparse.Namespace + args + contains the input-, template- and static dir """ dirs = [args.input_dir, args.template_dir, args.static_dir] - logger.info(f'Monitoring {dirs} for changes...') + logger.info(f"Monitoring {dirs} for changes...") # make sure we trigger the rebuild immediately when we enter the # loop to avoid serving stale contents - last_mtime = 0 + last_mtime = 0.0 while True: mtime = get_last_modified(dirs) if mtime > last_mtime: last_mtime = mtime - logger.info('Change detected, rebuilding...') + logger.info("Change detected, rebuilding...") blag.build(args) time.sleep(1) -def serve(args): +def serve(args: argparse.Namespace) -> None: """Start the webserver and the autoreloader. Parameters ---------- - args : arparse.Namespace + args + contains the input-, template- and static dir """ - httpd = HTTPServer(('', 8000), partial(SimpleHTTPRequestHandler, - directory=args.output_dir)) + httpd = HTTPServer( + ("", 8000), + partial(SimpleHTTPRequestHandler, directory=args.output_dir), + ) proc = multiprocessing.Process(target=autoreload, args=(args,)) proc.start() logger.info("\n\n Devserver Started -- visit http://localhost:8000\n") diff --git a/blag/markdown.py b/blag/markdown.py index 328b0d0..bc7aa6b 100644 --- a/blag/markdown.py +++ b/blag/markdown.py @@ -5,19 +5,22 @@ processing. """ -from datetime import datetime +# remove when we don't support py38 anymore +from __future__ import annotations + import logging +from datetime import datetime from urllib.parse import urlsplit, urlunsplit +from xml.etree.ElementTree import Element from markdown import Markdown from markdown.extensions import Extension from markdown.treeprocessors import Treeprocessor - logger = logging.getLogger(__name__) -def markdown_factory(): +def markdown_factory() -> Markdown: """Create a Markdown instance. This method exists only to ensure we use the same Markdown instance @@ -30,15 +33,21 @@ def markdown_factory(): """ md = Markdown( extensions=[ - 'meta', 'fenced_code', 'codehilite', 'smarty', - MarkdownLinkExtension() + "meta", + "fenced_code", + "codehilite", + "smarty", + MarkdownLinkExtension(), ], - output_format='html5', + output_format="html", ) return md -def convert_markdown(md, markdown): +def convert_markdown( + md: Markdown, + markdown: str, +) -> tuple[str, dict[str, str]]: """Convert markdown into html and extract meta data. Some meta data is treated special: @@ -48,72 +57,80 @@ def convert_markdown(md, markdown): Parameters ---------- - md : markdown.Markdown instance - markdown : str + md + the Markdown instance + markdown + the markdown text that should be converted Returns ------- - str, dict : + str, dict[str, str] html and metadata """ md.reset() content = md.convert(markdown) - meta = md.Meta + meta = md.Meta # type: ignore # markdowns metadata consists as list of strings -- one item per # line. let's convert into single strings. for key, value in meta.items(): - value = '\n'.join(value) + value = "\n".join(value) meta[key] = value # convert known metadata # date: datetime - if 'date' in meta: - meta['date'] = datetime.fromisoformat(meta['date']) - meta['date'] = meta['date'].astimezone() + if "date" in meta: + meta["date"] = datetime.fromisoformat(meta["date"]) + meta["date"] = meta["date"].astimezone() # tags: list[str] and lower case - if 'tags' in meta: - tags = meta['tags'].split(',') + if "tags" in meta: + tags = meta["tags"].split(",") tags = [t.lower() for t in tags] tags = [t.strip() for t in tags] - meta['tags'] = tags + meta["tags"] = tags return content, meta class MarkdownLinkTreeprocessor(Treeprocessor): - """Converts relative links to .md files to .html + """Converts relative links to .md files to .html.""" - """ - - def run(self, root): + def run(self, root: Element) -> Element: + """Process the ElementTree.""" for element in root.iter(): - if element.tag == 'a': - url = element.get('href') + if element.tag == "a": + url = element.get("href") + # element.get could also return None, we haven't seen this so + # far, so lets wait if we raise this + assert url is not None + url = str(url) converted = self.convert(url) - element.set('href', converted) + element.set("href", converted) return root - def convert(self, url): + def convert(self, url: str) -> str: + """Convert relative .md-links to .html-links.""" scheme, netloc, path, query, fragment = urlsplit(url) logger.debug( - f'{url}: {scheme=} {netloc=} {path=} {query=} {fragment=}' + f"{url}: {scheme=} {netloc=} {path=} {query=} {fragment=}" ) - if (scheme or netloc or not path): + if scheme or netloc or not path: return url - if path.endswith('.md'): - path = path[:-3] + '.html' + if path.endswith(".md"): + path = path[:-3] + ".html" url = urlunsplit((scheme, netloc, path, query, fragment)) return url class MarkdownLinkExtension(Extension): - """markdown.extension that converts relative .md- to .html-links. + """markdown.extension that converts relative .md- to .html-links.""" - """ - def extendMarkdown(self, md): + def extendMarkdown(self, md: Markdown) -> None: + """Register the MarkdownLinkTreeprocessor.""" md.treeprocessors.register( - MarkdownLinkTreeprocessor(md), 'mdlink', 0, + MarkdownLinkTreeprocessor(md), + "mdlink", + 0, ) diff --git a/blag/quickstart.py b/blag/quickstart.py index 00b4725..fdd34d8 100644 --- a/blag/quickstart.py +++ b/blag/quickstart.py @@ -1,11 +1,17 @@ -"""Helper methods for blag's quickstart command. +"""Helper methods for blag's quickstart command.""" -""" +# remove when we don't support py38 anymore +from __future__ import annotations +import argparse import configparser +import os +import shutil + +import blag -def get_input(question, default): +def get_input(question: str, default: str) -> str: """Prompt for user input. This is a wrapper around the input-builtin. It will show the default answer @@ -13,14 +19,15 @@ def get_input(question, default): Parameters ---------- - question : str + question the question the user is presented - default : str + default the default value that will be used if no answer was given Returns ------- str + the answer """ reply = input(f"{question} [{default}]: ") @@ -29,15 +36,38 @@ def get_input(question, default): return reply -def quickstart(args): +def copy_default_theme() -> None: + """Copy default theme into current directory. + + The default theme contains the 'templates', 'content' and 'static' + directories shipped with blag. + + It will not overwrite existing files. + + """ + print("Copying default theme...") + for dir_ in "templates", "content", "static": + print(f" Copying {dir_}...") + try: + shutil.copytree( + os.path.join(blag.__path__[0], dir_), + dir_, + ) + except FileExistsError: + print(f" {dir_} already exist. Skipping.") + + +def quickstart(args: argparse.Namespace | None) -> None: """Quickstart. - This method asks the user some questions and generates a - configuration file that is needed in order to run blag. + This method asks the user some questions and generates a configuration file + that is needed in order to run blag. Additionally, it creates the content + and static directories with some initial content, to get the user started. Parameters ---------- - args : argparse.Namespace + args + not used """ base_url = get_input( @@ -58,11 +88,13 @@ def quickstart(args): ) config = configparser.ConfigParser() - config['main'] = { - 'base_url': base_url, - 'title': title, - 'description': description, - 'author': author, + config["main"] = { + "base_url": base_url, + "title": title, + "description": description, + "author": author, } - with open('config.ini', 'w') as fh: + with open("config.ini", "w") as fh: config.write(fh) + + copy_default_theme() diff --git a/blag/static/blag.png b/blag/static/blag.png new file mode 100644 index 0000000..06445a2 Binary files /dev/null and b/blag/static/blag.png differ diff --git a/blag/static/code-dark.css b/blag/static/code-dark.css new file mode 100644 index 0000000..a2af57e --- /dev/null +++ b/blag/static/code-dark.css @@ -0,0 +1,83 @@ +pre { line-height: 125%; } +td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.hll { background-color: #49483e } +.c { color: #75715e } /* Comment */ +.err { color: #960050; background-color: #1e0010 } /* Error */ +.esc { color: #f8f8f2 } /* Escape */ +.g { color: #f8f8f2 } /* Generic */ +.k { color: #66d9ef } /* Keyword */ +.l { color: #ae81ff } /* Literal */ +.n { color: #f8f8f2 } /* Name */ +.o { color: #f92672 } /* Operator */ +.x { color: #f8f8f2 } /* Other */ +.p { color: #f8f8f2 } /* Punctuation */ +.ch { color: #75715e } /* Comment.Hashbang */ +.cm { color: #75715e } /* Comment.Multiline */ +.cp { color: #75715e } /* Comment.Preproc */ +.cpf { color: #75715e } /* Comment.PreprocFile */ +.c1 { color: #75715e } /* Comment.Single */ +.cs { color: #75715e } /* Comment.Special */ +.gd { color: #f92672 } /* Generic.Deleted */ +.ge { color: #f8f8f2; font-style: italic } /* Generic.Emph */ +.gr { color: #f8f8f2 } /* Generic.Error */ +.gh { color: #f8f8f2 } /* Generic.Heading */ +.gi { color: #a6e22e } /* Generic.Inserted */ +.go { color: #66d9ef } /* Generic.Output */ +.gp { color: #f92672; font-weight: bold } /* Generic.Prompt */ +.gs { color: #f8f8f2; font-weight: bold } /* Generic.Strong */ +.gu { color: #75715e } /* Generic.Subheading */ +.gt { color: #f8f8f2 } /* Generic.Traceback */ +.kc { color: #66d9ef } /* Keyword.Constant */ +.kd { color: #66d9ef } /* Keyword.Declaration */ +.kn { color: #f92672 } /* Keyword.Namespace */ +.kp { color: #66d9ef } /* Keyword.Pseudo */ +.kr { color: #66d9ef } /* Keyword.Reserved */ +.kt { color: #66d9ef } /* Keyword.Type */ +.ld { color: #e6db74 } /* Literal.Date */ +.m { color: #ae81ff } /* Literal.Number */ +.s { color: #e6db74 } /* Literal.String */ +.na { color: #a6e22e } /* Name.Attribute */ +.nb { color: #f8f8f2 } /* Name.Builtin */ +.nc { color: #a6e22e } /* Name.Class */ +.no { color: #66d9ef } /* Name.Constant */ +.nd { color: #a6e22e } /* Name.Decorator */ +.ni { color: #f8f8f2 } /* Name.Entity */ +.ne { color: #a6e22e } /* Name.Exception */ +.nf { color: #a6e22e } /* Name.Function */ +.nl { color: #f8f8f2 } /* Name.Label */ +.nn { color: #f8f8f2 } /* Name.Namespace */ +.nx { color: #a6e22e } /* Name.Other */ +.py { color: #f8f8f2 } /* Name.Property */ +.nt { color: #f92672 } /* Name.Tag */ +.nv { color: #f8f8f2 } /* Name.Variable */ +.ow { color: #f92672 } /* Operator.Word */ +.pm { color: #f8f8f2 } /* Punctuation.Marker */ +.w { color: #f8f8f2 } /* Text.Whitespace */ +.mb { color: #ae81ff } /* Literal.Number.Bin */ +.mf { color: #ae81ff } /* Literal.Number.Float */ +.mh { color: #ae81ff } /* Literal.Number.Hex */ +.mi { color: #ae81ff } /* Literal.Number.Integer */ +.mo { color: #ae81ff } /* Literal.Number.Oct */ +.sa { color: #e6db74 } /* Literal.String.Affix */ +.sb { color: #e6db74 } /* Literal.String.Backtick */ +.sc { color: #e6db74 } /* Literal.String.Char */ +.dl { color: #e6db74 } /* Literal.String.Delimiter */ +.sd { color: #e6db74 } /* Literal.String.Doc */ +.s2 { color: #e6db74 } /* Literal.String.Double */ +.se { color: #ae81ff } /* Literal.String.Escape */ +.sh { color: #e6db74 } /* Literal.String.Heredoc */ +.si { color: #e6db74 } /* Literal.String.Interpol */ +.sx { color: #e6db74 } /* Literal.String.Other */ +.sr { color: #e6db74 } /* Literal.String.Regex */ +.s1 { color: #e6db74 } /* Literal.String.Single */ +.ss { color: #e6db74 } /* Literal.String.Symbol */ +.bp { color: #f8f8f2 } /* Name.Builtin.Pseudo */ +.fm { color: #a6e22e } /* Name.Function.Magic */ +.vc { color: #f8f8f2 } /* Name.Variable.Class */ +.vg { color: #f8f8f2 } /* Name.Variable.Global */ +.vi { color: #f8f8f2 } /* Name.Variable.Instance */ +.vm { color: #f8f8f2 } /* Name.Variable.Magic */ +.il { color: #ae81ff } /* Literal.Number.Integer.Long */ diff --git a/blag/static/code-light.css b/blag/static/code-light.css new file mode 100644 index 0000000..e2cc7b8 --- /dev/null +++ b/blag/static/code-light.css @@ -0,0 +1,73 @@ +pre { line-height: 125%; } +td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } +td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } +.hll { background-color: #ffffcc } +.c { color: #3D7B7B; font-style: italic } /* Comment */ +.err { border: 1px solid #FF0000 } /* Error */ +.k { color: #008000; font-weight: bold } /* Keyword */ +.o { color: #666666 } /* Operator */ +.ch { color: #3D7B7B; font-style: italic } /* Comment.Hashbang */ +.cm { color: #3D7B7B; font-style: italic } /* Comment.Multiline */ +.cp { color: #9C6500 } /* Comment.Preproc */ +.cpf { color: #3D7B7B; font-style: italic } /* Comment.PreprocFile */ +.c1 { color: #3D7B7B; font-style: italic } /* Comment.Single */ +.cs { color: #3D7B7B; font-style: italic } /* Comment.Special */ +.gd { color: #A00000 } /* Generic.Deleted */ +.ge { font-style: italic } /* Generic.Emph */ +.gr { color: #E40000 } /* Generic.Error */ +.gh { color: #000080; font-weight: bold } /* Generic.Heading */ +.gi { color: #008400 } /* Generic.Inserted */ +.go { color: #717171 } /* Generic.Output */ +.gp { color: #000080; font-weight: bold } /* Generic.Prompt */ +.gs { font-weight: bold } /* Generic.Strong */ +.gu { color: #800080; font-weight: bold } /* Generic.Subheading */ +.gt { color: #0044DD } /* Generic.Traceback */ +.kc { color: #008000; font-weight: bold } /* Keyword.Constant */ +.kd { color: #008000; font-weight: bold } /* Keyword.Declaration */ +.kn { color: #008000; font-weight: bold } /* Keyword.Namespace */ +.kp { color: #008000 } /* Keyword.Pseudo */ +.kr { color: #008000; font-weight: bold } /* Keyword.Reserved */ +.kt { color: #B00040 } /* Keyword.Type */ +.m { color: #666666 } /* Literal.Number */ +.s { color: #BA2121 } /* Literal.String */ +.na { color: #687822 } /* Name.Attribute */ +.nb { color: #008000 } /* Name.Builtin */ +.nc { color: #0000FF; font-weight: bold } /* Name.Class */ +.no { color: #880000 } /* Name.Constant */ +.nd { color: #AA22FF } /* Name.Decorator */ +.ni { color: #717171; font-weight: bold } /* Name.Entity */ +.ne { color: #CB3F38; font-weight: bold } /* Name.Exception */ +.nf { color: #0000FF } /* Name.Function */ +.nl { color: #767600 } /* Name.Label */ +.nn { color: #0000FF; font-weight: bold } /* Name.Namespace */ +.nt { color: #008000; font-weight: bold } /* Name.Tag */ +.nv { color: #19177C } /* Name.Variable */ +.ow { color: #AA22FF; font-weight: bold } /* Operator.Word */ +.w { color: #bbbbbb } /* Text.Whitespace */ +.mb { color: #666666 } /* Literal.Number.Bin */ +.mf { color: #666666 } /* Literal.Number.Float */ +.mh { color: #666666 } /* Literal.Number.Hex */ +.mi { color: #666666 } /* Literal.Number.Integer */ +.mo { color: #666666 } /* Literal.Number.Oct */ +.sa { color: #BA2121 } /* Literal.String.Affix */ +.sb { color: #BA2121 } /* Literal.String.Backtick */ +.sc { color: #BA2121 } /* Literal.String.Char */ +.dl { color: #BA2121 } /* Literal.String.Delimiter */ +.sd { color: #BA2121; font-style: italic } /* Literal.String.Doc */ +.s2 { color: #BA2121 } /* Literal.String.Double */ +.se { color: #AA5D1F; font-weight: bold } /* Literal.String.Escape */ +.sh { color: #BA2121 } /* Literal.String.Heredoc */ +.si { color: #A45A77; font-weight: bold } /* Literal.String.Interpol */ +.sx { color: #008000 } /* Literal.String.Other */ +.sr { color: #A45A77 } /* Literal.String.Regex */ +.s1 { color: #BA2121 } /* Literal.String.Single */ +.ss { color: #19177C } /* Literal.String.Symbol */ +.bp { color: #008000 } /* Name.Builtin.Pseudo */ +.fm { color: #0000FF } /* Name.Function.Magic */ +.vc { color: #19177C } /* Name.Variable.Class */ +.vg { color: #19177C } /* Name.Variable.Global */ +.vi { color: #19177C } /* Name.Variable.Instance */ +.vm { color: #19177C } /* Name.Variable.Magic */ +.il { color: #666666 } /* Literal.Number.Integer.Long */ diff --git a/blag/static/favicon.ico b/blag/static/favicon.ico new file mode 100644 index 0000000..6175ffc Binary files /dev/null and b/blag/static/favicon.ico differ diff --git a/blag/static/style.css b/blag/static/style.css new file mode 100644 index 0000000..f00afd6 --- /dev/null +++ b/blag/static/style.css @@ -0,0 +1,170 @@ +@import "code-light.css" (prefers-color-scheme: light); +@import "code-dark.css" (prefers-color-scheme: dark); + +@media (prefers-color-scheme: light) { + :root { + --background: #FFFFFF; + --background-dim: #f5f7f9; + + --foreground: #2B303A; + --foreground-dim: #576379; + --foreground-heavy: #191C22; + + --primary-color: #375287; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #2B363B; + --background-dim: #2F3C42; + + --foreground: #f0f2f3; + --foreground-dim: #d5d5d5; + --foreground-heavy: #f2f4f5; + + --primary-color: #A1C5FF; + } +} + + +html { + font-size: 18px; + font-family: serif; +} + +body { + margin: 0 auto; + max-width: 50rem; + background: var(--background); + color: var(--foreground); + line-height: 1.5; + padding: 0rem 0.5rem; +} + +aside { + font-size: smaller; + font-style: italic; + color: var(--foreground-dim); +} + +img { + max-width: 100%; +} + +h1, +h2, +h3, +h4, +h5, +h6, +strong { + color: var(--foreground-heavy); +} + +a { + color: var(--primary-color); +} + +h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { + text-decoration: none; +} + +h1 a:hover, h2 a:hover, h3 a:hover, h4 a:hover, h5 a:hover, h6 a:hover { + text-decoration: underline; +} + +nav ul { + list-style: none; +} + +nav li { + display: inline; +} + +nav li + li:before { + content: " · "; + margin: 0 0.5ex; +} + +article header { + display: flex; + flex-direction: row; + margin: 1rem 0; +} + +article header time { + white-space: nowrap; + color: var(--foreground-dim); + font-style: italic; + flex: 0 0 12ex; +} + +article header h2, +article header p { + font-size: 1rem; + display: inline; +} + +code, +pre { + background: var(--background-dim); + border-radius: 0.3rem; + font-family: monospace; +} + +pre { + padding: 1rem; + border-left: 2px solid var(--primary-color); + overflow: auto; +} + +code { + padding: 0.1rem 0.2rem; +} + +/* reset the padding for code inside pre */ +pre code { + padding: 0; +} + +blockquote { + background: var(--background-dim); + border-radius: 0 0.3rem 0.3rem 0; + font-style: italic; + border-left: 2px solid var(--primary-color); + margin: 0; + padding: 1rem; +} + +/* reset the margin for p inside blockquotes */ +blockquote p { + margin: 0; +} + +body > header { + padding: 2rem 0; +} + +body footer { + margin: 3rem 0; + color: var(--foreground-dim); + font-size: smaller; + text-align: center; +} + +header nav { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +header h1 { + margin: 0 auto; + color: var(--primary-color); +} + +header h2 { + display: inline; + font-size: 1.2rem; +} diff --git a/blag/templates/archive.html b/blag/templates/archive.html index e8f9cca..5109438 100644 --- a/blag/templates/archive.html +++ b/blag/templates/archive.html @@ -1,20 +1,23 @@ {% extends "base.html" %} -{% block title %}{{ site.title }}{% endblock %} +{% block title %}Archive{% endblock %} {% block content %} + {% for entry in archive %} - {% if entry.title %} -

{{entry.title}}

- - {% if entry.description %} -

— {{ entry.description }}

- {% endif %} - - {% endif %} - -

Written on {{ entry.date.date() }}.

+
+
+ +
+

{{ entry.title }}

+ {% if entry.description %} +

— {{ entry.description }}

+ {% endif %} +
+
+
{% endfor %} + {% endblock %} diff --git a/blag/templates/article.html b/blag/templates/article.html index 4080c36..889186d 100644 --- a/blag/templates/article.html +++ b/blag/templates/article.html @@ -22,7 +22,6 @@

- {{ content }} {% endblock %} diff --git a/blag/templates/base.html b/blag/templates/base.html index fc6f822..2dad0d7 100644 --- a/blag/templates/base.html +++ b/blag/templates/base.html @@ -4,12 +4,15 @@ + {%- if description %} {%- else %} {%- endif %} + + {% block title %}{% endblock %} | {{ site.description }} @@ -19,9 +22,10 @@ @@ -31,7 +35,16 @@ {% endblock %} + - diff --git a/blag/templates/index.html b/blag/templates/index.html new file mode 100644 index 0000000..002e185 --- /dev/null +++ b/blag/templates/index.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}{{ site.title }}{% endblock %} + +{% block content %} + +{% for entry in archive[:15] %} + +
+
+ +
+

{{ entry.title }}

+ {% if entry.description %} +

— {{ entry.description }}

+ {% endif %} +
+
+
+ +{% endfor %} + +

all articles...

+ +{% endblock %} diff --git a/blag/templates/page.html b/blag/templates/page.html index 5507a42..bdeab2b 100644 --- a/blag/templates/page.html +++ b/blag/templates/page.html @@ -3,5 +3,7 @@ {% block title %}{{ title }}{% endblock %} {% block content %} -{{ content }} + + {{ content }} + {% endblock %} diff --git a/blag/templates/tag.html b/blag/templates/tag.html index 7abb4df..4abd3bc 100644 --- a/blag/templates/tag.html +++ b/blag/templates/tag.html @@ -1,20 +1,25 @@ {% extends "base.html" %} -{% block title %}Tag {{ tag }}{% endblock %} +{% block title %}#{{ tag }}{% endblock %} {% block content %} + +

Articles tagged "{{ tag }}"

+ {% for entry in archive %} - {% if entry.title %} -

{{entry.title}}

- - {% if entry.description %} -

— {{ entry.description }}

- {% endif %} - - {% endif %} - -

Written on {{ entry.date.date() }}.

+
+
+ +
+

{{ entry.title }}

+ {% if entry.description %} +

— {{ entry.description }}

+ {% endif %} +
+
+
{% endfor %} + {% endblock %} diff --git a/blag/version.py b/blag/version.py index 41c78c8..30d8088 100644 --- a/blag/version.py +++ b/blag/version.py @@ -1 +1,3 @@ -__VERSION__ = '1.3.2' +"""Version information for the blag package.""" + +__VERSION__ = "2.2.1" diff --git a/debian/blag-doc.docs b/debian/blag-doc.docs index 344fcaa..45ddf0a 100644 --- a/debian/blag-doc.docs +++ b/debian/blag-doc.docs @@ -1 +1 @@ -build/html/ +site/ diff --git a/debian/blag.install b/debian/blag.install deleted file mode 100644 index 28d4e43..0000000 --- a/debian/blag.install +++ /dev/null @@ -1 +0,0 @@ -build/man/blag.1 /usr/share/man/man1 diff --git a/debian/changelog b/debian/changelog index 8f252c0..3ec7e20 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,107 @@ +blag (2.2.1) unstable; urgency=medium + + * fixed suggests field to blag-doc (Closes: #1055769) + + -- Bastian Venthur Sat, 11 Nov 2023 10:57:06 +0100 + +blag (2.2.0) unstable; urgency=medium + + * switched from flake8 to ruff + * added missing docstrings + * fixed dev requirements in pyproject, still pointing to sphinx + * added Python3.12 to test suite + * removed watch file again + + -- Bastian Venthur Sun, 05 Nov 2023 17:08:09 +0100 + +blag (2.1.0) unstable; urgency=medium + + * default theme: `img` have now `max-width: 100%` by default to avoid very + large images overflowing + * packaging: explicitly list `templates`, `static` and `content` as packages + instead of relying on package-data for setuptools. additionally, created a + MANIFEST.in to add the contents of these directories here as well. the + automatic finding of namespace packages and packaga-data, currently does + not work as advertised in setuptools' docs + * updated dependencies + * created debian/watch + + -- Bastian Venthur Sun, 27 Aug 2023 15:27:39 +0200 + +blag (2.0.0) unstable; urgency=medium + + * new upstream version + + * breaking: + * blag does not use default fallback templates anymore and will return an + error if it is unable to find required templates, e.g. in `templates/`. + Users upgrading from older versions can either run `blag quickstart` + (don't forget to backup your `config.ini` or copy the templates from + blag's resources (the resource path is shown in the error message). + New users are not affected as `blag quickstart` will generate the needed + templates. + * Split former archive page which served as index.html into "index" and + "archive", each with their own template, respectively. Index is the + landing page and shows by default only the latest 10 articles. Archive + shows the full list of articles. + If you used custom templates, + * you should create an "index.html"-template (take blag's default one as + a starting point) + * you may want to include the new "/archive.html" link somewhere in your + navigation + + * Changes: + * blag comes now with a simple yet good looking default theme that + supports syntax highlighting and a light- and dark theme. + * apart from the generated configuration, `blag quickstart` will now also + create the initial directory structure, with the default template, the + static directory with the CSS files and the content directory with some + initial content to get the user started + * Added a make target to update the pygments themes + * updated dependencies: + * markdown 3.4.3 + * pygments 2.15.1 + * pytest 7.3.2 + * types-markdown 3.4.2.9 + * build 0.10.0 + * Switched from sphinx to mkdocs + * fixed pyproject.toml to include tests/conftest.py + + -- Bastian Venthur Fri, 16 Jun 2023 22:34:29 +0200 + +blag (1.5.0) unstable; urgency=medium + + * new upstream version + * moved to pyproject.toml + * added python 3.11 to test suite + * break out lint and mypy from test matrix and only run on linux- and latest + stable python to make it a bit more efficient + * added dependabot check for github actions + * updated dependencies: + * mypy 1.2.0 + * types-markdown 3.4.2.1 + * pytest-cov 4.0.0 + * sphinx 5.3.0 + * pytest 7.3.0 + * flake8 6.0.0 + * twine 4.0.2 + * wheel 0.40.0 + + -- Bastian Venthur Sun, 16 Apr 2023 10:48:18 +0200 + +blag (1.4.1) unstable; urgency=medium + + * Applied multi-arch fix by debian-janitor + + -- Bastian Venthur Thu, 29 Sep 2022 20:41:28 +0200 + +blag (1.4.0) unstable; urgency=medium + + * added type hints and mypy --strict to test suite + * improved default template + + -- Bastian Venthur Thu, 01 Sep 2022 18:59:11 +0200 + blag (1.3.2) unstable; urgency=medium * Added --version option diff --git a/debian/control b/debian/control index 390a92a..2011e23 100644 --- a/debian/control +++ b/debian/control @@ -5,9 +5,9 @@ Maintainer: Bastian Venthur Rules-Requires-Root: no Build-Depends: debhelper-compat (= 13), - dh-sequence-sphinxdoc, dh-sequence-python3, dh-python, + pybuild-plugin-pyproject, python3-setuptools, python3-all, python3-markdown, @@ -16,7 +16,9 @@ Build-Depends: python3-pygments, python3-pytest, python3-pytest-cov, - python3-sphinx, + mkdocs, + mkdocs-material, + mkdocstrings-python-handlers, #Testsuite: autopkgtest-pkg-python Standards-Version: 4.6.0.1 Homepage: https://github.com/venthur/blag @@ -29,11 +31,12 @@ Depends: ${python3:Depends}, ${misc:Depends}, Suggests: - python-blag-doc, + blag-doc, Description: Blog-aware, static site generator Blag is a blog-aware, static site generator, written in Python. It supports the following features: * Write content in Markdown + * Good looking default theme * Theming support using Jinja2 templates * Generation of Atom feeds for blog content * Fenced code blocks and syntax highlighting using Pygments @@ -44,12 +47,13 @@ Package: blag-doc Section: doc Architecture: all Depends: - ${sphinxdoc:Depends}, ${misc:Depends}, +Multi-Arch: foreign Description: Blog-aware, static site generator (documentation) Blag is a blog-aware, static site generator, written in Python. It supports the following features: * Write content in Markdown + * Good looking default theme * Theming support using Jinja2 templates * Generation of Atom feeds for blog content * Fenced code blocks and syntax highlighting using Pygments diff --git a/debian/rules b/debian/rules index d61e888..9368a86 100755 --- a/debian/rules +++ b/debian/rules @@ -9,17 +9,9 @@ export PYBUILD_TEST_ARGS=--no-cov export PYBUILD_NAME=blag %: - dh $@ --with python3,sphinxdoc --buildsystem=pybuild + dh $@ --with python3 --buildsystem=pybuild - -# If you need to rebuild the Sphinx documentation: -# Add sphinxdoc to the dh --with line. -# -# And uncomment the following lines. execute_after_dh_auto_build-indep: export http_proxy=127.0.0.1:9 execute_after_dh_auto_build-indep: export https_proxy=127.0.0.1:9 execute_after_dh_auto_build-indep: - PYTHONPATH=. python3 -m sphinx -N -bhtml \ - docs/ build/html # HTML generator - PYTHONPATH=. python3 -m sphinx -N -bman \ - docs/ build/man # Manpage generator + PYTHONPATH=. mkdocs build diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index d4bb2cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -# Minimal makefile for Sphinx documentation -# - -# You can set these variables from the command line, and also -# from the environment for the first two. -SPHINXOPTS ?= -SPHINXBUILD ?= sphinx-build -SOURCEDIR = . -BUILDDIR = _build - -# Put it first so that "make" without argument is like "make help". -help: - @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) - -.PHONY: help Makefile - -# Catch-all target: route all unknown targets to Sphinx using the new -# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). -%: Makefile - @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api.rst b/docs/api.rst deleted file mode 100644 index f856c26..0000000 --- a/docs/api.rst +++ /dev/null @@ -1,12 +0,0 @@ -API -=== - -.. autosummary:: - :toctree: api - - blag.__init__ - blag.version - blag.blag - blag.markdown - blag.devserver - blag.quickstart diff --git a/docs/blag.md b/docs/blag.md new file mode 100644 index 0000000..adae5eb --- /dev/null +++ b/docs/blag.md @@ -0,0 +1 @@ +::: blag.blag diff --git a/docs/blag.png b/docs/blag.png new file mode 100644 index 0000000..06445a2 Binary files /dev/null and b/docs/blag.png differ diff --git a/docs/blag.rst b/docs/blag.rst deleted file mode 100644 index b00450e..0000000 --- a/docs/blag.rst +++ /dev/null @@ -1,283 +0,0 @@ -Manual -====== - - -Quickstart ----------- - -Install blag from PyPI_ - -.. code-block:: sh - - $ pip install blag - -.. _pypi: https://pypi.org/project/blag/ - -Run blag's quickstart command to create the configuration needed - -.. code-block:: sh - - $ blag quickstart - -Create some content - -.. code-block:: sh - - $ mkdir content - $ edit content/hello-world.md - -Generate the website - -.. code-block:: sh - - $ blag build - -By default, blag will search for content in ``content`` and the output will be -generated in ``build``. All markdown files in ``content`` will be converted to -html, all other files (i.e. static files) will be copied over). - -If you want more separation between the static files and the markdown content, -you can put all static files into the ``static`` directory. Blag will copy -them over to the ``build`` directory. - -If you want to customize the looks of the generated site, create a -``template`` directory and put your jinja2 templates here. - -Those directories can be changed via command line arguments. See - -.. code-block:: sh - - $ blag --help - - -Manual ------- - - -Pages and Articles -^^^^^^^^^^^^^^^^^^ - -Internally, blag differentiates between **pages** and **articles**. -Intuitively, pages are simple pages and articles are blog posts. The decision -whether a document is a page or an article is made depending on the presence -of the ``date`` metadata element: Any document that contains the ``date`` -metadata element is an article, everything else a page. - -This differentiation has consequences: - -* blag uses different templates: ``page.html`` and ``article.html`` -* only articles are collected in the Atom feed -* only articles are aggregated in the tag pages - -blag does **not** enforce a certain directory structure for pages and -articles. You can mix and match them freely or structure them in different -directories. blag will mirror the structure found in the ``content`` directory - -:: - - content/ - article1.md - article2.md - page1.md - -results in: - -:: - - build/ - article1.html - article2.html - page1.html - -Arbitrary complex structures are possible too: - -:: - - content/ - posts/ - 2020/ - 2020-01-01-foo.md - 2020-02-01-foo.md - pages/ - foo.md - bar.md - -results in: - -:: - - build/ - posts/ - 2020/ - 2020-01-01-foo.html - 2020-02-01-foo.html - pages/ - foo.html - bar.html - - -Static Files -^^^^^^^^^^^^ - -Static files can be put into the ``content`` directory and will be copied over -to the ``build`` directory as well. If you want better separation between -content and static files, you can create a ``static`` directory and put the -files there. All files and directories found in the ``static`` directory will -be copied over to ``build``. - -:: - - content/ - foo.md - bar.md - kitty.jpg - -results in: - -:: - - build/ - foo.html - bar.html - kitty.jpg - -Alternatively: - -:: - - content/ - foo.md - bar.md - static/ - kitty.jpg - -results in: - -:: - - build/ - foo.html - bar.html - kitty.jpg - - -Internal Links --------------- - -In contrast to most other static blog generators, blag will automatically -convert **relative** markdown links. That means you can link you content using -relative markdown links and blag will convert them to html automatically. The -advantage is that your content tree in markdown is consistent and -self-contained even if you don't generate html from it. - - -.. code-block:: markdown - - [...] - this is a [link](foo.md) to an internal page foo. - -becomes - -.. code-block:: html - -

this is a link to an internal page foo.

- - -Templating ----------- - -Custom templates are **optional** and stored by default in the ``templates`` -directory. blag will search the ``templates`` directory first, and fall back -to blag's default built-in templates. - -============ ====================================== =================== -Template Used For Variables -============ ====================================== =================== -page.html pages (i.e. non-articles) site, content, meta -article.html articles (i.e. blog posts) site, content, meta -archive.html archive- and landing page of the blog site, archive -tags.html list of tags site, tags -tag.html archive of Articles with a certain tag site, archive, tag -============ ====================================== =================== - -If you make use of Jinja2's template inheritance, you can of course have more -template files in the ``templates`` directory. - -``site`` - This dictionary contains the site configuration, namely: ``base_url``, - ``title``, ``description`` and ``author``. Don't confuse the site-title - and -description with the title and description of individual pages or - articles. - -``content`` - HTML, converted from markdown. - -``meta`` - ``meta`` stands for all metadata elements available in the article or - page. Please be aware that those are not wrapped in a dictionary, but - **directly** available as variables. - -``archive`` - A list of ``[destination path, context]`` tuples, where the context are - the respective variables that would be provided to the individual page or - article. - -``tags`` - List of tags. - -``tag`` - A tag. - - -Metadata ---------- - -blag supports metadata elements in the markdown files. They must come before -the content and should be separated from the content with a blank line: - -.. code-block:: markdown - - title: foo - date: 2020-02-02 - tags: this, is, a, test - description: some subtitle - - this is my content. - [...] - -blag supports *arbitrary* metadata in your documents, and you can use them -freely in you templates. However, some metadata elements are treated special: - -``date`` - If a document contains the ``date`` element, it is treated as an - **article**, otherwise as a **page**. Additionally, ``date`` elements are - expected to be in ISO format (e.g. ``1980-05-05 21:58``). They are - automatically converted into ``datetime`` objects with the local timezone - attached. - -``tags`` - Tags are interpreted as a comma separated list. All elements are stripped - and converted to lower-case: ``tags: foo, Foo Bar, BAZ`` becomes: ``[foo, - foo bar, baz]``. - - Tags in **articles** are also used to generate the tag-pages, that - aggregate all articles per tag. - -``title`` and ``description`` - The title and description are used in the html header and in the atom - feed. - - -Devserver ---------- - -blag provides a devserver which you can use for local web-development. The -devserver provides a simple web server, serving your site in -http://localhost:8000 and will automatically rebuild the project when it -detects modifications in one of the ``content``, ``static`` and ``templates`` -directories. - -.. code-block:: sh - - $ blag serve - diff --git a/docs/blag_.md b/docs/blag_.md new file mode 100644 index 0000000..317c29f --- /dev/null +++ b/docs/blag_.md @@ -0,0 +1 @@ +::: blag diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 4cb1944..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,69 +0,0 @@ -# Configuration file for the Sphinx documentation builder. -# -# This file only contains a selection of the most common options. For a full -# list see the documentation: -# https://www.sphinx-doc.org/en/master/usage/configuration.html - -# -- Path setup -------------------------------------------------------------- - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. - -import os -import sys -sys.path.insert(0, os.path.abspath('..')) - -import blag - - -# -- Project information ----------------------------------------------------- - -project = 'blag' -copyright = '2021, Bastian Venthur' -author = 'Bastian Venthur' - -# The full version, including alpha/beta/rc tags -release = blag.__VERSION__ - - -# -- General configuration --------------------------------------------------- - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autosummary', - 'sphinx.ext.autodoc', - 'sphinx.ext.napoleon', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - - -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'alabaster' - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -autodoc_default_options = { - 'members': True, - 'undoc-members': True, - 'private-members': True, - 'special-members': True, -} - -autosummary_generate = True diff --git a/docs/devserver.md b/docs/devserver.md new file mode 100644 index 0000000..e62835e --- /dev/null +++ b/docs/devserver.md @@ -0,0 +1 @@ +::: blag.devserver diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..cc0564e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,57 @@ +# Welcome to blag! + +blag is a blog-aware, static site generator, written in [Python][]. + +* an example "deployment" can be found [here][venthur.de] +* online [documentation][] is available on https://readthedocs.org. + +blag is named after [the blag of the webcomic xkcd][blagxkcd]. + +[python]: https://python.org +[blagxkcd]: https://blog.xkcd.com +[venthur.de]: https://venthur.de +[documentation]: https://blag.readthedocs.io/en/latest/ + + +## Features + +* Write content in [Markdown][] +* Good looking default theme + ![Blag Screenshot](blag.png) +* Theming support using [Jinja2][] templates +* Generation of Atom feeds for blog content +* Fenced code blocks and syntax highlighting using [Pygments][] +* Integrated devserver +* Available on [PyPI][] + +blag runs on Linux, Mac and Windows and requires Python >= 3.8 + +[markdown]: https://daringfireball.net/projects/markdown/ +[jinja2]: https://palletsprojects.com/p/jinja/ +[pygments]: https://pygments.org/ +[pypi]: https://pypi.org/project/blag/ + + +## Install + +blag is available on [PyPI][], you can install it via: + +```bash +$ pip install blag +``` + +On Debian or Ubuntu, you can also just install the Debian package: + +```bash +$ sudo aptitude install blag +``` + + +## Quickstart + +```bash +$ pip install blag # 1. install blag +$ blag quickstart # 2. create a new site +$ vim content/hello-world.md # 3. create some content +$ blag build # 4. build the website +``` diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 731814f..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,53 +0,0 @@ -.. blag documentation master file, created by - sphinx-quickstart on Sun Mar 21 13:39:00 2021. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to blag! -================ - -blag is a blog-aware, static site generator, written in Python_. An example -"deployment" can be found here_. - -blag is named after the blag of the webcomic xkcd_. - -.. _python: https://python.org -.. _xkcd: https://blog.xkcd.com -.. _here: https://venthur.de - - -Features --------- - -* Write content in Markdown_ -* Theming support using Jinja2_ templates -* Generation of Atom feeds for blog content -* Fenced code blocks and syntax highlighting using Pygments_ -* Integrated devserver -* Available on PyPI_ - -blag runs on Linux, Mac and Windows and requires Python >= 3.8 - -.. _markdown: https://daringfireball.net/projects/markdown/ -.. _jinja2: https://palletsprojects.com/p/jinja/ -.. _pygments: https://pygments.org/ -.. _pypi: https://pypi.org/project/blag/ - - -Documentation -============= - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - - blag.rst - api.rst - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 2119f51..0000000 --- a/docs/make.bat +++ /dev/null @@ -1,35 +0,0 @@ -@ECHO OFF - -pushd %~dp0 - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set SOURCEDIR=. -set BUILDDIR=_build - -if "%1" == "" goto help - -%SPHINXBUILD% >NUL 2>NUL -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% -goto end - -:help -%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% - -:end -popd diff --git a/docs/manual.md b/docs/manual.md new file mode 100644 index 0000000..072e508 --- /dev/null +++ b/docs/manual.md @@ -0,0 +1,264 @@ +# Manual + + +## Quickstart + +Install blag from [PyPI][] + +```sh +$ pip install blag +``` + +[pypi]: https://pypi.org/project/blag/ + +Run blag's quickstart command to create the configuration, templates and some +initial content. + +```sh +$ blag quickstart +``` + +Create some content + +```sh +$ edit content/hello-world.md +``` + +Generate the website + +```sh +$ blag build +``` + +By default, blag will search for content in `content` and the output will be +generated in `build`. All markdown files in `content` will be converted to +html, all other files (i.e. static files) will be copied over). + +If you want more separation between the static files and the markdown content, +you can put all static files into the `static` directory. Blag will copy them +over to the `build` directory. + +If you want to customize the look of the generated site, visit the `template` +directory. It contains jinja2 templates and can be modified as needed. + +Those directories can be changed via command line arguments. See + +```sh +$ blag --help +``` + + +## Manual + +### Pages and Articles + +Internally, blag differentiates between **pages** and **articles**. +Intuitively, pages are simple pages and articles are blog posts. The decision +whether a document is a page or an article is made depending on the presence of +the `date` metadata element: Any document that contains the `date` metadata +element is an article, everything else a page. + +This differentiation has consequences: + +* blag uses different templates: `page.html` and `article.html` +* only articles are collected in the Atom feed +* only articles are aggregated in the tag pages + +blag does **not** enforce a certain directory structure for pages and articles. +You can mix and match them freely or structure them in different directories. +blag will mirror the structure found in the `content` directory + +``` +content/ + article1.md + article2.md + page1.md +``` + +results in: + +``` +build/ + article1.html + article2.html + page1.html +``` + +Arbitrary complex structures are possible too: + +``` +content/ + posts/ + 2020/ + 2020-01-01-foo.md + 2020-02-01-foo.md + pages/ + foo.md + bar.md +``` + +results in: + +``` +build/ + posts/ + 2020/ + 2020-01-01-foo.html + 2020-02-01-foo.html + pages/ + foo.html + bar.html +``` + + +### Static Files + +Static files can be put into the `content` directory and will be copied over to +the `build` directory as well. If you want better separation between content +and static files, you can use the `static` directory and put the files there. +All files and directories found in the `static` directory will be copied over +to `build`. + +``` +content/ + foo.md + bar.md + kitty.jpg +``` + +results in: + +``` +build/ + foo.html + bar.html + kitty.jpg +``` + +Alternatively: + +``` +content/ + foo.md + bar.md +static/ + kitty.jpg +``` + +results in: + +``` +build/ + foo.html + bar.html + kitty.jpg +``` + + +### Internal Links + +In contrast to most other static blog generators, blag will automatically +convert **relative** markdown links. That means you can link you content using +relative markdown links and blag will convert them to html automatically. The +advantage is that your content tree in markdown is consistent and +self-contained even if you don't generate html from it. + + +```markdown +[...] +this is a [link](foo.md) to an internal page foo. +``` + +becomes + +```html +

this is a link to an internal page foo.

+``` + +```python +def this_is_a(test): + pass +``` + +### Templating + +Templates are stored by default in the `templates` directory. + +Template | Used For | Variables +------------ | -------------------------------------- | ------------------- +page.html | pages (i.e. non-articles) | site, content, meta +article.html | articles (i.e. blog posts) | site, content, meta +index.html | landing page of the blog | site, archive +archive.html | archive page of the blog | site, archive +tags.html | list of tags | site, tags +tag.html | archive of Articles with a certain tag | site, archive, tag + +If you make use of Jinja2's template inheritance, you can of course have more +template files in the `templates` directory. + + +#### Variables + +* `site`: This dictionary contains the site configuration, namely: `base_url`, + `title`, `description` and `author`. Don't confuse the site-title and + -description with the title and description of individual pages or articles. + +* `content`: HTML, converted from markdown. + +* `meta`: stands for all metadata elements available in the article or page. + Please be aware that those are not wrapped in a dictionary, but **directly** + available as variables. + +* `archive`: A list of `[destination path, context]` tuples, where the context + are the respective variables that would be provided to the individual page or + article. + +* `tags`: List of tags. + +* `tag`: A tag. + + +### Metadata + +blag supports metadata elements in the markdown files. They must come before +the content and should be separated from the content with a blank line: + +```markdown +title: foo +date: 2020-02-02 +tags: this, is, a, test +description: some subtitle + +this is my content. +[...] +``` + +blag supports *arbitrary* metadata in your documents, and you can use them +freely in you templates. However, some metadata elements are treated special: + +* `date`: If a document contains the `date` element, it is treated as an + **article**, otherwise as a **page**. Additionally, `date` elements are + expected to be in ISO format (e.g. `1980-05-09 21:58`). They are + automatically converted into `datetime` objects with the local timezone + attached. + +* `tags`: Tags are interpreted as a comma separated list. All elements are + stripped and converted to lower-case: `tags: foo, Foo Bar, BAZ` becomes: + `[foo, foo bar, baz]`. Tags in **articles** are also used to generate the + tag-pages, that aggregate all articles per tag. + +* `title` and `description`: The title and description are used in the html + header and in the atom feed. + + +## Devserver + +blag provides a devserver which you can use for local web-development. The +devserver provides a simple web server, serving your site in +http://localhost:8000 and will automatically rebuild the project when it +detects modifications in one of the `content`, `static` and `templates` +directories. + +```sh +$ blag serve +``` diff --git a/docs/markdown.md b/docs/markdown.md new file mode 100644 index 0000000..9310fa0 --- /dev/null +++ b/docs/markdown.md @@ -0,0 +1 @@ +::: blag.markdown diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..a67addd --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1 @@ +::: blag.quickstart diff --git a/docs/version.md b/docs/version.md new file mode 100644 index 0000000..326829d --- /dev/null +++ b/docs/version.md @@ -0,0 +1 @@ +::: blag.version diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..e891eaf --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,30 @@ +site_name: blag +site_url: https://blag.readthedocs.io/ +repo_url: https://github.com/venthur/blag +repo_name: venthur/blag + +nav: + - Home: index.md + - Manual: manual.md + - API: + - blag: blag_.md + - blag.version: version.md + - blag.blag: blag.md + - blag.markdown: markdown.md + - blag.devserver: devserver.md + - blag.quickstart: quickstart.md + +theme: + name: material + highlightjs: true + +markdown_extensions: + - pymdownx.superfences + +plugins: + - search: + - mkdocstrings: + handlers: + python: + options: + docstring_style: numpy diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..59f0d1a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["setuptools>=64.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "blag" +authors = [ + { name="Bastian Venthur", email="mail@venthur.de" }, +] +description = "blog-aware, static site generator" +keywords = ["markdown", "blag", "blog", "static site generator", "cli"] +readme = "README.md" +license = { file="LICENSE" } +requires-python = ">=3.8" +dynamic = ["version"] +dependencies = [ + "markdown", + "feedgenerator", + "jinja2", + "pygments", +] + +[project.scripts] +blag = "blag.blag:main" + +[project.urls] +'Documentation' = 'https://blag.readthedocs.io/' +'Source' = 'https://github.com/venthur/blag' +'Changelog' = 'https://github.com/venthur/blag/blob/master/CHANGELOG.md' + +[project.optional-dependencies] +dev = [ + "build", + "mkdocs", + "mkdocs-material", + "mkdocstrings[python]", + "twine", + "wheel", + "pytest", + "pytest-cov", + "ruff", + "mypy", + "types-markdown", +] + +[tool.setuptools.dynamic] +version = {attr = "blag.__VERSION__" } + +[tool.setuptools] +packages = [ + "blag", + "blag.templates", + "blag.static", + "blag.content", + "tests", +] + +[tool.pytest.ini_options] +addopts = """ + --cov=blag + --cov=tests + --cov-report=html + --cov-report=term-missing:skip-covered +""" + +[tool.ruff] +select = [ + "F", # pyflakes + "E", "W", # pycodestyle + "C90", # mccabe + "I", # isort + "D", # pydocstyle + "UP" # pyupgrade +] +line-length = 79 +target-version = "py38" + +[tool.ruff.pydocstyle] +convention = "numpy" + +[tool.mypy] +files = "blag,tests" +strict = true + +[[tool.mypy.overrides]] +module = "feedgenerator.*" +ignore_missing_imports = true diff --git a/requirements-dev.txt b/requirements-dev.txt index 1292db9..a2c255c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,11 @@ -sphinx==5.1.1 -twine==4.0.1 -wheel==0.37.1 -pytest==7.1.2 -pytest-cov==3.0.0 -flake8==5.0.2 +build==1.0.3 +mkdocs==1.5.3 +mkdocs-material==9.4.8 +mkdocstrings[python]==0.23.0 +twine==4.0.2 +wheel==0.41.3 +pytest==7.4.3 +pytest-cov==4.1.0 +ruff==0.1.5 +mypy==1.6.1 +types-markdown==3.5.0.1 diff --git a/requirements.txt b/requirements.txt index c964cc4..62bba80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -markdown==3.4.1 -feedgenerator==2.0.0 +markdown==3.5.1 +feedgenerator==2.1.0 jinja2==3.1.2 -pygments==2.12.0 +pygments==2.16.1 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 681fad8..0000000 --- a/setup.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[tool:pytest] -addopts = - --cov=blag - --cov=tests - --cov-report=html - --cov-report=term-missing:skip-covered - -[flake8] -exclude = venv,build,docs diff --git a/setup.py b/setup.py deleted file mode 100644 index 2f275fa..0000000 --- a/setup.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup - -meta = {} -exec(open('./blag/version.py').read(), meta) -meta['long_description'] = open('./README.md').read() - -setup( - name='blag', - version=meta['__VERSION__'], - description='blog-aware, static site generator', - long_description=meta['long_description'], - long_description_content_type='text/markdown', - keywords='markdown blag blog static site generator cli', - author='Bastian Venthur', - author_email='mail@venthur.de', - url='https://github.com/venthur/blag', - project_urls={ - 'Documentation': 'https://blag.readthedocs.io/', - 'Source': 'https://github.com/venthur/blag', - 'Changelog': - 'https://github.com/venthur/blag/blob/master/CHANGELOG.md', - }, - python_requires='>=3.8', - package_data={ - 'blag': ['templates/*'], - }, - install_requires=[ - 'markdown', - 'feedgenerator', - 'jinja2', - 'pygments', - ], - packages=['blag'], - entry_points={ - 'console_scripts': [ - 'blag = blag.blag:main' - ] - }, - license='MIT', -) diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..808384b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for blag.""" diff --git a/tests/conftest.py b/tests/conftest.py index 221078c..9ee4239 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,53 +1,72 @@ -from tempfile import TemporaryDirectory +"""Pytest fixtures.""" + + +# remove when we don't support py38 anymore +from __future__ import annotations + import os +from argparse import Namespace +from tempfile import TemporaryDirectory +from typing import Callable, Iterator import pytest +from jinja2 import Environment, Template -from blag import blag +from blag import blag, quickstart @pytest.fixture -def environment(): +def environment(cleandir: str) -> Iterator[Environment]: + """Create a Jinja2 environment.""" site = { - 'base_url': 'site base_url', - 'title': 'site title', - 'description': 'site description', - 'author': 'site author', + "base_url": "site base_url", + "title": "site title", + "description": "site description", + "author": "site author", } - env = blag.environment_factory(globals_=dict(site=site)) + env = blag.environment_factory("templates", globals_=dict(site=site)) yield env @pytest.fixture -def page_template(environment): - yield environment.get_template('page.html') +def page_template(environment: Environment) -> Iterator[Template]: + """Create a Jinja2 page-template.""" + yield environment.get_template("page.html") @pytest.fixture -def article_template(environment): - yield environment.get_template('article.html') +def article_template(environment: Environment) -> Iterator[Template]: + """Create a Jinja2 article-template.""" + yield environment.get_template("article.html") @pytest.fixture -def archive_template(environment): - yield environment.get_template('archive.html') +def index_template(environment: Environment) -> Iterator[Template]: + """Create a Jinja2 index-template.""" + yield environment.get_template("index.html") @pytest.fixture -def tags_template(environment): - yield environment.get_template('tags.html') +def archive_template(environment: Environment) -> Iterator[Template]: + """Create a Jinja2 archive-template.""" + yield environment.get_template("archive.html") @pytest.fixture -def tag_template(environment): - yield environment.get_template('tag.html') +def tags_template(environment: Environment) -> Iterator[Template]: + """Create a Jinja2 tags-template.""" + yield environment.get_template("tags.html") @pytest.fixture -def cleandir(): - """Create a temporary workind directory and cwd. +def tag_template(environment: Environment) -> Iterator[Template]: + """Create a Jinja2 tag-template.""" + yield environment.get_template("tag.html") - """ + +@pytest.fixture +def cleandir() -> Iterator[str]: + """Create a temporary working directory and cwd.""" config = """ [main] base_url = https://example.com/ @@ -57,30 +76,25 @@ author = a. u. thor """ with TemporaryDirectory() as dir: - for d in 'content', 'build', 'static', 'templates': - os.mkdir(f'{dir}/{d}') - with open(f'{dir}/config.ini', 'w') as fh: + os.mkdir(f"{dir}/build") + with open(f"{dir}/config.ini", "w") as fh: fh.write(config) # change directory old_cwd = os.getcwd() os.chdir(dir) + quickstart.copy_default_theme() yield dir # and change back afterwards os.chdir(old_cwd) @pytest.fixture -def args(cleandir): - - class NameSpace: - def __init__(self, **kwargs): - for name in kwargs: - setattr(self, name, kwargs[name]) - - args = NameSpace( - input_dir='content', - output_dir='build', - static_dir='static', - template_dir='templates', +def args(cleandir: Callable[[], Iterator[str]]) -> Iterator[Namespace]: + """Create a Namespace with default arguments.""" + args = Namespace( + input_dir="content", + output_dir="build", + static_dir="static", + template_dir="templates", ) yield args diff --git a/tests/test_blag.py b/tests/test_blag.py index eeafbae..155e483 100644 --- a/tests/test_blag.py +++ b/tests/test_blag.py @@ -1,122 +1,144 @@ -from tempfile import TemporaryDirectory +"""Test blag.""" + + +# remove when we don't support py38 anymore +from __future__ import annotations + import os +from argparse import Namespace from datetime import datetime +from tempfile import TemporaryDirectory +from typing import Any import pytest +from jinja2 import Template +from pytest import CaptureFixture, LogCaptureFixture -from blag import blag +from blag import __VERSION__, blag -def test_generate_feed(cleandir): - articles = [] - blag.generate_feed(articles, 'build', ' ', ' ', ' ', ' ') - assert os.path.exists('build/atom.xml') +def test_generate_feed(cleandir: str) -> None: + """Test generate_feed.""" + articles: list[tuple[str, dict[str, Any]]] = [] + blag.generate_feed(articles, "build", " ", " ", " ", " ") + assert os.path.exists("build/atom.xml") -def test_feed(cleandir): - articles = [ - [ - 'dest1.html', +def test_feed(cleandir: str) -> None: + """Test feed.""" + articles: list[tuple[str, dict[str, Any]]] = [ + ( + "dest1.html", { - 'title': 'title1', - 'date': datetime(2019, 6, 6), - 'content': 'content1', - } - ], - [ - 'dest2.html', + "title": "title1", + "date": datetime(2019, 6, 6), + "content": "content1", + }, + ), + ( + "dest2.html", { - 'title': 'title2', - 'date': datetime(1980, 5, 9), - 'content': 'content2', - } - ], - + "title": "title2", + "date": datetime(1980, 5, 9), + "content": "content2", + }, + ), ] - blag.generate_feed(articles, 'build', 'https://example.com/', - 'blog title', 'blog description', 'blog author') - with open('build/atom.xml') as fh: + blag.generate_feed( + articles, + "build", + "https://example.com/", + "blog title", + "blog description", + "blog author", + ) + with open("build/atom.xml") as fh: feed = fh.read() - assert 'blog title' in feed + assert "blog title" in feed # enable when https://github.com/getpelican/feedgenerator/issues/22 # is fixed # assert 'blog description' in feed - assert 'blog author' in feed + assert "blog author" in feed # article 1 - assert 'title1' in feed + assert "title1" in feed assert 'title1' in feed - assert '2019-06-06' in feed + assert "2019-06-06" in feed assert 'content1' in feed assert 'title2' in feed + assert "title2" in feed assert 'title2' in feed - assert '1980-05-09' in feed + assert "1980-05-09" in feed assert 'content2' in feed assert ' None: + """Test generate_feed with description.""" # if a description is provided, it will be used as the summary in # the feed, otherwise we simply use the title of the article - articles = [[ - 'dest.html', - { - 'title': 'title', - 'description': 'description', - 'date': datetime(2019, 6, 6), - 'content': 'content', - } - ]] - blag.generate_feed(articles, 'build', ' ', ' ', ' ', ' ') + articles: list[tuple[str, dict[str, Any]]] = [ + ( + "dest.html", + { + "title": "title", + "description": "description", + "date": datetime(2019, 6, 6), + "content": "content", + }, + ) + ] + blag.generate_feed(articles, "build", " ", " ", " ", " ") - with open('build/atom.xml') as fh: + with open("build/atom.xml") as fh: feed = fh.read() - assert 'title' in feed + assert "title" in feed assert 'description' in feed - assert '2019-06-06' in feed + assert "2019-06-06" in feed assert 'content' in feed -def test_parse_args_build(): +def test_parse_args_build() -> None: + """Test parse_args with build.""" # test default args - args = blag.parse_args(['build']) - assert args.input_dir == 'content' - assert args.output_dir == 'build' - assert args.template_dir == 'templates' - assert args.static_dir == 'static' + args = blag.parse_args(["build"]) + assert args.input_dir == "content" + assert args.output_dir == "build" + assert args.template_dir == "templates" + assert args.static_dir == "static" # input dir - args = blag.parse_args(['build', '-i', 'foo']) - assert args.input_dir == 'foo' - args = blag.parse_args(['build', '--input-dir', 'foo']) - assert args.input_dir == 'foo' + args = blag.parse_args(["build", "-i", "foo"]) + assert args.input_dir == "foo" + args = blag.parse_args(["build", "--input-dir", "foo"]) + assert args.input_dir == "foo" # output dir - args = blag.parse_args(['build', '-o', 'foo']) - assert args.output_dir == 'foo' - args = blag.parse_args(['build', '--output-dir', 'foo']) - assert args.output_dir == 'foo' + args = blag.parse_args(["build", "-o", "foo"]) + assert args.output_dir == "foo" + args = blag.parse_args(["build", "--output-dir", "foo"]) + assert args.output_dir == "foo" # template dir - args = blag.parse_args(['build', '-t', 'foo']) - assert args.template_dir == 'foo' - args = blag.parse_args(['build', '--template-dir', 'foo']) - assert args.template_dir == 'foo' + args = blag.parse_args(["build", "-t", "foo"]) + assert args.template_dir == "foo" + args = blag.parse_args(["build", "--template-dir", "foo"]) + assert args.template_dir == "foo" # static dir - args = blag.parse_args(['build', '-s', 'foo']) - assert args.static_dir == 'foo' - args = blag.parse_args(['build', '--static-dir', 'foo']) - assert args.static_dir == 'foo' + args = blag.parse_args(["build", "-s", "foo"]) + assert args.static_dir == "foo" + args = blag.parse_args(["build", "--static-dir", "foo"]) + assert args.static_dir == "foo" -def test_get_config(): +def test_get_config() -> None: + """Test get_config.""" config = """ [main] base_url = https://example.com/ @@ -126,25 +148,24 @@ author = a. u. thor """ # happy path with TemporaryDirectory() as dir: - configfile = f'{dir}/config.ini' - with open(configfile, 'w') as fh: + configfile = f"{dir}/config.ini" + with open(configfile, "w") as fh: fh.write(config) config_parsed = blag.get_config(configfile) - assert config_parsed['base_url'] == 'https://example.com/' - assert config_parsed['title'] == 'title' - assert config_parsed['description'] == 'description' - assert config_parsed['author'] == 'a. u. thor' + assert config_parsed["base_url"] == "https://example.com/" + assert config_parsed["title"] == "title" + assert config_parsed["description"] == "description" + assert config_parsed["author"] == "a. u. thor" # a missing required config causes a sys.exit - for x in 'base_url', 'title', 'description', 'author': - config2 = '\n'.join([line - for line - in config.splitlines() - if not line.startswith(x)]) + for x in "base_url", "title", "description", "author": + config2 = "\n".join( + [line for line in config.splitlines() if not line.startswith(x)] + ) with TemporaryDirectory() as dir: - configfile = f'{dir}/config.ini' - with open(configfile, 'w') as fh: + configfile = f"{dir}/config.ini" + with open(configfile, "w") as fh: fh.write(config2) with pytest.raises(SystemExit): config_parsed = blag.get_config(configfile) @@ -158,25 +179,28 @@ description = description author = a. u. thor """ with TemporaryDirectory() as dir: - configfile = f'{dir}/config.ini' - with open(configfile, 'w') as fh: + configfile = f"{dir}/config.ini" + with open(configfile, "w") as fh: fh.write(config) config_parsed = blag.get_config(configfile) - assert config_parsed['base_url'] == 'https://example.com/' + assert config_parsed["base_url"] == "https://example.com/" -def test_environment_factory(): - globals_ = { - 'foo': 'bar', - 'test': 'me' - } - env = blag.environment_factory(globals_=globals_) - assert env.globals['foo'] == 'bar' - assert env.globals['test'] == 'me' +def test_environment_factory(cleandir: str) -> None: + """Test environment_factory.""" + globals_: dict[str, object] = {"foo": "bar", "test": "me"} + env = blag.environment_factory("templates", globals_=globals_) + assert env.globals["foo"] == "bar" + assert env.globals["test"] == "me" -def test_process_markdown(cleandir, page_template, article_template): +def test_process_markdown( + cleandir: str, + page_template: Template, + article_template: Template, +) -> None: + """Test process_markdown.""" page1 = """\ title: some page @@ -202,17 +226,12 @@ foo bar convertibles = [] for i, txt in enumerate((page1, article1, article2)): - i = str(i) - with open(f'content/{i}', 'w') as fh: + with open(f"content/{str(i)}", "w") as fh: fh.write(txt) - convertibles.append([i, i]) + convertibles.append((str(i), str(i))) articles, pages = blag.process_markdown( - convertibles, - 'content', - 'build', - page_template, - article_template + convertibles, "content", "build", page_template, article_template ) assert isinstance(articles, list) @@ -220,17 +239,18 @@ foo bar for dst, context in articles: assert isinstance(dst, str) assert isinstance(context, dict) - assert 'content' in context + assert "content" in context assert isinstance(pages, list) assert len(pages) == 1 for dst, context in pages: assert isinstance(dst, str) assert isinstance(context, dict) - assert 'content' in context + assert "content" in context -def test_build(args): +def test_build(args: Namespace) -> None: + """Test build.""" page1 = """\ title: some page @@ -259,55 +279,77 @@ foo bar # write some convertibles convertibles = [] for i, txt in enumerate((page1, article1, article2)): - i = str(i) - with open(f'{args.input_dir}/{i}.md', 'w') as fh: + with open(f"{args.input_dir}/{str(i)}.md", "w") as fh: fh.write(txt) - convertibles.append([i, i]) + convertibles.append((str(i), str(i))) # some static files - with open(f'{args.static_dir}/test', 'w') as fh: - fh.write('hello') + with open(f"{args.static_dir}/test", "w") as fh: + fh.write("hello") - os.mkdir(f'{args.input_dir}/testdir') - with open(f'{args.input_dir}/testdir/test', 'w') as fh: - fh.write('hello') + os.mkdir(f"{args.input_dir}/testdir") + with open(f"{args.input_dir}/testdir/test", "w") as fh: + fh.write("hello") blag.build(args) # test existence of the three converted files for i in range(3): - assert os.path.exists(f'{args.output_dir}/{i}.html') + assert os.path.exists(f"{args.output_dir}/{i}.html") # ... static file - assert os.path.exists(f'{args.output_dir}/test') + assert os.path.exists(f"{args.output_dir}/test") # ... directory - assert os.path.exists(f'{args.output_dir}/testdir/test') + assert os.path.exists(f"{args.output_dir}/testdir/test") # ... feed - assert os.path.exists(f'{args.output_dir}/atom.xml') + assert os.path.exists(f"{args.output_dir}/atom.xml") + # ... index + assert os.path.exists(f"{args.output_dir}/index.html") # ... archive - assert os.path.exists(f'{args.output_dir}/index.html') + assert os.path.exists(f"{args.output_dir}/archive.html") # ... tags - assert os.path.exists(f'{args.output_dir}/tags/index.html') - assert os.path.exists(f'{args.output_dir}/tags/foo.html') - assert os.path.exists(f'{args.output_dir}/tags/bar.html') + assert os.path.exists(f"{args.output_dir}/tags/index.html") + assert os.path.exists(f"{args.output_dir}/tags/foo.html") + assert os.path.exists(f"{args.output_dir}/tags/bar.html") -def test_main(cleandir): - blag.main(['build']) +@pytest.mark.parametrize( + "template", + [ + "page.html", + "article.html", + "index.html", + "archive.html", + "tags.html", + "tag.html", + ], +) +def test_missing_template_raises(template: str, args: Namespace) -> None: + """Test that missing templates raise SystemExit.""" + os.remove(f"templates/{template}") + with pytest.raises(SystemExit): + blag.build(args) -def test_cli_version(capsys): +def test_main(cleandir: str) -> None: + """Test main.""" + blag.main(["build"]) + + +def test_cli_version(capsys: CaptureFixture[str]) -> None: + """Test --version.""" with pytest.raises(SystemExit) as ex: - blag.main(['--version']) + blag.main(["--version"]) # normal system exit assert ex.value.code == 0 # proper version reported out, _ = capsys.readouterr() - assert blag.__VERSION__ in out + assert __VERSION__ in out -def test_cli_verbose(cleandir, caplog): - blag.main(['build']) - assert 'DEBUG' not in caplog.text +def test_cli_verbose(cleandir: str, caplog: LogCaptureFixture) -> None: + """Test --verbose.""" + blag.main(["build"]) + assert "DEBUG" not in caplog.text - blag.main(['--verbose', 'build']) - assert 'DEBUG' in caplog.text + blag.main(["--verbose", "build"]) + assert "DEBUG" in caplog.text diff --git a/tests/test_devserver.py b/tests/test_devserver.py index 7f56a40..3652f19 100644 --- a/tests/test_devserver.py +++ b/tests/test_devserver.py @@ -1,66 +1,82 @@ -import time +"""Tests for the devserver module.""" + + +# remove when we don't support py38 anymore +from __future__ import annotations + import threading +import time +from argparse import Namespace import pytest from blag import devserver -def test_get_last_modified(cleandir): +def test_get_last_modified(cleandir: str) -> None: + """Test get_last_modified.""" # take initial time - t1 = devserver.get_last_modified(['content']) + t1 = devserver.get_last_modified(["content"]) # wait a bit, create a file and measure again time.sleep(0.1) - with open('content/test', 'w') as fh: - fh.write('boo') - t2 = devserver.get_last_modified(['content']) + with open("content/test", "w") as fh: + fh.write("boo") + t2 = devserver.get_last_modified(["content"]) # wait a bit and take time again time.sleep(0.1) - t3 = devserver.get_last_modified(['content']) + t3 = devserver.get_last_modified(["content"]) assert t2 > t1 assert t2 == t3 -def test_autoreload_builds_immediately(args): +def test_autoreload_builds_immediately(args: Namespace) -> None: + """Test autoreload builds immediately.""" # create a dummy file that can be build - with open('content/test.md', 'w') as fh: - fh.write('boo') + with open("content/test.md", "w") as fh: + fh.write("boo") - t = threading.Thread(target=devserver.autoreload, - args=(args, ), - daemon=True,) - t0 = devserver.get_last_modified(['build']) + t = threading.Thread( + target=devserver.autoreload, + args=(args,), + daemon=True, + ) + t0 = devserver.get_last_modified(["build"]) t.start() # try for 5 seconds... for i in range(5): time.sleep(1) - t1 = devserver.get_last_modified(['build']) + t1 = devserver.get_last_modified(["build"]) print(t1) if t1 > t0: break assert t1 > t0 -@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning") # noqa -def test_autoreload(args): - t = threading.Thread(target=devserver.autoreload, - args=(args, ), - daemon=True,) +@pytest.mark.filterwarnings( + "ignore::pytest.PytestUnhandledThreadExceptionWarning" +) +def test_autoreload(args: Namespace) -> None: + """Test autoreload.""" + t = threading.Thread( + target=devserver.autoreload, + args=(args,), + daemon=True, + ) t.start() - t0 = devserver.get_last_modified(['build']) + t0 = devserver.get_last_modified(["build"]) # create a dummy file that can be build - with open('content/test.md', 'w') as fh: - fh.write('boo') + with open("content/test.md", "w") as fh: + fh.write("boo") # try for 5 seconds to see if we rebuild once... for i in range(5): time.sleep(1) - t1 = devserver.get_last_modified(['build']) + t1 = devserver.get_last_modified(["build"]) if t1 > t0: break assert t1 > t0 diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 9103ec1..56cb742 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -1,71 +1,94 @@ -from datetime import datetime +"""Test markdown module.""" + + +# remove when we don't support py38 anymore +from __future__ import annotations + +from datetime import datetime +from typing import Any -import pytest import markdown +import pytest from blag.markdown import convert_markdown, markdown_factory -@pytest.mark.parametrize("input_, expected", [ - # inline - ('[test](test.md)', 'test.html'), - ('[test](test.md "test")', 'test.html'), - ('[test](a/test.md)', 'a/test.html'), - ('[test](a/test.md "test")', 'a/test.html'), - ('[test](/test.md)', '/test.html'), - ('[test](/test.md "test")', '/test.html'), - ('[test](/a/test.md)', '/a/test.html'), - ('[test](/a/test.md "test")', '/a/test.html'), - # reference - ('[test][]\n[test]: test.md ''', 'test.html'), - ('[test][]\n[test]: test.md "test"', 'test.html'), - ('[test][]\n[test]: a/test.md', 'a/test.html'), - ('[test][]\n[test]: a/test.md "test"', 'a/test.html'), - ('[test][]\n[test]: /test.md', '/test.html'), - ('[test][]\n[test]: /test.md "test"', '/test.html'), - ('[test][]\n[test]: /a/test.md', '/a/test.html'), - ('[test][]\n[test]: /a/test.md "test"', '/a/test.html'), -]) -def test_convert_markdown_links(input_, expected): +@pytest.mark.parametrize( + "input_, expected", + [ + # inline + ("[test](test.md)", "test.html"), + ('[test](test.md "test")', "test.html"), + ("[test](a/test.md)", "a/test.html"), + ('[test](a/test.md "test")', "a/test.html"), + ("[test](/test.md)", "/test.html"), + ('[test](/test.md "test")', "/test.html"), + ("[test](/a/test.md)", "/a/test.html"), + ('[test](/a/test.md "test")', "/a/test.html"), + # reference + ("[test][]\n[test]: test.md " "", "test.html"), + ('[test][]\n[test]: test.md "test"', "test.html"), + ("[test][]\n[test]: a/test.md", "a/test.html"), + ('[test][]\n[test]: a/test.md "test"', "a/test.html"), + ("[test][]\n[test]: /test.md", "/test.html"), + ('[test][]\n[test]: /test.md "test"', "/test.html"), + ("[test][]\n[test]: /a/test.md", "/a/test.html"), + ('[test][]\n[test]: /a/test.md "test"', "/a/test.html"), + ], +) +def test_convert_markdown_links(input_: str, expected: str) -> None: + """Test convert_markdown.""" md = markdown_factory() html, _ = convert_markdown(md, input_) assert expected in html -@pytest.mark.parametrize("input_, expected", [ - # scheme - ('[test](https://)', 'https://'), - # netloc - ('[test](//test.md)', '//test.md'), - # no path - ('[test]()', ''), -]) -def test_dont_convert_normal_links(input_, expected): +@pytest.mark.parametrize( + "input_, expected", + [ + # scheme + ("[test](https://)", "https://"), + # netloc + ("[test](//test.md)", "//test.md"), + # no path + ("[test]()", ""), + ], +) +def test_dont_convert_normal_links(input_: str, expected: str) -> None: + """Test convert_markdown doesn't convert normal links.""" md = markdown_factory() html, _ = convert_markdown(md, input_) assert expected in html -@pytest.mark.parametrize("input_, expected", [ - ('foo: bar', {'foo': 'bar'}), - ('foo: those are several words', {'foo': 'those are several words'}), - ('tags: this, is, a, test\n', {'tags': ['this', 'is', 'a', 'test']}), - ('tags: this, IS, a, test', {'tags': ['this', 'is', 'a', 'test']}), - ('date: 2020-01-01 12:10', {'date': - datetime(2020, 1, 1, 12, 10).astimezone()}), -]) -def test_convert_metadata(input_, expected): +@pytest.mark.parametrize( + "input_, expected", + [ + ("foo: bar", {"foo": "bar"}), + ("foo: those are several words", {"foo": "those are several words"}), + ("tags: this, is, a, test\n", {"tags": ["this", "is", "a", "test"]}), + ("tags: this, IS, a, test", {"tags": ["this", "is", "a", "test"]}), + ( + "date: 2020-01-01 12:10", + {"date": datetime(2020, 1, 1, 12, 10).astimezone()}, + ), + ], +) +def test_convert_metadata(input_: str, expected: dict[str, Any]) -> None: + """Test convert_markdown converts metadata correctly.""" md = markdown_factory() _, meta = convert_markdown(md, input_) assert expected == meta -def test_markdown_factory(): +def test_markdown_factory() -> None: + """Test markdown_factory.""" md = markdown_factory() assert isinstance(md, markdown.Markdown) -def test_smarty(): +def test_smarty() -> None: + """Test smarty.""" md = markdown_factory() md1 = """ @@ -74,12 +97,13 @@ this --- is -- a test ... """ html, meta = convert_markdown(md, md1) - assert 'mdash' in html - assert 'ndash' in html - assert 'hellip' in html + assert "mdash" in html + assert "ndash" in html + assert "hellip" in html -def test_smarty_code(): +def test_smarty_code() -> None: + """Test smarty doesn't touch code.""" md = markdown_factory() md1 = """ @@ -88,6 +112,6 @@ this --- is -- a test ... ``` """ html, meta = convert_markdown(md, md1) - assert 'mdash' not in html - assert 'ndash' not in html - assert 'hellip' not in html + assert "mdash" not in html + assert "ndash" not in html + assert "hellip" not in html diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 4594497..4467fc9 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -1,24 +1,51 @@ +"""Tests for the quickstart module.""" + + +# remove when we don't support py38 anymore +from __future__ import annotations + +import os + +from pytest import MonkeyPatch + from blag.quickstart import get_input, quickstart -def test_get_input_default_answer(monkeypatch): - monkeypatch.setattr('builtins.input', lambda x: '') +def test_get_input_default_answer(monkeypatch: MonkeyPatch) -> None: + """Test get_input with default answer.""" + monkeypatch.setattr("builtins.input", lambda x: "") answer = get_input("foo", "bar") - assert answer == 'bar' + assert answer == "bar" -def test_get_input(monkeypatch): - monkeypatch.setattr('builtins.input', lambda x: 'baz') +def test_get_input(monkeypatch: MonkeyPatch) -> None: + """Test get_input.""" + monkeypatch.setattr("builtins.input", lambda x: "baz") answer = get_input("foo", "bar") - assert answer == 'baz' + assert answer == "baz" -def test_quickstart(cleandir, monkeypatch): - monkeypatch.setattr('builtins.input', lambda x: 'foo') +def test_quickstart(cleandir: str, monkeypatch: MonkeyPatch) -> None: + """Test quickstart.""" + monkeypatch.setattr("builtins.input", lambda x: "foo") quickstart(None) - with open('config.ini', 'r') as fh: + with open("config.ini") as fh: data = fh.read() - assert 'base_url = foo' in data - assert 'title = foo' in data - assert 'description = foo' in data - assert 'author = foo' in data + assert "base_url = foo" in data + assert "title = foo" in data + assert "description = foo" in data + assert "author = foo" in data + + for template in ( + "archive.html", + "article.html", + "base.html", + "index.html", + "page.html", + "tag.html", + "tags.html", + ): + assert os.path.exists(f"templates/{template}") + + for directory in "build", "content", "static": + assert os.path.exists(directory) diff --git a/tests/test_templates.py b/tests/test_templates.py index e420891..4d96f66 100644 --- a/tests/test_templates.py +++ b/tests/test_templates.py @@ -1,73 +1,107 @@ +"""Test the templates.""" + + +# remove when we don't support py38 anymore +from __future__ import annotations + import datetime +from jinja2 import Template -def test_page(page_template): + +def test_page(page_template: Template) -> None: + """Test the page template.""" ctx = { - 'content': 'this is the content', - 'title': 'this is the title', + "content": "this is the content", + "title": "this is the title", } result = page_template.render(ctx) - assert 'this is the content' in result - assert 'this is the title' in result + assert "this is the content" in result + assert "this is the title" in result -def test_article(article_template): +def test_article(article_template: Template) -> None: + """Test the article template.""" ctx = { - 'content': 'this is the content', - 'title': 'this is the title', - 'date': datetime.datetime(1980, 5, 9), + "content": "this is the content", + "title": "this is the title", + "date": datetime.datetime(1980, 5, 9), } result = article_template.render(ctx) - assert 'this is the content' in result - assert 'this is the title' in result - assert '1980-05-09' in result + assert "this is the content" in result + assert "this is the title" in result + assert "1980-05-09" in result -def test_archive(archive_template): +def test_index(index_template: Template) -> None: + """Test the index template.""" entry = { - 'title': 'this is a title', - 'dst': 'https://example.com/link', - 'date': datetime.datetime(1980, 5, 9), + "title": "this is a title", + "dst": "https://example.com/link", + "date": datetime.datetime(1980, 5, 9), } archive = [entry] ctx = { - 'archive': archive, + "archive": archive, + } + result = index_template.render(ctx) + assert "site title" in result + + assert "this is a title" in result + assert "1980-05-09" in result + assert "https://example.com/link" in result + + assert "/archive.html" in result + + +def test_archive(archive_template: Template) -> None: + """Test the archive template.""" + entry = { + "title": "this is a title", + "dst": "https://example.com/link", + "date": datetime.datetime(1980, 5, 9), + } + archive = [entry] + ctx = { + "archive": archive, } result = archive_template.render(ctx) - assert 'site title' in result + assert "Archive" in result - assert 'this is a title' in result - assert '1980-05-09' in result - assert 'https://example.com/link' in result + assert "this is a title" in result + assert "1980-05-09" in result + assert "https://example.com/link" in result -def test_tags(tags_template): - tags = [('foo', 42)] +def test_tags(tags_template: Template) -> None: + """Test the tags template.""" + tags = [("foo", 42)] ctx = { - 'tags': tags, + "tags": tags, } result = tags_template.render(ctx) - assert 'Tags' in result + assert "Tags" in result - assert 'foo.html' in result - assert 'foo' in result - assert '42' in result + assert "foo.html" in result + assert "foo" in result + assert "42" in result -def test_tag(tag_template): +def test_tag(tag_template: Template) -> None: + """Test the tag template.""" entry = { - 'title': 'this is a title', - 'dst': 'https://example.com/link', - 'date': datetime.datetime(1980, 5, 9), + "title": "this is a title", + "dst": "https://example.com/link", + "date": datetime.datetime(1980, 5, 9), } archive = [entry] ctx = { - 'tag': 'foo', - 'archive': archive, + "tag": "foo", + "archive": archive, } result = tag_template.render(ctx) - assert 'Tag foo' in result + assert "foo" in result - assert 'this is a title' in result - assert '1980-05-09' in result - assert 'https://example.com/link' in result + assert "this is a title" in result + assert "1980-05-09" in result + assert "https://example.com/link" in result diff --git a/tests/test_version.py b/tests/test_version.py index 04f8d9a..fbe1d02 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -1,5 +1,12 @@ +"""Test the version module.""" + + +# remove when we don't support py38 anymore +from __future__ import annotations + import blag -def test_version(): +def test_version() -> None: + """Test the version of the package.""" assert isinstance(blag.__VERSION__, str)