Merge pull request #95 from dillonkearns/structured-data

MVP for Structured Data (json-ld) to expand SEO API
This commit is contained in:
Dillon Kearns 2020-05-10 17:29:10 -07:00 committed by GitHub
commit 03e434538e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 342 additions and 31 deletions

View File

@ -19,6 +19,7 @@ import Html exposing (Html)
import Html.Attributes as Attr
import Index
import Json.Decode as Decode exposing (Decoder)
import Json.Encode
import MarkdownRenderer
import Metadata exposing (Metadata)
import MySitemap
@ -35,6 +36,7 @@ import Pages.StaticHttp as StaticHttp
import Palette
import Secrets
import Showcase
import StructuredData
manifest : Manifest.Config Pages.PathKey
@ -168,7 +170,7 @@ view siteMetadata page =
]
}
|> wrapBody stars page model
, head = head page.frontmatter
, head = head page.path page.frontmatter
}
)
(StaticHttp.get (Secrets.succeed "https://api.github.com/repos/dillonkearns/elm-pages")
@ -185,7 +187,7 @@ view siteMetadata page =
\model viewForPage ->
pageView stars model siteMetadata page viewForPage
|> wrapBody stars page model
, head = head page.frontmatter
, head = head page.path page.frontmatter
}
)
@ -470,8 +472,8 @@ commonHeadTags =
<https://html.spec.whatwg.org/multipage/semantics.html#standard-metadata-names>
<https://ogp.me/>
-}
head : Metadata -> List (Head.Tag Pages.PathKey)
head metadata =
head : PagePath Pages.PathKey -> Metadata -> List (Head.Tag Pages.PathKey)
head currentPath metadata =
commonHeadTags
++ (case metadata of
Metadata.Page meta ->
@ -507,26 +509,45 @@ head metadata =
|> Seo.website
Metadata.Article meta ->
Seo.summaryLarge
{ canonicalUrlOverride = Nothing
, siteName = "elm-pages"
, image =
{ url = meta.image
, alt = meta.description
, dimensions = Nothing
, mimeType = Nothing
}
, description = meta.description
, locale = Nothing
, title = meta.title
}
|> Seo.article
{ tags = []
, section = Nothing
, publishedTime = Just (Date.toIsoString meta.published)
, modifiedTime = Nothing
, expirationTime = Nothing
Head.structuredData
(StructuredData.article
{ title = meta.title
, description = meta.description
, author = StructuredData.person { name = meta.author.name }
, publisher = StructuredData.person { name = "Dillon Kearns" }
, url = canonicalSiteUrl ++ "/" ++ PagePath.toString currentPath
, imageUrl = canonicalSiteUrl ++ "/" ++ ImagePath.toString meta.image
, datePublished = Date.toIsoString meta.published
, mainEntityOfPage =
StructuredData.softwareSourceCode
{ codeRepositoryUrl = "https://github.com/dillonkearns/elm-pages"
, description = "A statically typed site generator for Elm."
, author = "Dillon Kearns"
, programmingLanguage = StructuredData.elmLang
}
}
)
:: (Seo.summaryLarge
{ canonicalUrlOverride = Nothing
, siteName = "elm-pages"
, image =
{ url = meta.image
, alt = meta.description
, dimensions = Nothing
, mimeType = Nothing
}
, description = meta.description
, locale = Nothing
, title = meta.title
}
|> Seo.article
{ tags = []
, section = Nothing
, publishedTime = Just (Date.toIsoString meta.published)
, modifiedTime = Nothing
, expirationTime = Nothing
}
)
Metadata.Author meta ->
let

View File

@ -51,9 +51,10 @@ function loadContentAndInitializeApp(/** @type { init: any } */ mainElmModule)
});
app.ports.toJsPort.subscribe((
/** @type { { head: HeadTag[], allRoutes: string[] } } */ fromElm
/** @type { { head: SeoTag[], allRoutes: string[] } } */ fromElm
) => {
appendTag({
type: 'head',
name: "meta",
attributes: [
["name", "generator"],
@ -65,7 +66,13 @@ function loadContentAndInitializeApp(/** @type { init: any } */ mainElmModule)
if (navigator.userAgent.indexOf("Headless") >= 0) {
fromElm.head.forEach(headTag => {
appendTag(headTag);
if (headTag.type === 'head') {
appendTag(headTag);
} else if (headTag.type === 'json-ld') {
appendJsonLdTag(headTag);
} else {
throw new Error(`Unknown tag type #{headTag}`)
}
});
headTagsAdded = true;
if (elmViewRendered) {
@ -209,7 +216,9 @@ function prefetchIfNeeded(/** @type {HTMLAnchorElement} */ target) {
}
}
/** @typedef {{ name: string; attributes: string[][]; }} HeadTag */
/** @typedef {HeadTag | JsonLdTag} SeoTag */
/** @typedef {{ name: string; attributes: string[][]; type: 'head' }} HeadTag */
function appendTag(/** @type {HeadTag} */ tagDetails) {
const meta = document.createElement(tagDetails.name);
tagDetails.attributes.forEach(([name, value]) => {
@ -218,6 +227,14 @@ function appendTag(/** @type {HeadTag} */ tagDetails) {
document.getElementsByTagName("head")[0].appendChild(meta);
}
/** @typedef {{ contents: Object; type: 'json-ld' }} JsonLdTag */
function appendJsonLdTag(/** @type {JsonLdTag} */ tagDetails) {
let jsonLdScript = document.createElement('script');
jsonLdScript.type = "application/ld+json";
jsonLdScript.innerHTML = JSON.stringify(tagDetails.contents);
document.getElementsByTagName("head")[0].appendChild(jsonLdScript);
}
function httpGet(/** @type string */ theUrl) {
return new Promise(function (resolve, reject) {
const xmlHttp = new XMLHttpRequest();

View File

@ -1,6 +1,7 @@
module Head exposing
( Tag, metaName, metaProperty
, rssLink, sitemapLink
, structuredData
, AttributeValue
, currentPageFullUrl, fullImageUrl, fullPageUrl, raw
, toJson, canonicalLink
@ -19,6 +20,11 @@ writing a plugin package to extend `elm-pages`.
@docs rssLink, sitemapLink
## Structured Data
@docs structuredData
## `AttributeValue`s
@docs AttributeValue
@ -42,6 +48,7 @@ through the `head` function.
-}
type Tag pathKey
= Tag (Details pathKey)
| StructuredData Json.Encode.Value
type alias Details pathKey =
@ -50,6 +57,29 @@ type alias Details pathKey =
}
{-| Take a look at this [Google Search Gallery](https://developers.google.com/search/docs/guides/search-gallery)
to see some examples of how structured data can be used by search engines to give rich search results. It can help boost
your rankings, get better engagement for your content, and also make your content more accessible. For example,
voice assistant devices can make use of structured data. If you're hosting a conference and want to make the event
date and location easy for attendees to find, this can make that information more accessible.
For the current version of API, you'll need to make sure that the format is correct and contains the required and recommended
structure.
Check out <https://schema.org> for a comprehensive listing of possible data types and fields. And take a look at
Google's [Structured Data Testing Tool](https://search.google.com/structured-data/testing-tool)
too make sure that your structured data is valid and includes the recommended values.
In the future, `elm-pages` will likely support a typed API, but schema.org is a massive spec, and changes frequently.
And there are multiple sources of information on the possible and recommended structure. So it will take some time
for the right API design to evolve. In the meantime, this allows you to make use of this for SEO purposes.
-}
structuredData : Json.Encode.Value -> Tag pathKey
structuredData value =
StructuredData value
{-| Create a raw `AttributeValue` (as opposed to some kind of absolute URL).
-}
raw : String -> AttributeValue pathKey
@ -196,11 +226,20 @@ node name attributes =
code will run this for you to generate your `manifest.json` file automatically!
-}
toJson : String -> String -> Tag pathKey -> Json.Encode.Value
toJson canonicalSiteUrl currentPagePath (Tag tag) =
Json.Encode.object
[ ( "name", Json.Encode.string tag.name )
, ( "attributes", Json.Encode.list (encodeProperty canonicalSiteUrl currentPagePath) tag.attributes )
]
toJson canonicalSiteUrl currentPagePath tag =
case tag of
Tag headTag ->
Json.Encode.object
[ ( "name", Json.Encode.string headTag.name )
, ( "attributes", Json.Encode.list (encodeProperty canonicalSiteUrl currentPagePath) headTag.attributes )
, ( "type", Json.Encode.string "head" )
]
StructuredData value ->
Json.Encode.object
[ ( "contents", value )
, ( "type", Json.Encode.string "json-ld" )
]
encodeProperty : String -> String -> ( String, AttributeValue pathKey ) -> Json.Encode.Value

234
src/StructuredData.elm Normal file
View File

@ -0,0 +1,234 @@
module StructuredData exposing (..)
import Json.Encode as Encode
{-| <https://schema.org/SoftwareSourceCode>
-}
softwareSourceCode :
{ codeRepositoryUrl : String
, description : String
, author : String
, programmingLanguage : Encode.Value
}
-> Encode.Value
softwareSourceCode info =
Encode.object
[ ( "@type", Encode.string "SoftwareSourceCode" )
, ( "codeRepository", Encode.string info.codeRepositoryUrl )
, ( "description", Encode.string info.description )
, ( "author", Encode.string info.author )
, ( "programmingLanguage", info.programmingLanguage )
]
{-| <https://schema.org/ComputerLanguage>
-}
computerLanguage : { url : String, name : String, imageUrl : String, identifier : String } -> Encode.Value
computerLanguage info =
Encode.object
[ ( "@type", Encode.string "ComputerLanguage" )
, ( "url", Encode.string info.url )
, ( "name", Encode.string info.name )
, ( "image", Encode.string info.imageUrl )
, ( "identifier", Encode.string info.identifier )
]
elmLang : Encode.Value
elmLang =
computerLanguage
{ url = "http://elm-lang.org/"
, name = "Elm"
, imageUrl = "http://elm-lang.org/"
, identifier = "http://elm-lang.org/"
}
{-| <https://schema.org/Article>
-}
article :
{ title : String
, description : String
, author : StructuredData { authorMemberOf | personOrOrganization : () } authorPossibleFields
, publisher : StructuredData { publisherMemberOf | personOrOrganization : () } publisherPossibleFields
, url : String
, imageUrl : String
, datePublished : String
, mainEntityOfPage : Encode.Value
}
-> Encode.Value
article info =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "Article" )
, ( "headline", Encode.string info.title )
, ( "description", Encode.string info.description )
, ( "image", Encode.string info.imageUrl )
, ( "author", encode info.author )
, ( "publisher", encode info.publisher )
, ( "url", Encode.string info.url )
, ( "datePublished", Encode.string info.datePublished )
, ( "mainEntityOfPage", info.mainEntityOfPage )
]
type StructuredData memberOf possibleFields
= StructuredData String (List ( String, Encode.Value ))
{-| <https://schema.org/Person>
-}
person :
{ name : String
}
->
StructuredData { personOrOrganization : () }
{ additionalName : ()
, address : ()
, affiliation : ()
}
person info =
StructuredData "Person" [ ( "name", Encode.string info.name ) ]
additionalName : String -> StructuredData memberOf { possibleFields | additionalName : () } -> StructuredData memberOf possibleFields
additionalName value (StructuredData typeName fields) =
StructuredData typeName (( "additionalName", Encode.string value ) :: fields)
{-| <https://schema.org/Article>
-}
article_ :
{ title : String
, description : String
, author : String
, publisher : StructuredData { personOrOrganization : () } possibleFieldsPublisher
, url : String
, imageUrl : String
, datePublished : String
, mainEntityOfPage : Encode.Value
}
-> Encode.Value
article_ info =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "Article" )
, ( "headline", Encode.string info.title )
, ( "description", Encode.string info.description )
, ( "image", Encode.string info.imageUrl )
, ( "author", Encode.string info.author )
, ( "publisher", encode info.publisher )
, ( "url", Encode.string info.url )
, ( "datePublished", Encode.string info.datePublished )
, ( "mainEntityOfPage", info.mainEntityOfPage )
]
encode : StructuredData memberOf possibleFieldsPublisher -> Encode.Value
encode (StructuredData typeName fields) =
Encode.object
(( "@type", Encode.string typeName ) :: fields)
--example : StructuredData { personOrOrganization : () } { address : (), affiliation : () }
example =
person { name = "Dillon Kearns" }
|> additionalName "Cornelius"
--organization :
-- {}
-- -> StructuredData { personOrOrganization : () }
--organization info =
-- StructuredData "Organization" []
--needsPersonOrOrg : StructuredData {}
--needsPersonOrOrg =
-- StructuredData "" []
{-|
```json
{
"@context": "http://schema.org/",
"@type": "PodcastSeries",
"image": "https://www.relay.fm/inquisitive_artwork.png",
"url": "http://www.relay.fm/inquisitive",
"name": "Inquisitive",
"description": "Inquisitive is a show for the naturally curious. Each week, Myke Hurley takes a look at what makes creative people successful and what steps they have taken to get there.",
"webFeed": "http://www.relay.fm//inquisitive/feed",
"author": {
"@type": "Person",
"name": "Myke Hurley"
}
}
}
```
-}
series : Encode.Value
series =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "PodcastSeries" )
, ( "image", Encode.string "TODO" )
, ( "url", Encode.string "http://elm-radio.com/episode/getting-started-with-elm-pages" )
, ( "name", Encode.string "Elm Radio" )
, ( "description", Encode.string "TODO" )
, ( "webFeed", Encode.string "https://elm-radio.com/feed.xml" )
]
{-|
```json
{
"@context": "http://schema.org/",
"@type": "PodcastEpisode",
"url": "http://elm-radio.com/episode/getting-started-with-elm-pages",
"name": "001: Getting Started with elm-pages",
"datePublished": "2015-02-18",
"timeRequired": "PT37M",
"description": "In the first episode of Behind the App, a special series of Inquisitive, we take a look at the beginnings of iOS app development, by focusing on the introduction of the iPhone and the App Store.",
"associatedMedia": {
"@type": "MediaObject",
"contentUrl": "https://cdn.simplecast.com/audio/6a206b/6a206baa-9c8e-4c25-9037-2b674204ba84/ca009f6e-1710-4518-b869-ca34cb0b7d17/001-getting-started-elm-pages_tc.mp3 "
},
"partOfSeries": {
"@type": "PodcastSeries",
"name": "Elm Radio",
"url": "https://elm-radio.com"
}
}
```
-}
episode : Encode.Value
episode =
Encode.object
[ ( "@context", Encode.string "http://schema.org/" )
, ( "@type", Encode.string "PodcastEpisode" )
, ( "url", Encode.string "http://elm-radio.com/episode/getting-started-with-elm-pages" )
, ( "name", Encode.string "Getting Started with elm-pages" )
, ( "datePublished", Encode.string "2015-02-18" )
, ( "timeRequired", Encode.string "PT37M" )
, ( "description", Encode.string "TODO" )
, ( "associatedMedia"
, Encode.object
[ ( "@type", Encode.string "MediaObject" )
, ( "contentUrl", Encode.string "https://cdn.simplecast.com/audio/6a206b/6a206baa-9c8e-4c25-9037-2b674204ba84/ca009f6e-1710-4518-b869-ca34cb0b7d17/001-getting-started-elm-pages_tc.mp3" )
]
)
, ( "partOfSeries"
, Encode.object
[ ( "@type", Encode.string "PodcastSeries" )
, ( "name", Encode.string "Elm Radio" )
, ( "url", Encode.string "https://elm-radio.com" )
]
)
]