Skip to content

Commit

Permalink
feat: Add support for Open Graph tags (#167)
Browse files Browse the repository at this point in the history
* Add OpenGraph `meta` tags to base template

* Fix use of `stylesheets` block

* Add `image` as a frontmatter option for Open Graph support

* Enable templates to know their eventual URL during rendering

* Update Open Graph tags to use URL and image if available

* Add config support for specifying site name & locale

* Make OG tags a separate front matter section

Default values are still pulled from the main front matter to keep
page writing simple.

* Update base template to use revised OG context values

* Fix incorrect default value for `og:type`

* Update unit tests to handle OG front matter

* Update pre-existing pages to have better OG content

* Remove `sources` from CI tasks

* Remove unused image

* Exclude `img/dj_howard/README.md` from website upload

* Ensure deleted files are removed from S3 on website upload
  • Loading branch information
Nadock authored Jul 6, 2023
1 parent daeb628 commit c3fdf4c
Show file tree
Hide file tree
Showing 13 changed files with 213 additions and 49 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/deploy_website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "/*"
Expand Down
8 changes: 0 additions & 8 deletions Taskfile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,6 @@ tasks:

mypy:
desc: Run python/mypy Python type checking tool
sources:
- ./**/*.py
cmds:
- "{{.PY_PREFIX}} mypy {{.CLI_ARGS}} ./site_generator"

Expand All @@ -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}}"

Expand All @@ -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"

Expand Down
6 changes: 4 additions & 2 deletions pages/index.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
7 changes: 3 additions & 4 deletions pages/license.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pages/privacy.md
Original file line number Diff line number Diff line change
@@ -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
---

Expand Down
35 changes: 28 additions & 7 deletions site_generator/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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",
Expand All @@ -112,20 +133,20 @@ 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:
pass
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}'")
19 changes: 19 additions & 0 deletions site_generator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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}"
2 changes: 2 additions & 0 deletions site_generator/config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
124 changes: 102 additions & 22 deletions site_generator/frontmatter.py
Original file line number Diff line number Diff line change
@@ -1,51 +1,91 @@
import datetime
import pathlib
from typing import Any, Literal
from urllib import parse

import pydantic

from site_generator import config as _config
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.
Expand Down Expand Up @@ -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.
Expand All @@ -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}

Expand Down
Loading

0 comments on commit c3fdf4c

Please sign in to comment.