Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions great_docs/_website/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
"""
The site's name and addresses

One `Identity` supplies the site name and addresses for page titles, social
cards, structured data, canonical links, sitemaps and crawler rules.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, cast

from . import page_url, quarto_yml

if TYPE_CHECKING:
from pathlib import Path

from ..config import Config


@dataclass(frozen=True)
class Identity:
"""The site's name, serving address and preferred address for links"""

name: str
"""The site name in page titles, or an empty string when none is known"""

url: str | None
"""The configured `site_url`, where the site is served, or `None`"""

base_url: str | None
"""The preferred site address with a trailing slash, or `None` when unknown"""

@classmethod
def make(
cls,
config: Config,
build_dir: Path,
github_owner: str | None,
github_repo: str | None,
package_name: str | None,
) -> Identity:
"""
Resolve the site's name and addresses

Quarto uses `website.title` from the build directory's `_quarto.yml`
in page titles, so it takes precedence. The name falls back to the
configured display name, then `package_name`. The preferred address
is `seo.canonical.base_url`, with fallbacks to `site_url`, then the
repository's GitHub Pages address.

Parameters
----------
config
Project configuration.
build_dir
The Quarto project directory. Its `_quarto.yml` may be missing or
unreadable.
github_owner
Owner of the project's GitHub repository, if it has one.
github_repo
Name of the project's GitHub repository, if it has one.
package_name
The detected package name, if any.

Returns
-------
:
The site's identity.
"""
return cls(
name=_title(build_dir) or config.display_name or package_name or "",
url=config.site_url,
base_url=page_url.resolve_base_url(config, github_owner, github_repo),
)


def _title(build_dir: Path) -> str | None:
"""
Read the site title from a build directory's `_quarto.yml`

Parameters
----------
build_dir
The Quarto project directory.

Returns
-------
:
A non-empty `website.title`, or `None` when the file is missing,
unreadable or has no title.
"""
if not quarto_yml.path(build_dir).exists():
return None
try:
quarto_config = quarto_yml.read(build_dir)
except (OSError, ValueError):
return None
website = quarto_config.get("website")
if isinstance(website, dict):
title = cast("dict[str, Any]", website).get("title")
if isinstance(title, str) and title:
return title
return None
58 changes: 58 additions & 0 deletions great_docs/_website/social_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,17 @@
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import unquote, urlsplit, urlunsplit

from .._content import publish
from . import head
from .seo import description
from .seo import title as seo_title

if TYPE_CHECKING:
from collections.abc import Callable

from .._layout import Layout
from .._yaml import YamlMapping
from ..config import Config

Expand Down Expand Up @@ -122,6 +127,59 @@ def register(
website["twitter-card"] = twitter_card or True


def publish_image(
config: Config,
layout: Layout,
build_dir: Path,
base_url: str | None,
*,
report: Callable[[str], None],
) -> str | None:
"""
Copy the configured card image into the site and return its address

A configured image URL is returned unchanged. A local image is resolved
relative to the source directory and copied to the build directory's root.

Parameters
----------
config
Project configuration, whose `social_cards.image` names the image.
layout
The project's directories.
build_dir
The Quarto project directory.
base_url
The preferred site address with a trailing slash, or `None`.
report
Receives a warning when the local image does not exist.

Returns
-------
:
The configured URL or the copied image's address under `base_url`.
Without `base_url`, the copied image has a site-relative path. The
result is `None` when no image is configured or the local image is
missing.
"""
image_path = config.social_cards_image
if not image_path:
return None
if urlsplit(image_path).scheme or image_path.startswith("//"):
return image_path

image_url = urlsplit(image_path)
source = layout.source_dir / unquote(image_url.path)
if not source.is_file():
report(f"Social card image not found: {image_path}")
return None

# The image must be at the site root to match the returned address.
_ = publish.copy_asset(layout, source, build_dir / source.name)
path = base_url + source.name if base_url else source.name
return urlunsplit(("", "", path, image_url.query, image_url.fragment))


def page_title(title: str | None, site_name: str | None, config: Config) -> str | None:
"""
Build the card title that matches a page's `<title>`
Expand Down
5 changes: 3 additions & 2 deletions great_docs/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2844,7 +2844,8 @@ def record_generated_file(name: str) -> None:
warnings = []
info = []

canonical_base = docs._get_canonical_base_url()
identity = docs._identity()
canonical_base = identity.base_url

# ── Check sitemap.xml ────────────────────────────────────────────
sitemap_path = site_dir / "sitemap.xml"
Expand Down Expand Up @@ -2898,7 +2899,7 @@ def record_generated_file(name: str) -> None:
# `{site_name}`. A missing placeholder or non-string value skips this
# check.
title_template = docs._config.seo_title_template
site_name = docs._get_site_name()
site_name = identity.name
if not isinstance(title_template, str) or "{site_name}" not in title_template:
site_name = ""

Expand Down
112 changes: 26 additions & 86 deletions great_docs/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from importlib import resources
from pathlib import Path
from typing import TYPE_CHECKING, Any
from urllib.parse import unquote, urlsplit, urlunsplit
from urllib.parse import urlsplit

from . import _marimo
from ._build import freeze, outputs, quarto, serve, timings
Expand Down Expand Up @@ -70,6 +70,7 @@
from ._website.ai_agents.skills import SkillsWriter
from ._website.homepage import page as homepage_page
from ._website.homepage import source as homepage_source
from ._website.identity import Identity
from ._website.navigation import github as nav_github
from ._website.navigation import label as nav_label
from ._website.navigation import navbar as nav_navbar
Expand Down Expand Up @@ -222,7 +223,7 @@ def __init__(
self._cached_exports: list | None = None
self._cached_package_name: str | None = None
self._page_authors = page_dates.site_authors(
self._config, self.layout.source_dir, self._get_canonical_base_url()
self._config, self.layout.source_dir, self._identity().base_url
)

# Set environment variables needed by the qrenderer. The renderer links
Expand Down Expand Up @@ -1309,13 +1310,18 @@ def _process_custom_pages(self, guide: UserGuide | None) -> int:
if not sources:
return 0

identity = self._identity()
head = custom_pages.RawPageHead(
base_url=self._get_canonical_base_url(),
base_url=identity.base_url,
card_image_url=(
self._resolve_social_card_image_url() if self._config.social_cards_enabled else None
social_cards.publish_image(
self._config, self.layout, self.build_dir, identity.base_url, report=report
)
if self._config.social_cards_enabled
else None
),
structured_data=self._structured_data(),
site_name=self._get_site_name(),
site_name=identity.name,
default_description=self._default_description(),
)
published = custom_pages.publish(
Expand Down Expand Up @@ -1694,11 +1700,9 @@ def _structured_data(self) -> StructuredData | None:
:
The structured data, or `None` when it is disabled.
"""
identity = self._identity()
return StructuredData.make(
self._config,
self._get_package_metadata(),
self._get_site_name(),
self._get_canonical_base_url(),
self._config, self._get_package_metadata(), identity.name, identity.base_url
)

def _update_config_with_user_guide(
Expand Down Expand Up @@ -4334,16 +4338,18 @@ def sel(*suffixes: str) -> str:
i18n.register(config, self._config)

# Canonical links and social cards share the preferred page address.
base_url = self._identity().base_url
page_url.register(
config,
self._config,
self._get_canonical_base_url(),
base_url,
self.build_dir,
og_url=social_cards.applies(config, self._config, self.build_dir),
)
social_cards.register(
config, self._config, self._resolve_social_card_image_url(), self.build_dir
card_image_url = social_cards.publish_image(
self._config, self.layout, self.build_dir, base_url, report=_print_warning
)
social_cards.register(config, self._config, card_image_url, self.build_dir)
markdown_pages.register(config, self._config)

# Apply explicit navbar ordering from config (if set)
Expand Down Expand Up @@ -4475,86 +4481,20 @@ def _auto_save_snapshot(self) -> None:
# SEO GENERATION METHODS
# ══════════════════════════════════════════════════════════════════════════

def _get_canonical_base_url(self) -> str | None:
def _identity(self) -> Identity:
"""
Resolve the preferred site URL for canonical links
Resolve the site's name and addresses

Use `seo.canonical.base_url` if set, otherwise `site_url`, otherwise the
GitHub Pages URL. The canonical override can point to a different address
from the one serving the site.

Returns
-------
str | None
The canonical base URL (with trailing slash) or None if not determined.
"""
owner, repo, _ = self._get_github_repo_info()
return page_url.resolve_base_url(self._config, owner, repo)

def _resolve_social_card_image_url(self) -> str | None:
"""
Resolve the social card image to an absolute URL.

If the configured image is a local file path, copies it to the build directory and returns
the site-relative path. If it's already a URL, returns it as-is. If no image is configured,
returns `None`.

Returns
-------
str | None
Absolute URL or site-relative path for the social card image.
"""
image_path = self._config.social_cards_image
if not image_path:
return None

# If already a URL, return as-is
if urlsplit(image_path).scheme or image_path.startswith("//"): # pragma: no cover
return image_path # pragma: no cover

# Resolve relative to project root
image_url = urlsplit(image_path)
source = self.layout.source_dir / unquote(image_url.path) # pragma: no cover
if not source.is_file(): # pragma: no cover
print(f" ⚠️ Social card image not found: {image_path}") # pragma: no cover
return None # pragma: no cover

# Copy to build directory root (so it's served at site root)
dest = self.build_dir / source.name # pragma: no cover
publish.copy_asset(self.layout, source, dest) # pragma: no cover

# Build absolute URL if canonical base is available
base_url = self._get_canonical_base_url() # pragma: no cover
if base_url: # pragma: no cover
return urlunsplit(("", "", base_url + source.name, image_url.query, image_url.fragment))

# Fallback to site-relative path (works for most crawlers)
return urlunsplit(("", "", source.name, image_url.query, image_url.fragment))

def _get_site_name(self) -> str:
"""
Resolve the site name used in page titles

Read `website.title` from the generated `_quarto.yml` first because
Quarto uses this value. Otherwise, use the configured display name or
detected package name.
The site title in `_quarto.yml` changes during a build, so each call
reads the identity afresh.

Returns
-------
:
The site name, or an empty string if none is available.
The site's identity.
"""
if quarto_yml.path(self.build_dir).exists():
try:
config = quarto_yml.read(self.build_dir)
except (OSError, ValueError):
config = {}
website = config.get("website")
if isinstance(website, dict):
title = website.get("title")
if isinstance(title, str) and title:
return title
return self._config.display_name or self._detect_package_name() or ""
owner, repo, _ = self._get_github_repo_info()
return Identity.make(self._config, self.build_dir, owner, repo, self._detect_package_name())

def _prerender_term_player(self, log: "BuildLog") -> None:
"""
Expand Down Expand Up @@ -5522,7 +5462,7 @@ def _step_seo_files(self) -> Outcome:
:
The step's result line.
"""
base_url = self._get_canonical_base_url()
base_url = self._identity().base_url
site_files.write(self.layout.site_dir, self._config, base_url, report=print)
return "sitemap.xml + robots.txt"

Expand Down
Loading
Loading