lander
Advanced tools
+52
| # Claude Configuration for Lander | ||
| This is an HTML landing page generator for LSST PDF documentation. It's a Python project with Node.js/Webpack for frontend asset building. | ||
| ## Commands | ||
| Build frontend assets: | ||
| ```bash | ||
| npm run build | ||
| ``` | ||
| Run tests: | ||
| ```bash | ||
| tox run -e py | ||
| ``` | ||
| Run linting/formatting: | ||
| ```bash | ||
| tox run -e lint | ||
| ``` | ||
| Run type checking: | ||
| ```bash | ||
| tox run -e typing | ||
| ``` | ||
| Run end-to-end integration tests: | ||
| ```bash | ||
| make test | ||
| ``` | ||
| ## Project Structure | ||
| - `src/lander/` - Python source code | ||
| - `tests/` - Python unit tests | ||
| - `integration-tests/` - End-to-end integration tests | ||
| - `js/`, `scss/` - Frontend source files | ||
| - `gulpfile.js`, `webpack.config.js` - Build configuration | ||
| ## Development Notes | ||
| - Python package managed with setuptools | ||
| - Frontend assets built with Webpack and Gulp | ||
| - Uses pytest for unit testing | ||
| - Integration tests in bash scripts | ||
| - Code formatting with black (79 char line length) | ||
| - Import sorting with isort |
| """Metadata formatting modules for HTML output.""" | ||
| __all__ = ["HighwireMetadata", "OpenGraphMetadata"] | ||
| from .highwire import HighwireMetadata | ||
| from .opengraph import OpenGraphMetadata |
| """Base class for metadata formatters.""" | ||
| from abc import ABC, abstractmethod | ||
| from typing import List, Optional, Union | ||
| class MetaTagFormatterBase(ABC): | ||
| """A base class for generating HTML meta tags.""" | ||
| def __str__(self) -> str: | ||
| """Create the metadata tags.""" | ||
| return self.as_html() | ||
| @property | ||
| @abstractmethod | ||
| def tag_attributes(self) -> List[str]: | ||
| """The names of class properties that create tags.""" | ||
| raise NotImplementedError | ||
| def as_html(self) -> str: | ||
| """Create the metadata HTML tags.""" | ||
| tags: List[str] = [] | ||
| for prop in self.tag_attributes: | ||
| self.extend_not_none(tags, getattr(self, prop)) | ||
| return "\n".join(tags) + "\n" | ||
| @staticmethod | ||
| def extend_not_none( | ||
| entries: List[str], new_item: Optional[Union[str, List[str]]] | ||
| ) -> None: | ||
| """Extend a list with new items if they are not None.""" | ||
| if new_item is None: | ||
| return | ||
| if isinstance(new_item, list): | ||
| entries.extend(new_item) | ||
| else: | ||
| entries.append(new_item) |
| """Highwire Press metadata formatter for Google Scholar.""" | ||
| from typing import TYPE_CHECKING, List, Optional | ||
| from .base import MetaTagFormatterBase | ||
| if TYPE_CHECKING: | ||
| from lander.config import Configuration | ||
| class HighwireMetadata(MetaTagFormatterBase): | ||
| """Format Lander metadata as Highwire Press tags for Google Scholar. | ||
| Highwire Press metadata tags are used by Google Scholar to index | ||
| academic and technical documents. | ||
| """ | ||
| def __init__(self, config: "Configuration") -> None: | ||
| self.config = config | ||
| @property | ||
| def tag_attributes(self) -> List[str]: | ||
| """The names of class properties that create tags.""" | ||
| return [ | ||
| "title", | ||
| "authors", | ||
| "date", | ||
| "technical_report_number", | ||
| "pdf_url", | ||
| "fulltext_html_url", | ||
| ] | ||
| @property | ||
| def title(self) -> str: | ||
| """The document title tag.""" | ||
| title = self.config.title.plain or self.config.title.html or "" | ||
| return f'<meta name="citation_title" content="{self._escape(title)}">' | ||
| @property | ||
| def authors(self) -> List[str]: | ||
| """Author metadata tags. | ||
| Each author generates multiple tags: | ||
| - citation_author | ||
| - citation_author_institution (if available) | ||
| - citation_author_email (if available) | ||
| - citation_author_orcid (if available) | ||
| """ | ||
| if not self.config.authors: | ||
| return [] | ||
| tags = [] | ||
| for author in self.config.authors: | ||
| name = author.plain or author.html or "" | ||
| tags.append( | ||
| f'<meta name="citation_author" content="{self._escape(name)}">' | ||
| ) | ||
| # Note: Institution, email, ORCID not currently in Lander config | ||
| # but included here for future extension | ||
| return tags | ||
| @property | ||
| def date(self) -> Optional[str]: | ||
| """The publication date tag. | ||
| Format: YYYY/MM/DD (Highwire requires slash separators) | ||
| """ | ||
| if self.config.build_datetime: | ||
| date_str = self.config.build_datetime.strftime("%Y/%m/%d") | ||
| return f'<meta name="citation_date" content="{date_str}">' | ||
| return None | ||
| @property | ||
| def technical_report_number(self) -> Optional[str]: | ||
| """The technical report number (document handle).""" | ||
| if self.config.handle: | ||
| return ( | ||
| f'<meta name="citation_technical_report_number" ' | ||
| f'content="{self._escape(self.config.handle)}">' | ||
| ) | ||
| return None | ||
| @property | ||
| def pdf_url(self) -> Optional[str]: | ||
| """The PDF download URL.""" | ||
| if self.config.canonical_url and self.config.pdf_path: | ||
| pdf_url = ( | ||
| f"{self.config.canonical_url}/" | ||
| f"{self.config.relative_pdf_path}" | ||
| ) | ||
| return f'<meta name="citation_pdf_url" content="{pdf_url}">' | ||
| return None | ||
| @property | ||
| def fulltext_html_url(self) -> Optional[str]: | ||
| """The canonical HTML URL of the document.""" | ||
| if self.config.canonical_url: | ||
| return ( | ||
| f'<meta name="citation_fulltext_html_url" ' | ||
| f'content="{self.config.canonical_url}">' | ||
| ) | ||
| return None | ||
| @staticmethod | ||
| def _escape(value: str) -> str: | ||
| """Escape HTML attribute content.""" | ||
| return value.replace('"', """).replace("'", "'") |
| """OpenGraph metadata formatter for social media.""" | ||
| from datetime import timezone | ||
| from typing import TYPE_CHECKING, List, Optional | ||
| from .base import MetaTagFormatterBase | ||
| if TYPE_CHECKING: | ||
| from lander.config import Configuration | ||
| class OpenGraphMetadata(MetaTagFormatterBase): | ||
| """Format Lander metadata as OpenGraph tags for social media. | ||
| OpenGraph metadata enables rich link previews in social media | ||
| platforms and messaging applications. | ||
| """ | ||
| def __init__(self, config: "Configuration") -> None: | ||
| self.config = config | ||
| @property | ||
| def tag_attributes(self) -> List[str]: | ||
| """The names of class properties that create tags.""" | ||
| return [ | ||
| "title", | ||
| "description", | ||
| "url", | ||
| "og_type", | ||
| "authors", | ||
| "dates", | ||
| ] | ||
| @property | ||
| def title(self) -> str: | ||
| """The og:title tag.""" | ||
| title = self.config.title.plain or self.config.title.html or "" | ||
| return ( | ||
| f'<meta property="og:title" ' # noqa: E231 | ||
| f'content="{self._escape(title)}">' | ||
| ) | ||
| @property | ||
| def description(self) -> Optional[str]: | ||
| """The og:description tag from the abstract.""" | ||
| if self.config.abstract: | ||
| abstract = ( | ||
| self.config.abstract.plain or self.config.abstract.html or "" | ||
| ) | ||
| return ( | ||
| f'<meta property="og:description" ' # noqa: E231 | ||
| f'content="{self._escape(abstract)}">' | ||
| ) | ||
| return None | ||
| @property | ||
| def url(self) -> Optional[str]: | ||
| """The og:url canonical URL tag.""" | ||
| if self.config.canonical_url: | ||
| return ( # noqa: E231 | ||
| f'<meta property="og:url" ' # noqa: E231 | ||
| f'content="{self.config.canonical_url}">' | ||
| ) | ||
| return None | ||
| @property | ||
| def og_type(self) -> str: | ||
| """The og:type tag (always 'article' for documents).""" | ||
| return '<meta property="og:type" content="article">' | ||
| @property | ||
| def authors(self) -> List[str]: | ||
| """The og:article:author tags.""" | ||
| if not self.config.authors: | ||
| return [] | ||
| tags = [] | ||
| for author in self.config.authors: | ||
| name = author.plain or author.html or "" | ||
| tags.append( | ||
| f'<meta property="og:article:author" ' # noqa: E231 | ||
| f'content="{self._escape(name)}">' | ||
| ) | ||
| return tags | ||
| @property | ||
| def dates(self) -> List[str]: | ||
| """Publication and modification time tags. | ||
| Format: ISO 8601 with timezone (YYYY-MM-DDTHH:MM:SSZ) | ||
| """ | ||
| tags = [] | ||
| if self.config.build_datetime: | ||
| # Ensure datetime has timezone info | ||
| dt = self.config.build_datetime | ||
| if dt.tzinfo is None: | ||
| dt = dt.replace(tzinfo=timezone.utc) | ||
| dt_str = dt.strftime("%Y-%m-%dT%H:%M:%SZ") | ||
| tags.append( | ||
| f'<meta property="og:article:published_time" ' # noqa: E231 | ||
| f'content="{dt_str}">' | ||
| ) | ||
| # Also use as modified time since we don't track separate | ||
| # modification dates | ||
| tags.append( | ||
| f'<meta property="og:article:modified_time" ' # noqa: E231 | ||
| f'content="{dt_str}">' | ||
| ) | ||
| return tags | ||
| @staticmethod | ||
| def _escape(value: str) -> str: | ||
| """Escape HTML attribute content.""" | ||
| return value.replace('"', """).replace("'", "'") |
+2
-0
@@ -103,1 +103,3 @@ # Byte-compiled / optimized / DLL files | ||
| .mypy_cache | ||
| .claude/settings.local.json |
+11
-0
@@ -5,2 +5,13 @@ ########## | ||
| 1.1.0 (2025-09-22) | ||
| ================== | ||
| - Generate meta tags and microdata consistent with the `Technote project <https://technote.lsst.io/user-guide/metadata.html>`__: | ||
| - Highwire Press metadata supported by Google Scholar | ||
| - OpenGraph metadata for additional social media support | ||
| - microformats2 metadata on HTML elements | ||
| These changes improve compatibility for Ook to ingest Lander landing pages. | ||
| 1.0.9 (2025-09-09) | ||
@@ -7,0 +18,0 @@ ================== |
+1
-1
@@ -13,3 +13,3 @@ .PHONY: help test pytest ldm151 dmtn070 | ||
| pytest: | ||
| pytest --flake8 --doctest-modules lander tests | ||
| tox | ||
@@ -16,0 +16,0 @@ ldm151: |
+12
-1
| Metadata-Version: 2.4 | ||
| Name: lander | ||
| Version: 1.0.9 | ||
| Version: 1.1.0 | ||
| Summary: HTML landing page generator for LSST PDF documentation deployed from Git to LSST the Docs. | ||
@@ -259,2 +259,13 @@ Home-page: https://github.com/lsst-sqre/lander | ||
| 1.1.0 (2025-09-22) | ||
| ================== | ||
| - Generate meta tags and microdata consistent with the `Technote project <https://technote.lsst.io/user-guide/metadata.html>`__: | ||
| - Highwire Press metadata supported by Google Scholar | ||
| - OpenGraph metadata for additional social media support | ||
| - microformats2 metadata on HTML elements | ||
| These changes improve compatibility for Ook to ingest Lander landing pages. | ||
| 1.0.9 (2025-09-09) | ||
@@ -261,0 +272,0 @@ ================== |
| Metadata-Version: 2.4 | ||
| Name: lander | ||
| Version: 1.0.9 | ||
| Version: 1.1.0 | ||
| Summary: HTML landing page generator for LSST PDF documentation deployed from Git to LSST the Docs. | ||
@@ -259,2 +259,13 @@ Home-page: https://github.com/lsst-sqre/lander | ||
| 1.1.0 (2025-09-22) | ||
| ================== | ||
| - Generate meta tags and microdata consistent with the `Technote project <https://technote.lsst.io/user-guide/metadata.html>`__: | ||
| - Highwire Press metadata supported by Google Scholar | ||
| - OpenGraph metadata for additional social media support | ||
| - microformats2 metadata on HTML elements | ||
| These changes improve compatibility for Ook to ingest Lander landing pages. | ||
| 1.0.9 (2025-09-09) | ||
@@ -261,0 +272,0 @@ ================== |
@@ -6,2 +6,3 @@ .gitattributes | ||
| CHANGELOG.rst | ||
| CLAUDE.md | ||
| LICENSE | ||
@@ -63,2 +64,6 @@ MANIFEST.in | ||
| src/lander/lsstprojectmeta/tex/scraper.py | ||
| src/lander/metadata/__init__.py | ||
| src/lander/metadata/base.py | ||
| src/lander/metadata/highwire.py | ||
| src/lander/metadata/opengraph.py | ||
| src/lander/templates/_footer.jinja | ||
@@ -65,0 +70,0 @@ src/lander/templates/_info-panel.jinja |
+17
-0
@@ -341,2 +341,19 @@ """Lander's configuration.""" | ||
| @property | ||
| def canonical_url(self) -> Optional[str]: | ||
| """The canonical URL of the document. | ||
| This is the primary URL where the document can be accessed. | ||
| Prefers LSST the Docs URL, falls back to GitHub repository. | ||
| """ | ||
| if self.ltd_product: | ||
| # Construct LSST the Docs URL from product slug | ||
| # Assumes we're building the main/default version | ||
| return f"https://{self.ltd_product}.lsst.io" # noqa: E231 | ||
| elif self.github_slug: | ||
| # Fall back to GitHub repository URL | ||
| return f"https://github.com/{self.github_slug}" # noqa: E231 | ||
| else: | ||
| return None | ||
| @validator("pdf_path", always=True) | ||
@@ -343,0 +360,0 @@ def check_pdf_path(cls, v: str) -> str: |
@@ -18,2 +18,5 @@ """HTML renderering functions.""" | ||
| from lander import __version__ | ||
| from lander.metadata import HighwireMetadata, OpenGraphMetadata | ||
| if TYPE_CHECKING: | ||
@@ -43,5 +46,14 @@ from lander.config import Configuration | ||
| def render_homepage(config: "Configuration", env: jinja2.Environment) -> str: | ||
| """Render the homepage.jinja template.""" | ||
| """Render the homepage.jinja template with metadata.""" | ||
| # Generate metadata | ||
| highwire = HighwireMetadata(config) | ||
| opengraph = OpenGraphMetadata(config) | ||
| template = env.get_template("homepage.jinja") | ||
| rendered_page = template.render(config=config) | ||
| rendered_page = template.render( | ||
| config=config, | ||
| lander_version=__version__, | ||
| highwire_metadata=highwire.as_html(), | ||
| opengraph_metadata=opengraph.as_html(), | ||
| ) | ||
| return rendered_page | ||
@@ -48,0 +60,0 @@ |
@@ -10,3 +10,3 @@ <header class="lander-info-header"> | ||
| {% endif %} | ||
| <h1 class="lander-info-header__product-name">{{ config.title.html|safe }}</h1> | ||
| <h1 class="lander-info-header__product-name p-name">{{ config.title.html|safe }}</h1> | ||
@@ -18,3 +18,5 @@ <ul class="o-list-bare"> | ||
| </span> | ||
| {{ config.build_datetime|simple_date }} | ||
| <time class="dt-published" datetime="{{ config.build_datetime.isoformat() }}"> | ||
| {{ config.build_datetime|simple_date }} | ||
| </time> | ||
| </li> | ||
@@ -42,3 +44,3 @@ | ||
| <li class="c-lander-btn-row__item"> | ||
| <a class="c-btn c-btn--small c-btn--ghost c-btn--ghost-faint" href="https://{{ config.ltd_product }}.lsst.io/v">Change version</a> | ||
| <a class="c-btn c-btn--small c-btn--ghost c-btn--ghost-faint u-url" href="https://{{ config.ltd_product }}.lsst.io/v">Change version</a> | ||
| </li> | ||
@@ -63,3 +65,3 @@ {% endif %} | ||
| {% for author_name in config.authors %} | ||
| <li>{{ author_name.html|safe }}</li> | ||
| <li class="p-author">{{ author_name.html|safe }}</li> | ||
| {% endfor %} | ||
@@ -71,3 +73,3 @@ </ul> | ||
| {% if config.abstract %} | ||
| <section class="lander-info-abstract"> | ||
| <section class="lander-info-abstract p-summary"> | ||
| <h2 class="lander-subsection-header">Abstract</h2> | ||
@@ -111,3 +113,3 @@ {{ config.abstract.html|safe }} | ||
| <li class="o-list-bare__item"> | ||
| <a class="hidden-link" href="https://github.com/{{ config.github_slug }}"> | ||
| <a class="hidden-link" href="https://github.com/{{ config.github_slug }}" data-lander-source-url="https://github.com/{{ config.github_slug }}"> | ||
| <span class="svg-icon svg-icon--left svg-baseline"> | ||
@@ -114,0 +116,0 @@ <svg><use xlink:href="#octicon-mark-github" /></svg> |
@@ -12,2 +12,17 @@ <!doctype html> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| {# Generator meta tag #} | ||
| <meta name="generator" content="Lander {{ lander_version }}"> | ||
| {# Canonical URL #} | ||
| {% if config.canonical_url %} | ||
| <link rel="canonical" href="{{ config.canonical_url }}"> | ||
| {% endif %} | ||
| {# OpenGraph metadata for social media #} | ||
| {{ opengraph_metadata }} | ||
| {# Highwire metadata for Google Scholar #} | ||
| {{ highwire_metadata }} | ||
| {% include "_typekit_loader.jinja" %} | ||
@@ -14,0 +29,0 @@ <link rel="stylesheet" href="assets/app.css"> |
@@ -5,3 +5,3 @@ {% extends "base.jinja" %} | ||
| <div class="lander-container lander-container--content"> | ||
| <div class="lander-container lander-container--content h-entry"> | ||
@@ -12,3 +12,3 @@ <div class="lander-info-item"> | ||
| <div class="lander-pdf-item"> | ||
| <div class="lander-pdf-item e-content"> | ||
| <div id="pdf-container" data-pdf-path="{{ config.relative_pdf_path }}"> | ||
@@ -15,0 +15,0 @@ </div> |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
295164
4.3%73
7.35%4155
6.27%