diff --git a/README.md b/README.md
index 1aec6878..a0885234 100644
--- a/README.md
+++ b/README.md
@@ -314,6 +314,7 @@ disable_title = false
[paige.page]
disable_alert = false
disable_authors = false
+disable_auto_schema = false # Disable the automatic SEO JSON-LD schema generation
disable_date = false
disable_description = false
disable_edit = false
@@ -326,7 +327,44 @@ disable_series = false
disable_title = false
disable_toc = false
+# paige.page.base_schema specifies the JSON-LD schema that all page schemas override.
+#
+# Example:
+#
+# [paige.page.base_schema]
+# isAccessibleForFree = true
+# isFamilyFriendly = true
+# [paige.page.base_schema.publisher]
+# "@type" = "Organization"
+# name = "John Doe"
+# url = "https://example.com"
+base_schema = {}
+
+# paige.page.schemas is the page JSON-LD schemas.
+#
+# Examples:
+#
+# [paige.page.schemas]
+# "@context" = "https://schema.org"
+# "@type" = "Book"
+# name = "My Book"
+# url = "https://example.com"
+schemas = []
+
[paige.site]
+# paige.site.base_schema specifies the JSON-LD schema that all site schemas override.
+#
+# Example:
+#
+# [paige.site.base_schema]
+# isAccessibleForFree = true
+# isFamilyFriendly = true
+# [paige.site.base_schema.publisher]
+# "@type" = "Organization"
+# name = "John Doe"
+# url = "https://example.com"
+base_schema = {}
+
disable_breadcrumbs = false
disable_copyright = false
disable_credit = false
@@ -335,6 +373,17 @@ disable_license = false
disable_menu = false
disable_title = false
+# paige.site.schemas is the site JSON-LD schemas.
+#
+# Examples:
+#
+# [paige.site.schemas]
+# "@context" = "https://schema.org"
+# "@type" = "Organization"
+# name = "Acme"
+# url = "https://example.com"
+schemas = []
+
[paige.search]
disable = false # Exclude the page from search
```
@@ -392,6 +441,18 @@ render = "never"
The `email` and `url` parameters in the front matter of an author term page are used in feeds if present.
+### SEO
+
+The "author", "description", and "keywords" meta tags are generated from the page parameters.
+The keywords are the page parameters "keywords", "categories", and "tags".
+
+A JSON-LD script is generated from the page parameters,
+which can be disabled with `paige.page.disable_auto_schema`.
+Arbitrary schemas can be specified for the site with `paige.site.schemas`
+or for pages with `paige.pages.schemas`, at the site or page level.
+A base schema can be specified for site schemas with `paige.site.base_schema`,
+and for page schemas with `paige.page.base_schema`.
+
## Layouts
### Cloud
diff --git a/layouts/partials/paige/schemas.html b/layouts/partials/paige/schemas.html
new file mode 100644
index 00000000..8b7f2bb1
--- /dev/null
+++ b/layouts/partials/paige/schemas.html
@@ -0,0 +1,154 @@
+{{ $page := . }}
+
+{{ $auto := $page.Param "paige.page.disable_auto_schema" | not }}
+{{ $pagebase := $page.Param "paige.page.base_schema" }}
+{{ $sitebase := $page.Param "paige.site.base_schema" }}
+
+{{ range $page.Param "paige.site.schemas" }}
+
+{{ end }}
+
+{{ range $page.Param "paige.page.schemas" }}
+
+{{ end }}
+
+{{ if $auto }}
+ {{ $audios := $page.Params.audio | default slice}}
+ {{ $authors := $page.GetTerms "authors" }}
+ {{ $images := $page.Params.images | default slice }}
+ {{ $keywords := sort (union (union $page.Keywords $page.Params.tags) $page.Params.categories) }}
+ {{ $license := $page.Param "paige.license" | markdownify | plainify }}
+ {{ $schema := newScratch }}
+ {{ $videos := $page.Params.videos | default slice }}
+
+ {{ $schema.Set "@context" "https://schema.org" }}
+ {{ $schema.Set "@type" "Article" }}
+ {{ $schema.Set "url" $page.Permalink }}
+
+ {{ with $page.Summary }}
+ {{ $schema.Set "abstract" (. | plainify | chomp) }}
+ {{ end }}
+
+ {{ with $page.Description }}
+ {{ $schema.Set "alternativeHeadline" (plainify .) }}
+ {{ end }}
+
+ {{ with $page.CurrentSection.Title }}
+ {{ $schema.Set "articleSection" . }}
+ {{ end }}
+
+ {{ with $audios }}
+ {{ $audioObjects := slice }}
+
+ {{ range $audios }}
+ {{ $audioObjects = $audioObjects | append (dict "@type" "AudioObject" "url" .) }}
+ {{ end }}
+
+ {{ $schema.Set "audio" $audioObjects }}
+ {{ end }}
+
+ {{ with $authors }}
+ {{ $authorObjects := slice }}
+
+ {{ range $authors }}
+ {{ $authoremail := cond (. | not | not) .Params.email "" }}
+ {{ $authorname := cond (. | not | not) .Params.name "" }}
+ {{ $authorurl := cond (. | not | not) .Params.url "" }}
+
+ {{ if or $authoremail $authorname $authorurl }}
+ {{ $authorSchema := newScratch }}
+
+ {{ with $authoremail }}
+ {{ $authorSchema.Set "email" . }}
+ {{ end }}
+
+ {{ with $authorname }}
+ {{ $authorSchema.Set "name" . }}
+ {{ end }}
+
+ {{ with $authorurl }}
+ {{ $authorSchema.Set "url" . }}
+ {{ end }}
+
+ {{ $authorSchema.Set "@type" "Person" }}
+ {{ $authorObjects = $authorObjects | append $authorSchema.Values }}
+ {{ end }}
+ {{ end }}
+
+ {{ $schema.Set "author" $authorObjects }}
+ {{ end }}
+
+ {{ with site.Copyright }}
+ {{ $schema.Set "copyrightNotice" (plainify .) }}
+ {{ end }}
+
+ {{ if and (not $page.Date.IsZero) (ne $page.Date $page.PublishDate) }}
+ {{ $schema.Set "dateCreated" $page.Date }}
+ {{ end }}
+
+ {{ if not $page.Lastmod.IsZero }}
+ {{ $schema.Set "dateModified" $page.Lastmod }}
+ {{ end }}
+
+ {{ with and (not $page.PublishDate.IsZero) (ne $page.Date $page.PublishDate) }}
+ {{ $schema.Set "datePublished" $page.PublishDate }}
+ {{ end }}
+
+ {{ with $page.Description }}
+ {{ $schema.Set "description" (plainify .) }}
+ {{ end }}
+
+ {{ if not $page.ExpiryDate.IsZero }}
+ {{ $schema.Set "expiresAt" $page.ExpiryDate }}
+ {{ end }}
+
+ {{ with $page.Title }}
+ {{ $schema.Set "headline" (plainify .) }}
+ {{ end }}
+
+ {{ with $images }}
+ {{ $imageObjects := slice }}
+
+ {{ range $images }}
+ {{ $imageObjects = $imageObjects | append (dict "@type" "ImageObject" "url" .) }}
+ {{ end }}
+
+ {{ $schema.Set "image" $imageObjects }}
+ {{ end }}
+
+ {{ with site.Language }}
+ {{ $schema.Set "inLanguage" .Lang }}
+ {{ end }}
+
+ {{ with $keywords }}
+ {{ $schema.Set "keywords" . }}
+ {{ end }}
+
+ {{ with $license }}
+ {{ $schema.Set "license" (plainify .) }}
+ {{ end }}
+
+ {{ with $page.Title }}
+ {{ $schema.Set "name" (plainify .) }}
+ {{ end }}
+
+ {{ with $page.ReadingTime }}
+ {{ $schema.Set "timeRequired" (printf "PT%dM" .) }}
+ {{ end }}
+
+ {{ with $videos }}
+ {{ $videoObjects := slice }}
+
+ {{ range $videos }}
+ {{ $videoObjects = $videoObjects | append (dict "@type" "VideoObject" "url" .) }}
+ {{ end }}
+
+ {{ $schema.Set "video" $videoObjects }}
+ {{ end }}
+
+ {{ with $page.WordCount }}
+ {{ $schema.Set "wordCount" . }}
+ {{ end }}
+
+
+{{ end }}
diff --git a/layouts/partials/paige/scripts.html b/layouts/partials/paige/scripts.html
index 0387444f..a4be8213 100644
--- a/layouts/partials/paige/scripts.html
+++ b/layouts/partials/paige/scripts.html
@@ -8,5 +8,6 @@
{{ end }}
{{ partial "paige/analytics.html" $page }}
+{{ partial "paige/schemas.html" $page }}