diff --git a/htmd/cli.py b/htmd/cli.py index 03e490f..23bd906 100644 --- a/htmd/cli.py +++ b/htmd/cli.py @@ -198,12 +198,19 @@ def build( default=True, help='If JavaScript should be minified', ) +@click.option( + '--drafts', + default=False, + help='Show draft posts in the preview.', + is_flag=True, +) def preview( _ctx: click.Context, host: str, port: int, css_minify: bool, # noqa: FBT001 js_minify: bool, # noqa: FBT001 + drafts: bool, # noqa: FBT001 ) -> None: from . import site # reload for tests to refresh app.static_folder @@ -219,6 +226,9 @@ def preview( assert app.static_folder is not None combine_and_minify_js(Path(app.static_folder)) + if drafts: + site.preview_drafts() + # reload when static files change # werkzeug will re-run the terminal command # Which causes the above combine_and_minify_*() to run diff --git a/htmd/site.py b/htmd/site.py index 96bdea0..41e99f5 100644 --- a/htmd/site.py +++ b/htmd/site.py @@ -10,7 +10,7 @@ from feedwerk.atom import AtomFeed from flask import abort, Blueprint, Flask, render_template, Response, url_for from flask.typing import ResponseReturnValue -from flask_flatpages import FlatPages, pygments_style_defs +from flask_flatpages import FlatPages, Page, pygments_style_defs from flask_frozen import Freezer from htmlmin import minify from jinja2 import ChoiceLoader, FileSystemLoader @@ -101,6 +101,13 @@ def get_project_dir() -> Path: freezer = Freezer(app) +SHOW_DRAFTS = False +def preview_drafts() -> None: + global published_posts, SHOW_DRAFTS # noqa: PLW0603 + SHOW_DRAFTS = True + published_posts = [p for p in posts if 'published' in p.meta] + + # Allow config settings (even new user created ones) to be used in templates for key in app.config: app.jinja_env.globals[key] = app.config[key] @@ -222,12 +229,19 @@ def all_posts() -> ResponseReturnValue: return render_template('all_posts.html', active='posts', posts=latest) +def draft_and_not_shown(post: Page) -> bool: + is_draft = 'draft' in post.meta + return is_draft and not SHOW_DRAFTS and 'build' not in str(post.meta['draft']) + + # If month and day are ints then Flask removes leading zeros @app.route('/////') def post(year: str, month: str, day: str, path: str) -> ResponseReturnValue: if len(year) != 4 or len(month) != 2 or len(day) != 2: # noqa: PLR2004 abort(404) post = posts.get_or_404(path) + if draft_and_not_shown(post): + abort(404) date_str = f'{year}-{month}-{day}' if post.meta.get('published').strftime('%Y-%m-%d') != date_str: abort(404) @@ -253,11 +267,25 @@ def all_tags() -> ResponseReturnValue: return render_template('all_tags.html', active='tags', tags=tag_counts) +def no_posts_shown(post_list: list[Page]) -> bool: + return all( + 'draft' in p.meta and 'build' not in str(p.meta['draft']) + for p in post_list + ) + @app.route('/tags//') def tag(tag: str) -> ResponseReturnValue: - tagged = [p for p in published_posts if tag in p.meta.get('tags', [])] + tagged = [p for p in posts if tag in p.meta.get('tags', [])] + if not tagged: + abort(404) + if not SHOW_DRAFTS and no_posts_shown(tagged): + abort(404) + if SHOW_DRAFTS: + tagged_published = tagged + else: + tagged_published = [p for p in tagged if 'draft' not in p.meta] sorted_posts = sorted( - tagged, + tagged_published, reverse=True, key=lambda p: p.meta.get('published'), ) @@ -266,10 +294,21 @@ def tag(tag: str) -> ResponseReturnValue: @app.route('/author//') def author(author: str) -> ResponseReturnValue: + # if the author has a draft build + # page is served without displaying posts + # so no 404 when for the link from the draft posts_author = [p for p in posts if author == p.meta.get('author', '')] + if not posts_author: abort(404) - posts_author_published = [p for p in posts_author if not p.meta.get('draft', False)] + + if not SHOW_DRAFTS and no_posts_shown(posts_author): + abort(404) + if SHOW_DRAFTS: + posts_author_published = posts_author + else: + posts_author_published = [p for p in posts_author if 'draft' not in p.meta] + posts_sorted = sorted( posts_author_published, reverse=True, @@ -309,7 +348,7 @@ def year_view(year: int) -> ResponseReturnValue: @app.route('///') def month_view(year: str, month: str) -> ResponseReturnValue: month_posts = [ - p for p in posts if year == p.meta.get('published').strftime('%Y') + p for p in published_posts if year == p.meta.get('published').strftime('%Y') and month == p.meta.get('published').strftime('%m') ] if not month_posts: @@ -331,7 +370,7 @@ def month_view(year: str, month: str) -> ResponseReturnValue: @app.route('////') def day_view(year: str, month: str, day: str) -> ResponseReturnValue: day_posts = [ - p for p in posts if year == p.meta.get('published').strftime('%Y') + p for p in published_posts if year == p.meta.get('published').strftime('%Y') and month == p.meta.get('published').strftime('%m') and day == p.meta.get('published').strftime('%d') ] diff --git a/pyproject.toml b/pyproject.toml index 1fc5906..ceea831 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ section-order = ["future", "standard-library", "third-party", "first-party", "lo [tool.ruff.lint.per-file-ignores] "htmd/utils.py" = ["I001"] -"tests/test_app.py" = ["ARG001"] +"tests/test_app.py" = ["I001", "ARG001"] "tests/test_build.py" = ["I001"] "tests/test_drafts.py" = ["ARG001", "I001"] "tests/test_post_dates.py" = ["I001"] diff --git a/tests/test_app.py b/tests/test_app.py index f2a631b..523f11c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,4 +1,5 @@ from collections.abc import Generator +import importlib from click.testing import CliRunner from flask import Flask @@ -6,6 +7,8 @@ from htmd.cli import start import pytest +from utils import set_example_to_draft + @pytest.fixture(scope='module') def run_start() -> Generator[CliRunner, None, None]: @@ -51,3 +54,27 @@ def test_draft_does_not_exist(client: FlaskClient) -> None: # before this change pages.page was serving templates response = client.get('/draft/dne/') assert response.status_code == 404 # noqa: PLR2004 + + +def test_tag_does_not_exist(client: FlaskClient) -> None: + found = 200 + not_found = 404 + # If the author doesn't exist it will be a 404 + response = client.get('/tags/dne/') + assert response.status_code == not_found + + set_example_to_draft() + from htmd import site + importlib.reload(site) + response = client.get('/tags/first/') + assert response.status_code == not_found + response = client.get('/author/Taylor/') + assert response.status_code == not_found + response = client.get('/2014/10/30/example/') + assert response.status_code == not_found + + site.preview_drafts() + response = client.get('/tags/first/') + assert response.status_code == found + response = client.get('/author/Taylor/') + assert response.status_code == found diff --git a/tests/test_drafts.py b/tests/test_drafts.py index 0301f59..6b198bf 100644 --- a/tests/test_drafts.py +++ b/tests/test_drafts.py @@ -6,19 +6,12 @@ from htmd.cli import build, start import pytest -from utils import remove_fields_from_post, SUCCESS_REGEX - - -def set_example_as_draft() -> None: - remove_fields_from_post('example', ('draft',)) - post_path = Path('posts') / 'example.md' - with post_path.open('r') as post_file: - lines = post_file.readlines() - with post_path.open('w') as post_file: - for line in lines: - if line == '...\n': - post_file.write('draft: true\n') - post_file.write(line) +from utils import ( + remove_fields_from_post, + set_example_to_draft, + set_example_to_draft_build, + SUCCESS_REGEX, +) def copy_example_as_draft_build() -> None: @@ -48,7 +41,7 @@ def build_draft() -> Generator[CliRunner, None, None]: runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke(start) - set_example_as_draft() + set_example_to_draft() copy_example_as_draft_build() result = runner.invoke(build) assert result.exit_code == 0 @@ -122,14 +115,11 @@ def test_no_drafts_for_day(build_draft: CliRunner) -> None: def test_draft_without_published(run_start: CliRunner) -> None: - set_example_as_draft() - copy_example_as_draft_build() - example_path = Path('posts') / 'example.md' - example_path.unlink() - remove_fields_from_post('copy', ('published', 'updated')) + set_example_to_draft_build() + remove_fields_from_post('example', ('published', 'updated')) result = run_start.invoke(build) assert result.exit_code == 0 assert re.search(SUCCESS_REGEX, result.output) - draft_uuid = get_draft_uuid('copy') + draft_uuid = get_draft_uuid('example') draft_path = Path('build') / 'draft' / draft_uuid / 'index.html' assert draft_path.is_file() is True diff --git a/tests/test_preview.py b/tests/test_preview.py index bd926fc..722ad36 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -9,6 +9,8 @@ import subprocess import sys +from utils import set_example_to_draft, set_example_to_draft_build + def invoke_preview(run_start: CliRunner, args: list[str]) -> None: """ @@ -149,8 +151,9 @@ def test_preview_reload_css(run_start: CliRunner) -> None: # noqa: ARG001 try: response = requests.get(url, timeout=0.1) except ( - requests.exceptions.ReadTimeout, + requests.exceptions.ChunkedEncodingError, requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, ): # happens during restart read_timeout = True @@ -186,8 +189,9 @@ def test_preview_reload_js(run_start: CliRunner) -> None: # noqa: ARG001 try: response = requests.get(url, timeout=0.1) except ( - requests.exceptions.ReadTimeout, + requests.exceptions.ChunkedEncodingError, requests.exceptions.ConnectionError, + requests.exceptions.ReadTimeout, ): # happens during restart read_timeout = True @@ -197,3 +201,67 @@ def test_preview_reload_js(run_start: CliRunner) -> None: # noqa: ARG001 assert read_timeout assert before != after assert expected in after + + +def test_preview_drafts(run_start: CliRunner) -> None: + args = ['--drafts'] + invoke_preview(run_start, args) + set_example_to_draft() + success = 200 + + urls = ( + (404, '/2014/'), + (404, '/2014/10/'), + (404, '/2014/10/30/'), + (404, '/2014/10/30/example/'), + (404, '/tags/first/'), + (404, '/author/Taylor/'), + ) + not_in = ( + '/', + '/all/', + ) + # drafts should not appear + with run_preview(): + for status, url in urls: + response = requests.get('http://localhost:9090' + url, timeout=1) + assert response.status_code == status + + for url in not_in: + response = requests.get('http://localhost:9090' + url, timeout=0.01) + assert response.status_code == success + assert 'Example Post' not in response.text + + # drafts should appear + with run_preview(args): + for _status, url in urls: + response = requests.get('http://localhost:9090' + url, timeout=1) + assert response.status_code == success + + for url in not_in: + response = requests.get('http://localhost:9090' + url, timeout=0.01) + assert response.status_code == success + assert 'Example Post' in response.text + + set_example_to_draft_build() + urls = ( + (404, '/2014/'), + (404, '/2014/10/'), + (404, '/2014/10/30/'), + (200, '/2014/10/30/example/'), + (200, '/tags/first/'), + (200, '/author/Taylor/'), + ) + not_in = ( + '/', + '/all/', + ) + with run_preview(): + for status, url in urls: + response = requests.get('http://localhost:9090' + url, timeout=1) + assert response.status_code == status + + for url in not_in: + response = requests.get('http://localhost:9090' + url, timeout=0.01) + assert response.status_code == success + assert 'Example Post' not in response.text diff --git a/tests/utils.py b/tests/utils.py index 958278c..c9aa1a8 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -18,3 +18,26 @@ def remove_fields_from_post(path: str, field_names: tuple[str, ...]) -> None: break else: post.write(line) + + +def set_example_draft_status(draft_status: str) -> None: + remove_fields_from_post('example', ('draft',)) + post_path = Path('posts') / 'example.md' + + with post_path.open('r') as post_file: + lines = post_file.readlines() + + with post_path.open('w') as post_file: + for line in lines: + if line == '...\n': + draft_line = f'draft: {draft_status}\n' + post_file.write(draft_line) + post_file.write(line) + + +def set_example_to_draft() -> None: + set_example_draft_status('true') + + +def set_example_to_draft_build() -> None: + set_example_draft_status('build') diff --git a/typehints/flask_flatpages/page.pyi b/typehints/flask_flatpages/page.pyi index 5899ef8..a4af46f 100644 --- a/typehints/flask_flatpages/page.pyi +++ b/typehints/flask_flatpages/page.pyi @@ -5,8 +5,8 @@ class Page: body: Incomplete html_renderer: Incomplete folder: Incomplete + meta: dict def __init__(self, path, meta, body, html_renderer, folder) -> None: ... def __getitem__(self, name): ... def __html__(self): ... def html(self): ... - def meta(self): ...