mirror of
https://github.com/dillonkearns/elm-pages-v3-beta.git
synced 2024-11-28 14:34:18 +03:00
Merge pull request #95 from dillonkearns/structured-data
MVP for Structured Data (json-ld) to expand SEO API
This commit is contained in:
commit
03e434538e
@ -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
|
||||
|
23
index.js
23
index.js
@ -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();
|
||||
|
49
src/Head.elm
49
src/Head.elm
@ -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
234
src/StructuredData.elm
Normal 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" )
|
||||
]
|
||||
)
|
||||
]
|
Loading…
Reference in New Issue
Block a user