diff --git a/include/filter/locals.js b/include/filter/locals.js index afe9c03..059e1ba 100644 --- a/include/filter/locals.js +++ b/include/filter/locals.js @@ -18,7 +18,7 @@ module.exports = hexo => { const ALTERNATIVE_CONFIG = { post: getThemeConfig('.post'), page: getThemeConfig('.page') - } + }; function getExtraConfig(source, reservedKeys) { const result = {}; @@ -48,7 +48,7 @@ module.exports = hexo => { if (page) { if ((page.layout !== 'page' || page.layout !== 'post') && ALTERNATIVE_CONFIG[page.layout]) { // load alternative config if exists - locals.config = Object.assign({}, Object.getPrototypeOf(locals).config, ALTERNATIVE_CONFIG[page.layout]) + locals.config = Object.assign({}, Object.getPrototypeOf(locals).config, ALTERNATIVE_CONFIG[page.layout]); } else { // site config already merged into theme config in hexo/lib/hexo/index.js#Hexo.prototype._generateLocals() locals.config = Object.assign({}, Object.getPrototypeOf(locals).theme); diff --git a/include/schema/common/head.json b/include/schema/common/head.json index da27ad9..3269314 100644 --- a/include/schema/common/head.json +++ b/include/schema/common/head.json @@ -18,6 +18,9 @@ "open_graph": { "$ref": "/misc/open_graph.json" }, + "structured_data": { + "$ref": "/misc/structured_data.json" + }, "meta": { "$ref": "/misc/meta.json" }, diff --git a/include/schema/misc/open_graph.json b/include/schema/misc/open_graph.json index c508531..28b51d5 100644 --- a/include/schema/misc/open_graph.json +++ b/include/schema/misc/open_graph.json @@ -6,18 +6,18 @@ "properties": { "title": { "type": "string", - "description": "Page title (og:title)", + "description": "Page title (og:title) (optional)\nYou should leave this blank for most of the time", "nullable": true }, "type": { "type": "string", - "description": "Page type (og:type)", + "description": "Page type (og:type) (optional)\nYou should leave this blank for most of the time", "default": "blog", "nullable": true }, "url": { "type": "string", - "description": "Page URL (og:url)", + "description": "Page URL (og:url) (optional)\nYou should leave this blank for most of the time", "nullable": true }, "image": { @@ -25,7 +25,7 @@ "string", "array" ], - "description": "Page cover (og:image)", + "description": "Page cover (og:image) (optional) Default to the Open Graph image or thumbnail of the page\nYou should leave this blank for most of the time", "items": { "type": "string" }, @@ -33,12 +33,17 @@ }, "site_name": { "type": "string", - "description": "Site name (og:site_name)", + "description": "Site name (og:site_name) (optional)\nYou should leave this blank for most of the time", + "nullable": true + }, + "author": { + "type": "string", + "description": "Page author (article:author) (optional)\nYou should leave this blank for most of the time", "nullable": true }, "description": { "type": "string", - "description": "Page description (og:description)", + "description": "Page description (og:description) (optional)\nYou should leave this blank for most of the time", "nullable": true }, "twitter_card": { diff --git a/include/schema/misc/structured_data.json b/include/schema/misc/structured_data.json new file mode 100644 index 0000000..9ea8334 --- /dev/null +++ b/include/schema/misc/structured_data.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/misc/structured_data.json", + "description": "Structured data of the page\nhttps://developers.google.com/search/docs/guides/intro-structured-data", + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Page title (optional)\nYou should leave this blank for most of the time", + "nullable": true + }, + "description": { + "type": "string", + "description": "Page description (optional)\nYou should leave this blank for most of the time", + "nullable": true + }, + "url": { + "type": "string", + "description": "Page URL (optional)\nYou should leave this blank for most of the time", + "nullable": true + }, + "author": { + "type": "string", + "description": "Page author (article:author) (optional)\nYou should leave this blank for most of the time", + "nullable": true + }, + "image": { + "type": [ + "string", + "array" + ], + "description": "Page images (optional) Default to the Open Graph image or thumbnail of the page\nYou should leave this blank for most of the time", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "nullable": true +} \ No newline at end of file diff --git a/layout/common/head.jsx b/layout/common/head.jsx index 9bf26e9..e3fd0f1 100644 --- a/layout/common/head.jsx +++ b/layout/common/head.jsx @@ -1,6 +1,7 @@ const { Component } = require('inferno'); const MetaTags = require('../misc/meta'); const OpenGraph = require('../misc/open_graph'); +const StructuredData = require('../misc/structured_data'); const Plugins = require('./plugins'); function getPageTitle(page, siteTitle, helper) { @@ -39,7 +40,8 @@ module.exports = class extends Component { } = config; const { meta = [], - open_graph, + open_graph = {}, + structured_data = {}, canonical_url, rss, favicon @@ -75,7 +77,24 @@ module.exports = class extends Component { let adsenseClientId = null; if (Array.isArray(config.widgets)) { - adsenseClientId = config.widgets.find(widget => widget.type === 'adsense').client_id; + const widget = config.widgets.find(widget => widget.type === 'adsense'); + if (widget) { + adsenseClientId = widget.client_id; + } + } + + let openGraphImages = images; + if ((Array.isArray(open_graph.image) && open_graph.image.length > 0) || typeof open_graph.image === 'string') { + openGraphImages = open_graph.image; + } else if ((Array.isArray(page.photos) && page.photos.length > 0) || typeof page.photos === 'string') { + openGraphImages = page.photos; + } + + let structuredImages = images; + if ((Array.isArray(structured_data.image) && structured_data.image.length > 0) || typeof structured_data.image === 'string') { + structuredImages = structured_data.image; + } else if ((Array.isArray(page.photos) && page.photos.length > 0) || typeof page.photos === 'string') { + structuredImages = page.photos; } return @@ -86,16 +105,16 @@ module.exports = class extends Component { {getPageTitle(page, config.title, helper)} - {open_graph ? : null} + {typeof structured_data === 'object' ? : null} + {canonical_url ? : null} {rss ? : null} {favicon ? : null} diff --git a/layout/misc/structured_data.jsx b/layout/misc/structured_data.jsx new file mode 100644 index 0000000..bff8de3 --- /dev/null +++ b/layout/misc/structured_data.jsx @@ -0,0 +1,56 @@ +const urlFn = require('url'); +const moment = require('moment'); +const { Component } = require('inferno'); +const { stripHTML, escapeHTML } = require('hexo-util'); + +module.exports = class extends Component { + render() { + const { title, url, author } = this.props; + let { description, images, date, updated } = this.props; + + if (description) { + description = escapeHTML(stripHTML(description).substring(0, 200).trim()) + .replace(/\n/g, ' '); + } + + if (!Array.isArray(images)) { + images = [images]; + } + images = images.map(path => { + if (!urlFn.parse(path).host) { + // resolve `path`'s absolute path relative to current page's url + // `path` can be both absolute (starts with `/`) or relative. + return urlFn.resolve(url, path); + } + return path; + }).filter(url => url.endsWith('.jpg') || url.endsWith('.png') || url.endsWith('.gif')); + + if (date && (moment.isMoment(date) || moment.isDate(date)) && !isNaN(date.valueOf())) { + date = date.toISOString(); + } + + if (updated && (moment.isMoment(updated) || moment.isDate(updated)) && !isNaN(updated.valueOf())) { + updated = updated.toISOString(); + } + + const data = { + '@context': 'https://schema.org', + '@type': 'BlogPosting', + 'mainEntityOfPage': { + '@type': 'WebPage', + '@id': url + }, + 'headline': title, + 'image': images, + 'datePublished': date, + 'dateModified': updated, + 'author': { + '@type': 'Person', + 'name': author + }, + 'description': description + }; + + return ; + } +}; diff --git a/layout/widget/adsense.jsx b/layout/widget/adsense.jsx index 1e4e73c..df3a116 100644 --- a/layout/widget/adsense.jsx +++ b/layout/widget/adsense.jsx @@ -37,5 +37,5 @@ module.exports = cacheComponent(AdSense, 'widget.adsense', props => { title: helper.__('widget.adsense'), clientId: client_id, slotId: slot_id - } -}); \ No newline at end of file + }; +});