npm package: @shevky/plugin-open-graph
Description: Shevky plugin for Open Graph, Twitter Card, and schema-aware JSON-LD generation.
Author: Fatih Tatoğlu
Latest version: 0.0.1

# Shevky Plugin: Open Graph

Open Graph, Twitter Card, and JSON-LD metadata generation for Shevky.

## Features

- Fills `page.og`, `page.twitter`, and `page.structuredData`
- Supports schema-driven JSON-LD with `schemaType`
- Runs in both hooks: `content:ready` and `page:meta`
- Produces graph-based structured data for home/about/contact pages
- Supports paginated home listing JSON-LD (`ItemList`)
- Supports dedicated `schemaType: job-listing` pages (separate builder)

## Installation

```bash
npm i @shevky/plugin-open-graph
```

## Usage

Add the plugin in `site.json`:

```json
{
  "plugins": ["@shevky/plugin-open-graph"],
  "pluginConfigs": {
    "shevky-open-graph": {
      "twitterSite": "@fatihtatoglu",
      "defaultTwitterCard": "summary_large_image",
      "defaultImage": "/assets/images/default-cover.webp",
      "siteName": "Fatih Tatoğlu",
      "publisherType": "person",
      "faqItemsPath": "faq.accordion.items",
      "newsItemsPath": "press.accordion.items",
      "questionCount": 20,
      "force": false,
      "exposePageMeta": true,
      "enableSearchAction": null,
      "searchActionTarget": "/?q={search_term_string}",
      "searchActionQueryInput": "required name=search_term_string"
    }
  }
}
```

## Mustache Example

```html
<!-- Open Graph -->
<meta property="og:type" content="{{page.og.type}}">
<meta property="og:site_name" content="{{site.title}}">
<meta property="og:title" content="{{page.og.title}}">
<meta property="og:description" content="{{page.og.description}}">
<meta property="og:image" content="{{{page.og.image}}}">
<meta property="og:image:secure_url" content="{{{page.og.image}}}">
<meta property="og:url" content="{{{page.og.url}}}">
<meta property="og:locale" content="{{page.og.locale}}">
{{#page.og.altLocale}}
<meta property="og:locale:alternate" content="{{.}}">
{{/page.og.altLocale}}

<!-- Twitter -->
<meta name="twitter:card" content="{{page.twitter.card}}">
<meta name="twitter:title" content="{{page.twitter.title}}">
<meta name="twitter:description" content="{{page.twitter.description}}">
<meta name="twitter:image" content="{{{page.twitter.image}}}">
<meta name="twitter:url" content="{{{page.twitter.url}}}">
{{#page.twitter.site}}
<meta name="twitter:site" content="{{.}}">
{{/page.twitter.site}}

{{#page.structuredData}}
<script type="application/ld+json">
  {{{page.structuredData}}}
</script>
{{/page.structuredData}}
```

## `schemaType`

Use explicit `schemaType` in front matter:

- `post` -> graph: `WebSite` + `Person` + `WebPage` + `BlogPosting` (+ `ImageObject` when present)
- `job-post` -> graph: `WebSite` + publisher entity + `WebPage` + `JobPosting` (job detail)
- `job-listing` -> graph: `WebSite` + publisher entity + `WebPage/CollectionPage` + `ItemList`
- `home` -> graph: `WebSite` + (`Person` or `Organization`) + `WebPage/CollectionPage/HomePage`
- `collection` + `collectionType: home` -> paginated home graph
- `about` -> graph: `WebSite` + (`Person` or `Organization`) + `AboutPage`
- `contact` -> graph: `WebSite` + (`Person` or `Organization`) + `ContactPage`
- `press` -> graph: `WebSite` + (`Person` or `Organization`) + `WebPage/CollectionPage` (+ `ItemList` when entries exist)
- `help` -> graph: `WebSite` + (`Person` or `Organization`) + `WebPage` (or `FAQPage` when FAQ entries exist)
- `collection` -> graph: `WebSite` + `Person` + `WebPage/CollectionPage` (+ `mainEntity: ItemList` when entries exist)
- `page` -> graph: `WebSite` + publisher entity + `WebPage`
- `policy` -> graph: `WebSite` + `Person` + `WebPage` (policy-aware genre)

### Post extras (for `schemaType: post`)

- `wordCount` is included only when `wordCount` exists in front matter
- `categoryUrl` + `categoryLabel` add:
  - `WebPage.breadcrumb` (`Home -> Category -> Post`)
  - `BlogPosting.about` reference to category webpage (`<categoryUrl>#webpage`)
- `ImageObject.width` and `ImageObject.height` are included when one of these exists:
  - `primaryImageWidth` / `primaryImageHeight`
  - `ogImageWidth` / `ogImageHeight`
  - `coverWidth` / `coverHeight`

### Job extras (detail + listing)

- Job detail pages emit `JobPosting` with:
  - `title`, `description`, `datePosted`, `url`
  - optional `validThrough` (or fallback from `jobValidThroughDays`)
  - optional `employmentType`, `identifier`, `directApply`, `baseSalary`
  - optional `hiringOrganization` and `jobLocation`
- Listing pages (`home`, `collectionType: home`, `job-listing`) emit `ItemList` entries
  - list item `@type` becomes `JobPosting` for detected job entries
- Type switch is resolved from listed page data (front matter / derived front matter / header.raw)
- `employmentType` is read directly from front matter:
  - first: `schemaEmploymentType` / `employmentTypeSchema` / `employmentSchema` / `jobEmploymentType`
  - then: `employmentType` / `employment` / `workType` / `jobType` / `contractType` / `positionType`
  - numeric values are supported and can be resolved from i18n key: `seo.home.jobs.employmentTypes.<value>`
  - if missing: `"-"`
- `jobLocation.address.addressCountry` comes from i18n map
  - if country is unknown/unmapped, `addressCountry` is omitted from JSON-LD
- Country names are normalized to ISO alpha-2 when possible (e.g. `Türkiye` -> `TR`, `Dubai` -> `AE`, `England` -> `GB`)
- Country code mapping comes from i18n key `seo.jobs.countryCodeMap`
- Job detection tokens and locality country-strip tokens are configurable from plugin config:
  - `jobCategoryTokens`
  - `jobUrlTokens`
  - `localityCountryTokens`
- Example:
  - `"seo": { "jobs": { "countryCodeMap": { "dubai": "AE", "türkiye": "TR", "england": "GB" } } }`
- `jobLocation.address.addressLocality` is normalized to city-only value (e.g. `Istanbul, Turkiye` -> `Istanbul`)
- Optional `identifier` is emitted as `PropertyValue` (from `jobId` / `jobIdentifier` / `identifier` / `id` / `slug` / URL slug)
- Optional `hiringOrganization.url` and `hiringOrganization.sameAs` are emitted when present
- `jobLocation.address.streetAddress` can be sourced from `addressText` (front matter shortcut)
- `resolveListingPageName` and `resolveHomeItemListName` are i18n-driven; fallback is always `-`
- Suggested keys: `seo.home.page.name`, `seo.home.page.pageName`, `seo.home.jobs.itemList.name`, `seo.home.jobs.itemList.pageName`, `seo.home.itemList.name`, `seo.home.itemList.pageName`

Example front matter for `schemaType: job-post`:

```yaml
schemaType: job-post
description: "Kısa ama net ilan açıklaması"
jobLocation:
  address:
    addressLocality: "İstanbul"
    addressRegion: "İstanbul"
    addressCountry: "TR"
    addressText: "Levent, Büyükdere Cad. No: 123"
```

### Help / FAQ extras (for `schemaType: help`, `schemaType: faq`, `/help/`, `/faq/`)

- Output is `@graph` (`WebSite` + publisher entity + page node)
- Page node uses `name` (`headline` is not used) and does not include `mainEntityOfPage`
- If FAQ entries are found, page type becomes `["WebPage", "FAQPage"]` and `mainEntity` is emitted as `Question[]`
- FAQ source path can be configured from plugin config:
  - `faqItemsPath`: single dot-path (example: `faq.accordion.items`)
  - `faqItemsPaths`: multiple dot-paths (example: `["faq.accordion.items", "blocks.faq.items"]`)
- `questionCount` limits `mainEntity` count only for `schemaType: help` (`faq` type always lists all questions)
- FAQ sources (front matter / derived front matter):
  - `faqs`, `faq`, `questions`, `qna`, `helpFaqs`
  - each item supports `question|q|name|title` and `answer|a|text|content|description|acceptedAnswer.text`

### Press extras (for `schemaType: press`)

- Output is `@graph` (`WebSite` + publisher entity + `["WebPage","CollectionPage"]`)
- Page node uses `name` and does not include `mainEntityOfPage`
- Press entries are read from front matter / derived front matter sources:
  - configurable path: `newsItemsPath` / `newsItemsPaths` (example: `press.accordion.items`)
  - `pressItems`, `pressList`, `press`, `mediaMentions`, `mediaCoverage`, `news`
- If valid entries are found, `mainEntity` is emitted as `ItemList` with `ListItem -> NewsArticle` items
- Entries pointing to the press page itself / site home are ignored; if no valid press links remain, `mainEntity` is omitted
- Item fields:
  - `headline` (from `headline|title|name`)
  - `url` (from `url|link|href|canonical`)
  - optional `datePublished` (from `datePublished|publishedAt|published|publishDate|date`)
  - optional `publisher` name (from `publisherName|publisher.name|publisher|sourceName|source`)
- ItemList name prefers i18n keys: `seo.press.itemList.name`, `seo.press.itemList.title`

### Collection i18n keys

Recommended grouped structure:

- `seo.collections.tag.description`
- `seo.collections.tag.itemList.name`
- `seo.collections.category.description`
- `seo.collections.category.itemList.name`
- `seo.collections.series.description`
- `seo.collections.series.itemList.name`
- `seo.collections.default.itemList.name` (optional fallback)

Templates can include `{{label}}`.

### Policy Type and i18n (for `schemaType: policy`)

You can classify policy pages in two ways:

- Explicit: set `policyType` (recommended)
- Automatic: configure keyword hints in i18n and let plugin infer from title/slug/category

Supported i18n keys:

- `seo.policy.hints` -> object map (`<type>` -> list of match tokens)
- `seo.policy.genre.<type>` -> `genre` array for that type
- `seo.policy.genre.default` -> fallback `genre`

Example:

```json
{
  "seo": {
    "policy": {
      "hints": {
        "cookie": {
          "tr": ["çerez", "cookies"],
          "en": ["cookie", "cookies"]
        },
        "terms": {
          "tr": ["kullanım koşulları", "kullanim kosullari"],
          "en": ["terms of use", "terms"]
        },
        "disclaimer": {
          "tr": ["sorumluluk reddi"],
          "en": ["disclaimer"]
        }
      },
      "genre": {
        "cookie": {
          "tr": ["Çerez Politikası", "Hukuki"],
          "en": ["Cookie Policy", "Legal"]
        },
        "terms": {
          "tr": ["Kullanım Koşulları", "Hukuki"],
          "en": ["Terms of Use", "Legal"]
        },
        "disclaimer": {
          "tr": ["Sorumluluk Reddi", "Hukuki"],
          "en": ["Disclaimer", "Legal"]
        },
        "default": {
          "tr": ["Politika", "Hukuki"],
          "en": ["Policy", "Legal"]
        }
      }
    }
  }
}
```

Example:

```yaml
---
lang: en
title: NVIDIA Jetson Orin Nano Setup
slug: nvidia-jetson-orin-nano-setup
schemaType: post
template: post
cover: /assets/images/jetson-orin-nano-super-developer-kit.webp
description: Step-by-step setup guide.
date: 2026-01-14
updated: 2026-01-15
keywords:
  - jetson
  - jetpack
---
```

## Config Options

- `twitterSite`: X/Twitter handle for `twitter:site`
- `defaultTwitterCard`: default card type (`summary_large_image`)
- `defaultImage`: fallback image when page has no cover
- `siteName`: optional override for `og:site_name`
- `publisherType`: `person` (default) or `organization`
- `jobCategoryTokens`: keyword list for category/type based job detection
- `jobUrlTokens`: keyword list for URL/slug based job detection
- `faqItemsPath` / `faqItemsPaths`: dot-path to FAQ item list in front matter (`faq.accordion.items`)
- `newsItemsPath` / `newsItemsPaths`: dot-path to press/news item list in front matter (`press.accordion.items`)
- `questionCount`: max FAQ item count for `schemaType: help` (default `-1`, no limit)
- `jobValidThroughDays`: fallback day count for `JobPosting.validThrough` when no explicit end date (default `60`)
- `localityCountryTokens`: tokens removed from `addressLocality` to keep city-only value
- `force`: overwrite existing front matter meta fields
- `exposePageMeta`: expose computed object as `page.pageMeta`
- `enableSearchAction`: force enable/disable `SearchAction` on home graph
- `searchActionTarget`: URL template for search action
- `searchActionQueryInput`: query-input string for `SearchAction`

For `publisherType: organization`, source fields are fixed:

- `Organization.name` <- `identity.author`
- `Organization.url` <- `identity.url`
- `Organization.logo` <- `identity.profileImage`
- `Organization.sameAs` <- `identity.social.*`

## Notes

- Core should stay focused on markdown-to-html. This plugin handles SEO meta generation.
- If plugin is not used, only front-matter-driven meta fields are available.

## License

MIT