Issues (1686)

sources/ElkArte/MetadataIntegrate.php (1 issue)

Labels
Severity
1
<?php
2
3
/**
4
 * This class deals with the generation of Open Graph and Schema.org metadata / microdata
5
 *
6
 * @package   ElkArte Forum
7
 * @copyright ElkArte Forum contributors
8
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
9
 *
10
 * @version 2.0 dev
11
 *
12
 */
13
14
namespace ElkArte;
15
16
use ElkArte\Helper\Util;
17
18
/**
19
 * Class \ElkArte\MetadataIntegrate
20
 *
21
 * OG and Schema functions for creation of microdata
22
 */
23
class MetadataIntegrate
24
{
25
	/** @var array data from the post renderer */
26
	public $data;
27
28
	/** @var array attachment data from AttachmentsDisplay Controller */
29
	public $attachments;
30
31
	/** @var array ila data from AttachmentsDisplay Controller */
32
	public $ila;
33
34
	/**
35
	 * Register Metadata hooks to the system.
36
	 *
37
	 * @return array
38
	 */
39
	public static function register()
40
	{
41
		global $modSettings;
42
43
		if (empty($modSettings['metadata_enabled']))
44
		{
45
			return [];
46
		}
47
48
		// Simply load context with our data which will be consumed by the theme's index.template (if supported)
49
		return array(
50
			// Display
51
			array('integrate_action_display_after', '\\ElkArte\\MetadataIntegrate::prepare_topic_metadata'),
52
			// Board
53
			array('integrate_action_boardindex_after', '\\ElkArte\\MetadataIntegrate::prepare_basic_metadata'),
54
			// MessageIndex
55
			array('integrate_action_messageindex_after', '\\ElkArte\\MetadataIntegrate::prepare_basic_metadata'),
56
		);
57
	}
58
59
	/**
60
	 * Prepares Open Graph and Schema data for use in templates when viewing a specific topic
61
	 *
62
	 * - It will only generate full schema data when the pageindex of the topic is on page 1
63
	 *
64
	 * @param int $start
65
	 */
66
	public static function prepare_topic_metadata($start = -1)
67
	{
68
		global $context;
69
70
		$meta = new self();
71
		$start = $context['start'] ?? $start;
72
73
		// Load in the post data if available
74
		$meta->data = $meta->initPostData($start);
75
		$meta->attachments = $meta->data['attachments'] ?? [];
76
		$meta->ila = $meta->data['ila'] ?? [];
77
78
		// Set the data into context for template use
79
		$meta->setContext();
80
	}
81
82
	/**
83
	 * Prepares Open Graph and Schema data when viewing a message listing or the board index.
84
	 * Currently, this consists of a simple organizational card and OG with description
85
	 */
86
	public static function prepare_basic_metadata()
87
	{
88
		$meta = new self();
89
90
		// Set the data into context for template use
91
		$meta->setContext();
92
	}
93
94
	/*
95
	 * Set what we have created into context for template consumption.
96
	 *
97
	 * The schema data should be output in a template as
98
	 * <script type="application/ld+json">
99
	 * json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
100
	 * </script>
101
	 * OG data is an array of <meta> tags for implosion.
102
	 */
103
	private function setContext()
104
	{
105
		global $context;
106
107
		// Set the data into context for template use
108
		$context['smd_site'] = $this->getSiteSchema();
109
		$context['smd_article'] = $this->getPostSchema();
110
		$context['open_graph'] = $this->getOgData();
111
	}
112
113
	/**
114
	 * When viewing the first page of a topic, will return data from the first post
115
	 * to be used in creating microdata.
116
	 *
117
	 * Requires that display renderer, $context['get_message'], has been set via the Display Controller
118
	 *
119
	 * @param int $start
120
	 * @return array
121
	 */
122
	private function initPostData($start)
123
	{
124
		global $context, $topic;
125
126
		$smd = [];
127
128
		// If this is a topic, and we are on the first page (so we can get first post data)
129
		if (!empty($topic)
130
			&& $start === 0
131
			&& (!empty($context['get_message'][0]) && is_object($context['get_message'][0])))
132
		{
133
			// Grab the first post of the thread to get proper thread author data
134
			$controller = $context['get_message'][0];
135
			$smd = $controller->{$context['get_message'][1]}();
136
137
			// Tell the template to reset, or it will miss the first post!
138
			$context['reset_renderer'] = true;
139
140
			// Create a short body, leaving some very basic html
141
			$smd['raw_body'] = trim(strip_tags($smd['body']));
142
			$smd['html_body'] = trim(strip_tags($smd['body'], '<br><strong><em><blockquote>'));
143
			$smd['html_body'] = str_replace(["\n", "\t"], '', $smd['html_body']);
144
145
			// Strip attributes from any remaining tags
146
			$smd['html_body'] = preg_replace('~<([bse][a-z0-9]*)[^>]*?(/?)>~i', '<$1$2>', $smd['html_body']);
147
			$smd['html_body'] = Util::shorten_html($smd['html_body'], 375);
148
149
			// Create a short plain text description // $context['page_description']
150
			$description = empty($context['description'])
151
				? preg_replace('~\s\s+|&nbsp;|&quot;|&#039;~', ' ', $smd['raw_body'])
152
				: $context['description'];
153
			$smd['description'] = Util::shorten_text($description, 110, true);
154
		}
155
156
		return $smd;
157
	}
158
159
	/**
160
	 * Build and return the schema business card
161
	 *
162
	 * @return array
163
	 */
164
	public function getSiteSchema()
165
	{
166
		global $context, $boardurl, $mbname, $settings;
167
168
		// Snag us a site logo
169
		$logo = $this->getLogo();
170
171
		$slogan = empty($settings['site_slogan']) ? un_htmlspecialchars($mbname) : $settings['site_slogan'];
172
173
		// The sites organizational card
174
		return [
175
			'@context' => 'https://schema.org',
176
			'@type' => 'Organization',
177
			'url' => empty($context['canonical_url']) ? $boardurl : $context['canonical_url'],
178
			'logo' => [
179
				'@type' => 'ImageObject',
180
				'url' => $logo[2],
181
				'width' => $logo[0],
182
				'height' => $logo[1],
183
			],
184
			'name' => un_htmlspecialchars($context['forum_name']),
185
			'slogan' => $slogan,
186
		];
187
	}
188
189
	/**
190
	 * Function to return the sites logo url
191
	 *
192
	 * @return array width, height and html safe logo url
193
	 */
194
	private function getLogo()
195
	{
196
		global $context, $boardurl;
197
198
		// Set in ThemeLoader
199
		if (!empty($context['header_logo_url_html_safe']))
200
		{
201
			$logo = $context['header_logo_url_html_safe'];
202
		}
203
		else
204
		{
205
			$logo = $boardurl . '/mobile.png';
206
		}
207
208
		// This will also cache these values for us
209
		require_once(SUBSDIR . '/Attachments.subs.php');
210
		[$width, $height] = url_image_size(un_htmlspecialchars($logo));
211
212
		return [$width, $height, $logo];
213
	}
214
215
	/**
216
	 * Build and return the article schema.  This is intended for use when displaying  a topic.
217
	 *
218
	 * @return array
219
	 */
220
	public function getPostSchema()
221
	{
222
		global $context, $boardurl, $mbname, $board_info;
223
224
		$smd = [];
225
226
		if (empty($this->data))
227
		{
228
			return $smd;
229
		}
230
231
		$logo = $this->getLogo();
232
		$likes = $this->getLikeCount();
233
		$smd = [
234
			'@context' => 'https://schema.org',
235
			'@type' => 'WebPage',
236
			'url' => $this->data['href'],
237
			'mainEntity' => [
238
				'@type' => 'DiscussionForumPosting',
239
				'@id' => $this->data['href'],
240
				'headline' => $this->getPageTitle(),
241
				'datePublished' => utcTime($this->data['timestamp'], true),
0 ignored issues
show
true of type true is incompatible with the type integer expected by parameter $userAdjust of utcTime(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

241
				'datePublished' => utcTime($this->data['timestamp'], /** @scrutinizer ignore-type */ true),
Loading history...
242
				'dateModified' => empty($this->data['modified']['name']) ? utcTime($this->data['timestamp'], true) : utcTime($this->data['modified']['timestamp'], true),
243
				'url' => $this->data['href'],
244
				'articleSection' => $board_info['name'] ?? '',
245
				'author' => [
246
					'@type' => 'Person',
247
					'name' => $this->data['member']['name'],
248
					'url' => $this->data['member']['href'] ?? ''
249
				],
250
				//num_views
251
				// count(likes)
252
				'interactionStatistic' => [
253
					[
254
						'@type' => 'InteractionCounter',
255
						'interactionType' => 'https://schema.org/ViewAction',
256
						'userInteractionCount' => empty($context['num_views']) ? 0 : $context['num_views'],
257
					],
258
					[
259
						'@type' => 'InteractionCounter',
260
						'interactionType' => 'https://schema.org/CommentAction',
261
						'userInteractionCount' => empty($context['real_num_replies']) ? 0 : $context['real_num_replies'],
262
					],
263
					[
264
						'@type' => 'InteractionCounter',
265
						'interactionType' => 'https://schema.org/LikeAction',
266
						'userInteractionCount' => $likes,
267
					]
268
				],
269
				'articleBody' => $this->data['html_body'],
270
				'wordCount' => str_word_count($this->data['raw_body']),
271
				'publisher' => [
272
					'@type' => 'Organization',
273
					'name' => un_htmlspecialchars($mbname),
274
					'logo' => [
275
						'@type' => 'ImageObject',
276
						'url' => $logo[2],
277
						'width' => $logo[0],
278
						'height' => $logo[1],
279
					],
280
				],
281
			]
282
		];
283
284
		// If the post has any attachments, set an ImageObject
285
		$image = $this->getAttachment();
286
		if (!empty($image))
287
		{
288
			$smd['image'] = $image;
289
		}
290
291
		return $smd;
292
	}
293
294
	/**
295
	 * Checks the post for any attachments to use as an image.  Will use the
296
	 * first below post attachment, failing that the first ILA, failing that nothing
297
	 *
298
	 * @return array
299
	 */
300
	private function getAttachment()
301
	{
302
		global $boardurl;
303
304
		if (empty($this->data))
305
		{
306
			return [];
307
		}
308
309
		// If there are below post attachments, use the first one that is an image
310
		if (!empty($this->data['attachment']))
311
		{
312
			foreach ($this->data['attachment'] as $attachment)
313
			{
314
				if (!isset($attachment['is_image']))
315
				{
316
					continue;
317
				}
318
319
				if (empty($attachment['is_approved']))
320
				{
321
					continue;
322
				}
323
324
				return [
325
					'@type' => 'ImageObject',
326
					'url' => $attachment['href'],
327
					'width' => $attachment['real_width'] ?? 0,
328
					'height' => $attachment['real_height'] ?? 0
329
				];
330
			}
331
		}
332
333
		// Maybe it has an inline image?
334
		if (!empty($this->data['ila']))
335
		{
336
			foreach ($this->data['ila'] as $ila)
337
			{
338
				if (!isset($ila['is_image']))
339
				{
340
					continue;
341
				}
342
343
				if (empty($ila['is_approved']))
344
				{
345
					continue;
346
				}
347
348
				return [
349
					'@type' => 'ImageObject',
350
					'url' => $boardurl . '/index.php?action=dlattach;attach=' . $ila['id'] . ';image',
351
					'width' => $ila['real_width'] ?? 0,
352
					'height' => $ila['real_height'] ?? 0
353
				];
354
			}
355
		}
356
357
		return [];
358
	}
359
360
	/**
361
	 * Function to provide backup page name if none is defined
362
	 *
363
	 * @param string $description
364
	 * @return string html safe title
365
	 */
366
	private function getPageTitle($description = '')
367
	{
368
		global $context;
369
370
		// As long as you are calling this class from the right area, this will be set
371
		if (!empty($context['page_title']))
372
		{
373
			return Util::shorten_text(Util::htmlspecialchars(un_htmlspecialchars($context['page_title'])), 110, true);
374
		}
375
376
		// Otherwise, do the best we can
377
		$description = empty($description) ? $this->getDescription() : $description;
378
379
		return Util::shorten_text(Util::htmlspecialchars(un_htmlspecialchars($description)), 110, true);
380
	}
381
382
	/**
383
	 * Prepares the description for use in Metadata
384
	 *
385
	 * This is typically already generated and is one of
386
	 * - The board description, set in MessageIndex Controller
387
	 * - The topic description, set in Display Controller
388
	 *
389
	 * Failing that will use one of
390
	 * - The page title
391
	 * - The site slogan
392
	 * - The site name
393
	 *
394
	 * @return string html safe description
395
	 */
396
	private function getDescription()
397
	{
398
		global $context, $settings, $mbname;
399
400
		// Supplied one, simply use it.
401
		if (!empty($context['page_description']) || !empty(!empty($context['description'])))
402
		{
403
			return $context['page_description'] ?? $context['description'];
404
		}
405
406
		// Build out a default that makes some sense
407
		if (!empty($this->data['description']))
408
		{
409
			$description = $this->data['description'];
410
		}
411
		else
412
		{
413
			$sitename = un_htmlspecialchars($mbname);
414
415
			// Avoid if possible a description like sitename - Index
416
			if (isset($context['page_title']) && strpos($context['page_title'], (string) $sitename) === 0)
417
			{
418
				$description = $settings['site_slogan'] ?? $context['page_title'];
419
			}
420
			else
421
			{
422
				$description = $context['page_title'] ?? $settings['site_slogan'] ?? $sitename;
423
			}
424
		}
425
426
		return Util::htmlspecialchars($description);
427
	}
428
429
	/**
430
	 * Basic OG Metadata to insert in to the <head></head> element.  See https://ogp.me
431
	 *
432
	 * This will generate *basic* og metadata, suitable for FB/Meta website/post sharing.
433
	 *
434
	 * og:title - The title of your article without any branding (site name)
435
	 * og:type - The type of your object, e.g., "website".
436
	 * og:image - The URL of the image that appears when someone shares the content
437
	 * og:url - The canonical URL of your page.
438
	 * og:site_name - The name which should be displayed for the overall site.
439
	 * og:description - A brief description of the content, usually between 2 and 4 sentences.
440
	 *
441
	 * @return array
442
	 */
443
	public function getOgData()
444
	{
445
		global $context, $boardurl, $mbname, $topic;
446
447
		$description = strip_tags($this->getDescription());
448
		$page_title = $this->getPageTitle();
449
		$logo = $this->getLogo();
450
		$attach = $this->getAttachment();
451
452
		// If on a post page, with attachments, use it vs a site logo
453
		if (isset($attach['url']))
454
		{
455
			$logo[2] = $attach['url'];
456
			$logo[1] = $attach['height'];
457
			$logo[0] = $attach['width'];
458
		}
459
460
		$metaOg = [];
461
		$metaOg['title'] = '<meta property="og:title" content="' . $page_title . '" />';
462
		$metaOg['type'] = '<meta property="og:type" content="' . (empty($topic) ? 'website' : 'article') . '" />';
463
		$metaOg['url'] = '<meta property="og:url" content="' . (empty($context['canonical_url']) ? $boardurl : $context['canonical_url']) . '" />';
464
		$metaOg['image'] = '<meta property="og:image" content="' . $logo[2] . '" />';
465
		$metaOg['image_width'] = '<meta property="og:image:width" content="' . $logo[0] . '" />';
466
		$metaOg['image_height'] = '<meta property="og:image:height" content="' . $logo[1] . '" />';
467
		$metaOg['sitename'] = '<meta property="og:site_name" content="' . Util::htmlspecialchars($mbname) . '" />';
468
		$metaOg['description'] = '<meta property="og:description" content="' . $description . '" />';
469
470
		return $metaOg;
471
	}
472
473
	/**
474
	 * Get the total count of likes.
475
	 *
476
	 * This method calculates the total count of likes from the $context['likes'] array.
477
	 *
478
	 * @return int The total count of likes.
479
	 */
480
	public function getLikeCount()
481
	{
482
		global $context;
483
484
		$total = 0;
485
		if (empty($context['likes']))
486
		{
487
			return $total;
488
		}
489
490
		foreach($context['likes'] as $item) {
491
			$total += $item['count'];
492
		}
493
494
		return $total;
495
	}
496
}
497