diff --git a/.github/workflows/deploy_website.yml b/.github/workflows/deploy_website.yml index 60124cce..8ea1adbf 100644 --- a/.github/workflows/deploy_website.yml +++ b/.github/workflows/deploy_website.yml @@ -61,11 +61,11 @@ jobs: task --output group \ --output-group-begin "::group::{{.TASK}}{{if .NAME}}:{{.NAME}}{{end}}" \ --output-group-end "::endgroup::" \ - build + build -- --host rileychase.net # Publish - name: Deploy Website - run: aws s3 sync ./output s3://${{ secrets.BUCKET_NAME }}/website + run: aws s3 sync ./output s3://${{ secrets.BUCKET_NAME }}/website --exclude "**/*.md" --delete - name: Invalidate Cache run: aws cloudfront create-invalidation --distribution-id ${{ secrets.DISTRIBUTION_ID }} --path "/*" diff --git a/Taskfile.yaml b/Taskfile.yaml index b3b17fce..68912866 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -40,8 +40,6 @@ tasks: mypy: desc: Run python/mypy Python type checking tool - sources: - - ./**/*.py cmds: - "{{.PY_PREFIX}} mypy {{.CLI_ARGS}} ./site_generator" @@ -67,9 +65,6 @@ tasks: validate: desc: Run the inbuilt site_generator validator - sources: - - ./*.md - - ./**/*.md cmds: - "{{.PY_PREFIX}} python -m site_generator validate {{.CLI_ARGS}}" @@ -80,9 +75,6 @@ tasks: aws:lint: desc: Run cfn-lint across all templates - sources: - - aws/*.yml - - aws/**/*.yml cmds: - "{{.PY_PREFIX}} pipenv run cfn-lint --include-checks I -- ./aws/**/*.yml" diff --git a/pages/index.md b/pages/index.md index 7b5791fb..095c941d 100644 --- a/pages/index.md +++ b/pages/index.md @@ -1,9 +1,11 @@ --- title: Hi, I'm Riley +description: Welcome to my little corner of the internet. Right now there's not much here, but that might change in the future. +og: + description: Welcome to my little corner of the internet. Right now there's not much here, but that might change in the future. meta: validation: - subtitle: False - description: False + subtitle: false --- Hi :wave: diff --git a/pages/license.md b/pages/license.md index 330efae6..6d6cd9f0 100644 --- a/pages/license.md +++ b/pages/license.md @@ -1,10 +1,9 @@ --- title: License +subtitle: I'm just a webpage, I can't tell you what to do. +description: Unless noted otherwise, all of the content on this site are licensed to the public under the MIT license. +date: 2022-09-16 path: /license -meta: - validation: - subtitle: false - description: false --- Unless noted otherwise, all of the content on this site are licensed to the public under the MIT license (see below). This includes any source code that comprises the website as well as any of the content on the website such as blog posts and code snippets. diff --git a/pages/privacy.md b/pages/privacy.md index b5a20fd1..64d6799d 100644 --- a/pages/privacy.md +++ b/pages/privacy.md @@ -1,8 +1,8 @@ --- title: Privacy subtitle: This website doesn't know what you do in the dark -date: 2022-09-16 description: Privacy policy information for this website rileychase.net. +date: 2022-09-16 path: /privacy --- diff --git a/site_generator/cli.py b/site_generator/cli.py index 7b42cab8..1cf43288 100644 --- a/site_generator/cli.py +++ b/site_generator/cli.py @@ -62,6 +62,20 @@ def _setup_parser(self) -> None: action="store_true", help="Enable verbose logging output.", ) + self.root_parser.add_argument( + "--site-name", + type=str, + default=None, + metavar="NAME", + help="The name for this site, used in Open Graph tags.", + ) + self.root_parser.add_argument( + "--locale", + type=str, + default=None, + metavar="LOCALE", + help="The locale of this website, used in Open Graph tags.", + ) command_parsers = self.root_parser.add_subparsers(dest="command") @@ -85,10 +99,17 @@ def _setup_parser(self) -> None: help="Port number to listen on when running in live mode.", ) - _ = command_parsers.add_parser( + build_parser = command_parsers.add_parser( "build", help="Build a fully rendered site and then exit.", ) + build_parser.add_argument( + "--host", + type=str, + default="localhost", + metavar="HOST", + help="Hostname the site will be hosted under.", + ) _ = command_parsers.add_parser( "validate", @@ -112,7 +133,7 @@ def run(self, argv: list[str]) -> None: for key, value in cfg.dict().items(): logger.debug(f"config.{key} = {value}") # noqa: G004 - cmd = self._get_command(args.command, cfg) + cmd = self._get_command(cfg) try: asyncio.run(cmd()) except KeyboardInterrupt: @@ -120,12 +141,12 @@ def run(self, argv: list[str]) -> None: except Exception as ex: errors.log_error(ex) - def _get_command(self, command: str, cfg: config.SiteGeneratorConfig) -> Callable: - if command == "live": + def _get_command(self, cfg: config.SiteGeneratorConfig) -> Callable: + if cfg.command == "live": return commands.live(cfg) - if command == "build": + if cfg.command == "build": return commands.build(cfg) - if command == "validate": + if cfg.command == "validate": return commands.validate(cfg) - raise ValueError(f"Unknown command name '{command}'") + raise ValueError(f"Unknown command name '{cfg.command}'") diff --git a/site_generator/config.py b/site_generator/config.py index 458e2f3d..9c8763b7 100644 --- a/site_generator/config.py +++ b/site_generator/config.py @@ -10,6 +10,8 @@ class SiteGeneratorConfig(BaseSettings): model_config = SettingsConfigDict(env_prefix="SG_", from_attributes=True) + command: str + base: pathlib.Path templates: pathlib.Path pages: pathlib.Path @@ -25,6 +27,9 @@ class SiteGeneratorConfig(BaseSettings): verbose: bool = False + locale: str | None = None + site_name: str | None = None + @pydantic.field_validator("templates", "pages", "static", "base", "output") @classmethod def ensure_directory(cls, path: pathlib.Path | None) -> pathlib.Path | None: @@ -57,3 +62,17 @@ def format_relative_path(self, path: pathlib.Path | str) -> str: if path.is_absolute(): return str(path) return f"./{path}" + + def base_url(self) -> str: + """ + Return the base URL of the site. + + When running in live mode, this assumes `http` and uses both the configured host + and port values. Otherwise, this assumes `https` and uses only the host value. + + >>> cfg.base_url() + 'https://example.com' + """ + scheme = "http" if self.command == "live" else "https" + fqdn = f"{self.host}:{self.port}" if self.command == "live" else self.host + return f"{scheme}://{fqdn}" diff --git a/site_generator/config_test.py b/site_generator/config_test.py index bfa81d6b..0ad785bc 100644 --- a/site_generator/config_test.py +++ b/site_generator/config_test.py @@ -16,6 +16,8 @@ def fake_test_config(**kwargs) -> config.SiteGeneratorConfig: # noqa: ANN003 kwargs["static"] = pathlib.Path("./static") if "output" not in kwargs: kwargs["output"] = pathlib.Path("./output") + if "command" not in kwargs: + kwargs["command"] = "build" return config.SiteGeneratorConfig(**kwargs) diff --git a/site_generator/frontmatter.py b/site_generator/frontmatter.py index 0ad05546..7bab1b63 100644 --- a/site_generator/frontmatter.py +++ b/site_generator/frontmatter.py @@ -1,6 +1,7 @@ import datetime import pathlib from typing import Any, Literal +from urllib import parse import pydantic @@ -8,44 +9,83 @@ from site_generator import emoji -class PageFrontmatter(pydantic.BaseModel): - """ - Frontmatter values extracted from markdown page frontmatter content. +class OpenGraphFrontmatter(pydantic.BaseModel): + """Page Open Graph details extracted from markdown frontmatter content.""" - `template` is the name of the template to use when rendering this file + title: str | None = None + """The og:title for this page, defaults to the page title if not set.""" - `path` the path under which the page served in the output. If this path does not - end in `.html` it will have `index.html` appended so the path works as expected - in browsers. + image: str | None = None + """The og:image for this page.""" - `title`, `subtitle`, `description`, `date`, and `tags` can each be used to describe - the page and it's content. It depends on the template how these are used, for - example you can use the `title` to set the `.head.title` DOM field's text. + description: str | None = None + """The og:description for this page, defaults to the page subtitle if not set.""" - `type` is a special keyword that enable bespoke page handling. You normally do not - need to specify this value, the default has no special meaning. However, you can - set the page `type` to one of the following values to enable specific - functionality. - - `"default"` is the default value and has no special meaning. - - `"blog_index"` marks this page as the root page for a blog. When being - processed, this file will use the `blog_index` pipeline instead of the default - `markdown` pipeline. The file must be named `index.md`. + url: str | None = None + """The og:url for this page, defaults to the computed page URL if not set.""" - `meta` is a dict of arbitrary values that can be set on a per-page basis with no - further validation or prescribed semantic meaning. It depends on the template - how these values are used. + type: str = "website" # noqa: A003 + """The og:type for this page, defaults to `"website"` if not set.""" + + locale: str | None = None + """The og:locale for this page, defaults to the locale in config if not set..""" + + site_name: str | None = None """ + The og:site_name for this page, defaults to the site name in config if not set. + """ + + +class PageFrontmatter(pydantic.BaseModel): + """Page details extracted from markdown frontmatter content.""" template: str | None = None + """The name of the template to use when rendering this file.""" + path: str | None = None + """ + The path under which the page served in the output. If this path does not end in + `.html` it will have `index.html` appended so it works as expected in browsers. + """ + title: str | None = None + """The title for this page.""" + subtitle: str | None = None + """The subtitle for this page.""" + description: str | None = None + """The meta description for this page.""" + tags: list[str] = pydantic.Field(default_factory=list) + """Classification tags for this page's content.""" + date: datetime.date | None = None - type: Literal["default"] | Literal["blog_index"] = "default" # noqa: A003 + """The original publication date for this page.""" + + og: OpenGraphFrontmatter | None = None + """ + Open Graph details. Some values are auto populated from `title`, `subtitle`, `date`, + and other sources. + """ meta: dict | None = None + """ + Arbitrary values that can be set on a per-page basis with no further validation or + prescribed semantic meaning. It depends on the template how these values are used. + """ + + type: Literal["default"] | Literal["blog_index"] = "default" # noqa: A003 + """ + `type` is a special keyword that enable bespoke page handling. You normally do not + need to specify this value, the default has no special meaning. However, you can + set the page `type` to one of the following values to enable specific + functionality. + - `"default"` is the default value and has no special meaning. + - `"blog_index"` marks this page as the root page for a blog. When being + processed, this file will use the `blog_index` pipeline instead of the default + `markdown` pipeline. The file must be named `index.md`. + """ # The following fields are populated automatically, don't need to be in the # template frontmatter, and aren't included in template rendering. @@ -84,6 +124,45 @@ def get_output_path(self) -> pathlib.Path: return path + def get_page_url(self) -> str: + """ + Determine the URL to this page in the rendered site based on it's output path + and the base URL. + """ + if not self.config: + raise ValueError(f"{self.__class__.__name__}.config must be set") + + path = "/" + str(self.get_output_path().relative_to(self.config.output)) + path = path.removesuffix("index.html") + + return self.config.base_url() + path + + def get_open_graph(self) -> OpenGraphFrontmatter: + """ + Returns the Open Graph frontmatter for this page with default values applied. + + The values actually provided via frontmatter from the source file remain + unmodified in `self.og`. + """ + if not self.config: + raise ValueError(f"{self.__class__.__name__}.config must be set") + + og = self.og or OpenGraphFrontmatter() + + # Make OG image URL fully qualified if it isn't already + if og.image and not parse.urlparse(og.image).hostname: + image = og.image if og.image.startswith("/") else "/" + og.image + og.image = self.config.base_url() + "/" + image + + # Set default values for OG properties + og.title = og.title or self.title + og.description = og.description or self.subtitle + og.url = og.url or self.get_page_url() + og.locale = og.locale or self.config.locale + og.site_name = og.site_name or self.config.site_name + + return og + def get_props(self) -> dict[str, Any]: """ Return the frontmatter properties as a `dict` of values. @@ -96,6 +175,7 @@ def get_props(self) -> dict[str, Any]: "title": emoji.replace_emoji(self.title), "subtitle": emoji.replace_emoji(self.subtitle), "description": emoji.replace_emoji(self.description), + "og": self.get_open_graph(), } return {k: v for k, v in props.items() if v is not None} diff --git a/site_generator/frontmatter_test.py b/site_generator/frontmatter_test.py index 4190186e..6b53f60a 100644 --- a/site_generator/frontmatter_test.py +++ b/site_generator/frontmatter_test.py @@ -60,10 +60,28 @@ def test_page_frontmatter__get_output_path__no_config(): @pytest.mark.parametrize( ("fm_kwargs", "expected"), [ - ({}, {"tags": [], "type": "default"}), + ( + {}, + { + "tags": [], + "type": "default", + "og": frontmatter.OpenGraphFrontmatter( + url="https://localhost/test.html", type="website" + ), + }, + ), ( {"title": "test title", "subtitle": None}, - {"title": "test title", "tags": [], "type": "default"}, + { + "title": "test title", + "tags": [], + "type": "default", + "og": frontmatter.OpenGraphFrontmatter( + title="test title", + url="https://localhost/test.html", + type="website", + ), + }, ), ( { @@ -88,10 +106,17 @@ def test_page_frontmatter__get_output_path__no_config(): "tags": ["tag_a", "tag_b"], "date": datetime.date(2022, 1, 1), "type": "default", + "og": frontmatter.OpenGraphFrontmatter( + title="props_title", + description="props_subtitle", + url="https://localhost/props_path/", + type="website", + ), }, ), ], ) def test_page_frontmatter__get_props(fm_kwargs, expected): fm = fake_page_frontmatter(**fm_kwargs) + fm.config = config_test.fake_test_config() assert fm.get_props() == expected diff --git a/static/img/R20_18.jpg b/static/img/R20_18.jpg deleted file mode 100644 index 14453d1c..00000000 Binary files a/static/img/R20_18.jpg and /dev/null differ diff --git a/templates/base.html b/templates/base.html index 237d2fb8..3cae2272 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,11 +13,34 @@ + {% block opengraph %} + {% if props.og.title %} + + {% endif %} + + {% if props.og.image %} + + {% endif %} + + {% if props.og.description %} + + {% endif %} + + {% if props.og.url %} + + {% endif %} + + + + + {% endblock opengraph %} + + {% block stylesheets %} - {% block stylesheets %}{% endblock stylesheets %} + {% endblock stylesheets %} {{ props.title }} {% endblock head %} diff --git a/templates/blog_index.html b/templates/blog_index.html index 6570fd38..d1e8542f 100644 --- a/templates/blog_index.html +++ b/templates/blog_index.html @@ -1,6 +1,7 @@ {% extends "default.html" %} {% block stylesheets %} +{{ super() }} {% endblock stylesheets %}