Metadata-Version: 2.4
Name: opengraph-linter
Version: 0.1.0
Summary: A pluggable CLI meta-tag inspector for Open Graph and X card tags
Author: Jeff Triplett
Author-email: Jeff Triplett <jeff.triplett@gmail.com>
License-Expression: BSD-3-Clause
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Dist: httpx2>=2.12.0
Requires-Dist: pluggy>=1.6.0
Requires-Dist: pillow>=11.0.0
Requires-Dist: pydantic>=2.13.0
Requires-Dist: rich>=15.0.0
Requires-Dist: typer>=0.27.0
Requires-Dist: textual-image>=0.13.2 ; extra == 'preview'
Requires-Python: >=3.14
Project-URL: Homepage, https://github.com/jefftriplett/opengraph-linter
Project-URL: Repository, https://github.com/jefftriplett/opengraph-linter
Project-URL: Issues, https://github.com/jefftriplett/opengraph-linter/issues
Provides-Extra: preview
Description-Content-Type: text/markdown

# opengraph-linter

A pluggable CLI meta-tag inspector for Open Graph and X card tags — a terminal take on
[opengraph.xyz](https://www.opengraph.xyz).

## Install

```bash
uv tool install opengraph-linter             # core: reports without previews
uv tool install "opengraph-linter[preview]"  # + inline og:image previews
```

The inline preview needs the optional `preview` extra (textual-image), and is
off by default. Turn it on with `--preview` or with `preview = true` in the
config. Without the extra, the report still runs every rule and prints a
one-line hint instead of the picture.

## Usage

```bash
# straight from a checkout, no install
uv run src/opengraph_linter https://example.com/

# the installed console script; several URLs work in one run
uv run opengraph-linter https://example.com/ https://example.org/

# equivalents
uv run python -m opengraph_linter https://example.com/
uv run scripts/og_preview.py https://example.com/
```

Options:

| Flag | Effect |
| --- | --- |
| `--preview` / `--no-preview` | Force the inline `og:image` preview on or off; overrides the `preview` config (off by default, needs the `preview` extra) |
| `--width` | Image width in terminal cells (default: half the panel width) |
| `--skip-image-checks` | Do not download `og:image` |
| `--list-rules` | List every registered rule and exit |
| `--only X` / `--disable X` | Run or skip a rule by id or lint code, repeatable |
| `--no-plugins` | Ignore third-party plugin entry points |
| `--json` | Emit findings as JSON |
| `--strict` | Exit non-zero on warnings as well as criticals |
| `--timeout` | Request timeout in seconds (default 15) |

Exit codes: `0` clean, `1` critical findings (warnings too with `--strict`),
`2` a page could not be fetched or an option was invalid. A run with several
URLs prints one panel per URL and a final overview table.

`--json` emits a list with one `{url, findings}` object per URL.

## Configuration

Every threshold lives on the `Settings` model. Override any field from a
`[tool.opengraph-linter]` table in the `pyproject.toml` of the directory you
run from:

```toml
[tool.opengraph-linter]
og_title_max = 70
max_image_bytes = 8388608
# render og:image inline (needs the `preview` extra, off by default);
# --preview/--no-preview wins
preview = true
# rules to skip on every run, by lint code or rule id
ignore = ["OG004", "image-conversion-text"]
```

When `pyproject.toml` has no `[tool.opengraph-linter]` table, the linter
falls back on an `opengraph-linter.toml` file in the same directory. Its keys
sit at the top level:

```toml
# opengraph-linter.toml
og_title_max = 70
ignore = ["OG004", "image-conversion-text"]
```

The `ignore` list is merged with any `--disable` flags. An unknown code or id
in either place exits with code `2`.

## Rules

Every data point is a `Rule` — a lint code, an id, a tag, and a
`check(context) -> Finding`. Twenty-three ship built in. Each reports critical,
warning, or success; the "Fails as" column shows the severity when the check
does not pass. Ignore any of them by code or id with `--disable` or the
`ignore` list.

### Open Graph (`OG`)

| Code | Rule id | Tag | Fails as | Checks |
| --- | --- | --- | --- | --- |
| `OG001` | `og-title-present` | `og:title` | critical | The tag is set; platforms fall back to the page title or the URL without it |
| `OG002` | `og-title-length` | `og:title` | warning | The title fits the 60-character target (`og_title_max`) |
| `OG003` | `og-description-length` | `og:description` | critical / warning | The tag is set (critical) and fits a preview card (`og_description_max`, warning) |
| `OG004` | `og-site-name` | `og:site_name` | warning | The tag is set; Discord and others show the bare domain without it |
| `OG005` | `og-url` | `og:url` | warning | The canonical URL is set and absolute, so shares consolidate on one URL |
| `OG006` | `og-type` | `og:type` | warning | The object type is declared; platforms assume `website` without it |
| `OG007` | `og-image-alt` | `og:image:alt` | warning | Alt text is set for the image; the spec asks for it and screen readers announce it |
| `OG008` | `og-image-dimensions` | `og:image:width` | warning | Width and height are declared (and match the file), so the first share renders before download |
| `OG009` | `og-image-secure-url` | `og:image:secure_url` | warning | The image has an HTTPS URL, directly or via `og:image:secure_url` |
| `OG010` | `og-type-valid` | `og:type` | warning | The value is a global type from the spec, or a namespaced custom type with a colon |
| `OG011` | `og-locale-format` | `og:locale` | warning | The locale, when set, matches `language_TERRITORY` (e.g. `en_US`) |
| `OG012` | `og-determiner` | `og:determiner` | warning | The determiner, when set, is one of `a`, `an`, `the`, `""`, `auto` |
| `OG013` | `og-vertical-properties` | `og:type` | warning | An `article`, `book`, `profile`, `music.*`, or `video.*` page carries its vertical's recommended properties |

### X / Twitter cards (`TW`)

| Code | Rule id | Tag | Fails as | Checks |
| --- | --- | --- | --- | --- |
| `TW001` | `twitter-card-type` | `twitter:card` | warning | The card type is `summary_large_image` (`preferred_card`) for a full-width preview |
| `TW002` | `twitter-title` | `twitter:title` | warning | A title is available (falls back to `og:title`) and fits `twitter_title_max` |
| `TW003` | `twitter-description-length` | `twitter:description` | warning | A description is available (falls back to `og:description`) and fits `twitter_description_max` |
| `TW004` | `twitter-image` | `twitter:image` | critical | An image is available for the card (falls back to `og:image`) |

### The og:image asset (`IMG`)

| Code | Rule id | Tag | Fails as | Checks |
| --- | --- | --- | --- | --- |
| `IMG001` | `image-loads` | `og:image` | critical | The tag is set and the file downloads cleanly |
| `IMG002` | `image-aspect-ratio` | `og:image` | critical | The image is close to `target_ratio` (1.91:1, 1200×630) within `ratio_tolerance` |
| `IMG003` | `image-file-size` | `og:image` | warning | The file is under `max_image_bytes` (5 MB); platforms reject larger files |
| `IMG004` | `image-conversion-text` | `og:image` | warning | The image reads as a designed card with a headline or CTA (see below) |

### Plain SEO tags (`SEO`)

| Code | Rule id | Tag | Fails as | Checks |
| --- | --- | --- | --- | --- |
| `SEO001` | `page-title-present` | `title` | critical | The document has a `<title>` |
| `SEO002` | `meta-description-length` | `description` | warning | A meta description is set and fits a Google snippet (`meta_description_max`) |

The `IMG` rules that need the downloaded file (`IMG002`–`IMG004`) report a
warning when the image was not fetched, for example with
`--skip-image-checks`. Run `opengraph-linter --list-rules` for the same table
in the terminal.

The conversion-text rule is a heuristic, not OCR: it measures how much of the
image is covered by its dominant flat colors, which separates designed cards
from photographs and auto-generated video thumbnails. Every threshold lives on
the `Settings` model in `src/opengraph_linter/models.py`.

## Plugins

Rules are registered through [pluggy](https://pluggy.readthedocs.io), so a
separate package can add or replace them:

```python
from opengraph_linter import Rule, hookimpl


class FacebookAppId(Rule):
    id = "fb-app-id"
    code = "FB001"  # pick an unused prefix for your plugin
    tag = "fb:app_id"
    description = "fb:app_id is set for Facebook insights"
    order = 35

    def check(self, context):
        value = context.page.get("fb:app_id")
        if not value:
            return self.warn("Facebook app id is missing", "Set fb:app_id.")
        return self.ok("Facebook app id is set", value)


@hookimpl
def opengraph_rules():
    return [FacebookAppId()]
```

Expose it as an `opengraph_linter` entry point and it loads automatically:

```toml
[project.entry-points.opengraph_linter]
my-rules = "my_package.rules"
```

Two hooks are available:

- `opengraph_rules()` — return rules to add. Reusing a built-in `id` replaces it.
- `opengraph_finding(finding, context)` — return a `Finding` to override one,
  or `None` to leave it alone.

Rules are plain classes; everything they consume and emit — `Page`, `ImageInfo`,
`Settings`, `Context`, `Finding` — is a pydantic model.
