509 lines
15 KiB
Text
509 lines
15 KiB
Text
|
<?php namespace ProcessWire;
|
|||
|
|
|||
|
/**
|
|||
|
* ProcessWire Markup RSS Module
|
|||
|
*
|
|||
|
* Given a PageArray of pages, this module will render an RSS feed from them.
|
|||
|
* This is intended to be used directly from a template file. See usage below.
|
|||
|
*
|
|||
|
* USAGE
|
|||
|
* ~~~~~~
|
|||
|
* $rss = $modules->get('MarkupRSS');
|
|||
|
* $rss->setArray([ // specify RSS feed settings
|
|||
|
* 'title' => 'Latest updates',
|
|||
|
* 'description' => 'The most recent pages updated on my site',
|
|||
|
* 'itemTitleField' => 'title',
|
|||
|
* 'itemDateField' => 'created', // date field or 'created', 'published' or 'modified'
|
|||
|
* 'itemDescriptionField' => 'summary',
|
|||
|
* 'itemDescriptionLength' => 1000, // truncate description to this max length
|
|||
|
* 'itemContentField' => 'body', // optional HTML full-content, or omit to exclude
|
|||
|
* 'itemAuthorField' => 'author', // optional text or Page field containing author(s)
|
|||
|
* ]);
|
|||
|
* $items = $pages->find('limit=10, sort=-modified'); // or any pages you want
|
|||
|
* $rss->render($items);
|
|||
|
* exit; // exit now, or if you don’t then at least stop sending further output
|
|||
|
* ~~~~~~
|
|||
|
*
|
|||
|
* See also the $defaultConfigData below (first thing in the class) to see what
|
|||
|
* options you can change at runtime.
|
|||
|
*
|
|||
|
*
|
|||
|
* ProcessWire 3.x, Copyright 2021 by Ryan Cramer
|
|||
|
* https://processwire.com
|
|||
|
*
|
|||
|
* @property string $title
|
|||
|
* @property string $url
|
|||
|
* @property string $description
|
|||
|
* @property string $xsl
|
|||
|
* @property string $css
|
|||
|
* @property string $copyright
|
|||
|
* @property int $ttl
|
|||
|
* @property bool $stripTags
|
|||
|
* @property string $itemTitleField
|
|||
|
* @property string $itemContentField
|
|||
|
* @property string $itemDateField
|
|||
|
* @property string $itemDescriptionField
|
|||
|
* @property string $itemDescriptionLength
|
|||
|
* @property string $itemAuthorField
|
|||
|
* @property string $itemAuthorElement
|
|||
|
* @property string $header
|
|||
|
* @property array|PageArray $feedPages
|
|||
|
*
|
|||
|
*
|
|||
|
*/
|
|||
|
|
|||
|
class MarkupRSS extends WireData implements Module, ConfigurableModule {
|
|||
|
|
|||
|
/**
|
|||
|
* Return general info about the module for ProcessWire
|
|||
|
*
|
|||
|
*/
|
|||
|
public static function getModuleInfo() {
|
|||
|
return array(
|
|||
|
'title' => 'Markup RSS Feed',
|
|||
|
'version' => 104,
|
|||
|
'summary' => 'Renders an RSS feed. Given a PageArray, renders an RSS feed of them.',
|
|||
|
'icon' => 'rss-square',
|
|||
|
);
|
|||
|
}
|
|||
|
|
|||
|
protected static $defaultConfigData = array(
|
|||
|
'title' => 'Untitled RSS Feed',
|
|||
|
'url' => '',
|
|||
|
'description' => '',
|
|||
|
'xsl' => '',
|
|||
|
'css' => '',
|
|||
|
'copyright' => '',
|
|||
|
'ttl' => 60,
|
|||
|
'stripTags' => true,
|
|||
|
'itemTitleField' => 'title',
|
|||
|
'itemContentField' => '', // for <content:encoded>
|
|||
|
'itemDescriptionField' => 'summary',
|
|||
|
'itemDescriptionLength' => 1024,
|
|||
|
'itemDateField' => 'created',
|
|||
|
'itemAuthorField' => '', // i.e. createdUser.title or leave blank to not use
|
|||
|
'itemAuthorElement' => 'dc:creator', // may be 'dc:creator' or 'author' (author if email address)
|
|||
|
'header' => 'Content-Type: application/xml; charset=utf-8;',
|
|||
|
'feedPages' => array(),
|
|||
|
);
|
|||
|
|
|||
|
/**
|
|||
|
* Set the default config data
|
|||
|
*
|
|||
|
*/
|
|||
|
public function __construct() {
|
|||
|
parent::__construct();
|
|||
|
$this->setArray(self::$defaultConfigData);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Module init
|
|||
|
*
|
|||
|
*/
|
|||
|
public function init() { }
|
|||
|
|
|||
|
/**
|
|||
|
* @param string $str
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function ent1($str) {
|
|||
|
if(strpos($str, '&') !== false) $str = $this->wire()->sanitizer->unentities($str, true);
|
|||
|
return $this->ent($str);
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* @param string $str
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function ent($str) {
|
|||
|
$str = htmlspecialchars($str, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
|||
|
$str = strtr($str, array(
|
|||
|
// https://validator.w3.org/feed/
|
|||
|
// recommends using hexadecimal entities here
|
|||
|
'>' => '>',
|
|||
|
'<' => '<',
|
|||
|
'&' => '&',
|
|||
|
'"' => '"',
|
|||
|
''' => ''',
|
|||
|
''' => ''',
|
|||
|
));
|
|||
|
return $str;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Render RSS header
|
|||
|
*
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function renderHeader() {
|
|||
|
|
|||
|
if(!$this->url) $this->url = $this->page->httpUrl;
|
|||
|
|
|||
|
$xsl = $this->ent1($this->xsl);
|
|||
|
$css = $this->ent1($this->css);
|
|||
|
$title = $this->ent1($this->title);
|
|||
|
$url = $this->ent1($this->url);
|
|||
|
$description = $this->ent1($this->description);
|
|||
|
$pubDate = date(\DATE_RSS);
|
|||
|
$ttl = (int) $this->ttl;
|
|||
|
$copyright = $this->ent1($this->copyright);
|
|||
|
|
|||
|
$out = '<?xml version="1.0" encoding="utf-8" ?>' . "\n";
|
|||
|
if($xsl) $out .= "<?xml-stylesheet type='text/xsl' href='$xsl' ?>\n";
|
|||
|
if($css) $out .= "<?xml-stylesheet type='text/css' href='$css' ?>\n";
|
|||
|
|
|||
|
$xmlns = array(
|
|||
|
'xmlns:atom="http://www.w3.org/2005/Atom"',
|
|||
|
'xmlns:dc="http://purl.org/dc/elements/1.1/"'
|
|||
|
);
|
|||
|
if($this->itemContentField) {
|
|||
|
$xmlns[] = 'xmlns:content="http://purl.org/rss/1.0/modules/content/"';
|
|||
|
}
|
|||
|
$xmlns = implode(' ', $xmlns);
|
|||
|
|
|||
|
$out .=
|
|||
|
"<rss version=\"2.0\" $xmlns>\n" .
|
|||
|
"<channel>\n" .
|
|||
|
"\t<title>$title</title>\n" .
|
|||
|
"\t<link>$url</link>\n" .
|
|||
|
"\t<atom:link href=\"$url\" rel=\"self\" type=\"application/rss+xml\" />\n" .
|
|||
|
"\t<description>$description</description>\n" .
|
|||
|
"\t<pubDate>$pubDate</pubDate>\n";
|
|||
|
|
|||
|
if($copyright) $out .= "\t<copyright>$copyright</copyright>\n";
|
|||
|
if($ttl) $out .= "\t<ttl>$ttl</ttl>\n";
|
|||
|
|
|||
|
return $out;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Render individual RSS item
|
|||
|
*
|
|||
|
* @param Page $page
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function renderItem(Page $page) {
|
|||
|
|
|||
|
$sanitizer = $this->wire()->sanitizer;
|
|||
|
$title = strip_tags($page->get($this->itemTitleField));
|
|||
|
|
|||
|
if(empty($title)) return '';
|
|||
|
|
|||
|
$author = '';
|
|||
|
$description = '';
|
|||
|
$content = '';
|
|||
|
$pubDate = '';
|
|||
|
$title = $this->ent1($title);
|
|||
|
|
|||
|
if($this->itemDateField && ($ts = $page->getUnformatted($this->itemDateField))) {
|
|||
|
// date
|
|||
|
$pubDate = "\t\t<pubDate>" . date(DATE_RSS, $ts) . "</pubDate>\n";
|
|||
|
}
|
|||
|
|
|||
|
if($this->itemAuthorField) {
|
|||
|
// author
|
|||
|
$author = $page->get($this->itemAuthorField);
|
|||
|
if($author instanceof Page) {
|
|||
|
$author = $author->get('title|name');
|
|||
|
} else if($author instanceof PageArray) {
|
|||
|
$author = $author->implode(', ', 'title');
|
|||
|
}
|
|||
|
$author = (string) $author;
|
|||
|
if(strlen($author)) {
|
|||
|
$author = $this->ent1($author);
|
|||
|
$author = "\t\t<$this->itemAuthorElement>$author</$this->itemAuthorElement>\n";
|
|||
|
} else {
|
|||
|
$author = '';
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
if($this->itemDescriptionField) {
|
|||
|
// description summary
|
|||
|
$description = $page->get($this->itemDescriptionField);
|
|||
|
if($description !== null) {
|
|||
|
$description = $sanitizer->unentities($description, true);
|
|||
|
$description = $this->truncateDescription($description);
|
|||
|
$description = '<![CDATA[' . $this->ent($description) . ']]>';
|
|||
|
} else {
|
|||
|
$description = '';
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
if($this->itemContentField) {
|
|||
|
// full HTML content, like that from CKEditor
|
|||
|
$content = (string) $page->get($this->itemContentField);
|
|||
|
$content = str_ireplace(array('<![CDATA[', ']]>'), array('<![CDATA[', ']]>'), $content);
|
|||
|
$rootUrl = $this->wire()->config->urls->httpRoot;
|
|||
|
if(strpos($content, '"/') !== false) {
|
|||
|
// convert relative URLs to absolute with host
|
|||
|
$content = str_ireplace(array(' href="/', ' src="/'), array(' href="' . $rootUrl, ' src="' . $rootUrl), $content);
|
|||
|
}
|
|||
|
if(strpos($content, 'href="#') !== false) {
|
|||
|
// convert in-page #anchor links to page URL with anchor
|
|||
|
$content = str_ireplace(' href="#', ' href="' . $page->httpUrl . '#', $content);
|
|||
|
}
|
|||
|
$content = "\t\t<content:encoded><![CDATA[" . $content . "]]></content:encoded>\n";
|
|||
|
}
|
|||
|
|
|||
|
$out =
|
|||
|
"\t<item>\n" .
|
|||
|
"\t\t<title>$title</title>\n" .
|
|||
|
"\t\t<description>$description</description>\n" .
|
|||
|
$pubDate .
|
|||
|
$author .
|
|||
|
$content .
|
|||
|
"\t\t<link>$page->httpUrl</link>\n" .
|
|||
|
"\t\t<guid>$page->httpUrl</guid>\n" .
|
|||
|
"\t</item>\n";
|
|||
|
|
|||
|
return $out;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Render the feed and return it
|
|||
|
*
|
|||
|
* @param PageArray|null $feedPages
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*/
|
|||
|
public function renderFeed(PageArray $feedPages = null) {
|
|||
|
|
|||
|
if(!is_null($feedPages)) $this->feedPages = $feedPages;
|
|||
|
|
|||
|
$out = $this->renderHeader();
|
|||
|
|
|||
|
foreach($this->feedPages as $page) {
|
|||
|
if(!$page->viewable()) continue;
|
|||
|
$out .= $this->renderItem($page);
|
|||
|
}
|
|||
|
|
|||
|
$out .= "</channel>\n</rss>\n";
|
|||
|
|
|||
|
return $out;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Render the feed and echo it (with proper http header)
|
|||
|
*
|
|||
|
* @param PageArray|null $feedPages
|
|||
|
* @return bool
|
|||
|
*
|
|||
|
*/
|
|||
|
public function render(PageArray $feedPages = null) {
|
|||
|
header($this->header);
|
|||
|
echo $this->renderFeed($feedPages);
|
|||
|
return true;
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Truncate the description to a specific length and then truncate to avoid splitting any words.
|
|||
|
*
|
|||
|
* @param string $str
|
|||
|
* @return string
|
|||
|
*
|
|||
|
*/
|
|||
|
protected function truncateDescription($str) {
|
|||
|
|
|||
|
$str = trim($str);
|
|||
|
$maxlen = $this->itemDescriptionLength;
|
|||
|
|
|||
|
if(!$maxlen) return $str;
|
|||
|
|
|||
|
// note: tags are not stripped if itemDescriptionLength == 0 and stripTags == true
|
|||
|
if($this->stripTags) $str = strip_tags($str);
|
|||
|
|
|||
|
if(strlen($str) < $maxlen) return $str;
|
|||
|
|
|||
|
$str = trim(substr($str, 0, $maxlen));
|
|||
|
|
|||
|
// boundaries that we can end the summary with
|
|||
|
$boundaries = array('. ', '? ', '! ', ', ', '; ', '-');
|
|||
|
$bestPos = 0;
|
|||
|
|
|||
|
foreach($boundaries as $boundary) {
|
|||
|
if(($pos = strrpos($str, $boundary)) !== false) {
|
|||
|
// find the boundary that is furthest in string
|
|||
|
if($pos > $bestPos) $bestPos = $pos;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// determine if we should truncate to last punctuation or last space.
|
|||
|
// if the last punctuation is further away then 1/4th the total length, then we'll
|
|||
|
// truncate to the last space. Otherwise, we'll truncate to the last punctuation.
|
|||
|
$spacePos = strrpos($str, ' ');
|
|||
|
if($spacePos > $bestPos && (($spacePos - ($maxlen / 4)) > $bestPos)) $bestPos = $spacePos;
|
|||
|
|
|||
|
if(!$bestPos) $bestPos = $maxlen;
|
|||
|
|
|||
|
return trim(substr($str, 0, $bestPos+1));
|
|||
|
}
|
|||
|
|
|||
|
/**
|
|||
|
* Provide fields for configuring this module
|
|||
|
*
|
|||
|
* @param array $data
|
|||
|
* @return InputfieldWrapper
|
|||
|
*
|
|||
|
*/
|
|||
|
public function getModuleConfigInputfields(array $data) {
|
|||
|
|
|||
|
/** @var Modules $modules */
|
|||
|
$modules = $this->wire('modules');
|
|||
|
|
|||
|
/** @var InputfieldWrapper $form */
|
|||
|
$form = $this->wire(new InputfieldWrapper());
|
|||
|
/** @var InputfieldFieldset $inputfields */
|
|||
|
$inputfields = $modules->get('InputfieldFieldset');
|
|||
|
$inputfields->attr('name', '_defaults');
|
|||
|
$inputfields->label = 'RSS feed defaults';
|
|||
|
$inputfields->icon = 'rss';
|
|||
|
$inputfields->description =
|
|||
|
"Select the default options for any given feed. Each of these may be overridden in the API, " .
|
|||
|
"so the options you select below should be considered defaults, unless you only have 1 feed. " .
|
|||
|
"If you only need to support 1 feed, then you will not need to override any of these in the API.";
|
|||
|
$form->add($inputfields);
|
|||
|
|
|||
|
foreach(self::$defaultConfigData as $key => $value) {
|
|||
|
if(!isset($data[$key])) $data[$key] = $value;
|
|||
|
}
|
|||
|
|
|||
|
/** @var InputfieldText $f */
|
|||
|
$f = $modules->get('InputfieldText');
|
|||
|
$f->attr('name', 'title');
|
|||
|
$f->attr('value', $data['title']);
|
|||
|
$f->label = "Feed title";
|
|||
|
$f->description = "The primary title of the RSS feed.";
|
|||
|
$f->columnWidth = 50;
|
|||
|
$inputfields->add($f);
|
|||
|
|
|||
|
/** @var InputfieldURL $f */
|
|||
|
$f = $modules->get('InputfieldURL');
|
|||
|
$f->attr('name', 'url');
|
|||
|
$f->attr('value', $data['url']);
|
|||
|
$f->label = "Feed URL";
|
|||
|
$f->description = "Optional URL on your site that serves as a feed index.";
|
|||
|
$f->columnWidth = 50;
|
|||
|
$inputfields->add($f);
|
|||
|
|
|||
|
/** @var InputfieldText $f */
|
|||
|
$f = $modules->get('InputfieldText');
|
|||
|
$f->attr('name', 'description');
|
|||
|
$f->attr('value', $data['description']);
|
|||
|
$f->label = "Feed description";
|
|||
|
$f->description = "Optional default description for a feed.";
|
|||
|
$f->columnWidth = 50;
|
|||
|
$inputfields->add($f);
|
|||
|
|
|||
|
/** @var InputfieldURL $f */
|
|||
|
$f = $modules->get('InputfieldURL');
|
|||
|
$f->attr('name', 'xsl');
|
|||
|
$f->attr('value', $data['xsl']);
|
|||
|
$f->label = "Link to XSL stylesheet";
|
|||
|
$f->description = "Optional URL/link to an XSL stylesheet.";
|
|||
|
$f->columnWidth = 50;
|
|||
|
$inputfields->add($f);
|
|||
|
|
|||
|
$f = $modules->get('InputfieldURL');
|
|||
|
$f->attr('name', 'css');
|
|||
|
$f->attr('value', $data['css']);
|
|||
|
$f->label = "Link to CSS stylesheet";
|
|||
|
$f->description = "Optional URL/link to a CSS stylesheet.";
|
|||
|
$f->columnWidth = 50;
|
|||
|
$inputfields->add($f);
|
|||
|
|
|||
|
/** @var InputfieldText $f */
|
|||
|
$f = $modules->get('InputfieldText');
|
|||
|
$f->attr('name', 'copyright');
|
|||
|
$f->attr('value', $data['copyright']);
|
|||
|
$f->label = "Feed copyright";
|
|||
|
$f->description = "Optional default copyright statement for a feed.";
|
|||
|
$f->columnWidth = 50;
|
|||
|
$inputfields->add($f);
|
|||
|
|
|||
|
/** @var InputfieldSelect $f3 */
|
|||
|
$f3 = $modules->get('InputfieldSelect');
|
|||
|
$f3->attr('name', 'itemDateField');
|
|||
|
$f3->attr('value', $data['itemDateField']);
|
|||
|
$f3->label = "Feed item date field";
|
|||
|
$f3->description = "The default field to use as an individual feed item's date.";
|
|||
|
$f3->addOption('created');
|
|||
|
$f3->addOption('modified');
|
|||
|
$f3->addOption('published');
|
|||
|
$f3->columnWidth = 50;
|
|||
|
|
|||
|
/** @var InputfieldSelect $f1 */
|
|||
|
$f1 = $modules->get('InputfieldSelect');
|
|||
|
$f1->attr('name', 'itemTitleField');
|
|||
|
$f1->attr('value', $data['itemTitleField']);
|
|||
|
$f1->label = "Feed item title field";
|
|||
|
$f1->description = "The default field to use as an individual feed item's title.";
|
|||
|
$f1->columnWidth = 50;
|
|||
|
|
|||
|
/** @var InputfieldSelect $f2 */
|
|||
|
$f2 = $modules->get('InputfieldSelect');
|
|||
|
$f2->attr('name', 'itemDescriptionField');
|
|||
|
$f2->attr('value', $data['itemDescriptionField']);
|
|||
|
$f2->label = "Feed item description field";
|
|||
|
$f2->columnWidth = 50;
|
|||
|
$f2->description = "The default field to use as an individual feed item's description (typically a summary or body field). Note that HTML will be stripped out.";
|
|||
|
|
|||
|
/** @var InputfieldInteger $f2a */
|
|||
|
$f2a = $modules->get('InputfieldInteger');
|
|||
|
$f2a->attr('name', 'itemDescriptionLength');
|
|||
|
$f2a->attr('value', (int) $data['itemDescriptionLength']);
|
|||
|
$f2a->label = "Maximum characters for item description field";
|
|||
|
$f2a->columnWidth = 50;
|
|||
|
$f2a->description = "The item description will be truncated to be no longer than the max length provided. Specify '0' for no max length. When there is no max length, markup tags will not be stripped.";
|
|||
|
|
|||
|
/** @var InputfieldSelect $f4 */
|
|||
|
$f4 = $modules->get('InputfieldSelect');
|
|||
|
$f4->attr('name', 'itemContentField');
|
|||
|
$f4->attr('value', $data['itemContentField']);
|
|||
|
$f4->label = "HTML content/body field";
|
|||
|
$f4->description = "Optional field that contains the entire article/bodycopy in HTML. Select only if you intend to include the entire content in the RSS feed, otherwise use just the description field.";
|
|||
|
$f4->columnWidth = 50;
|
|||
|
|
|||
|
/** @var InputfieldInteger $ttl */
|
|||
|
$ttl = $modules->get('InputfieldInteger');
|
|||
|
$ttl->attr('name', 'ttl');
|
|||
|
$ttl->attr('value', (int) $data['ttl']);
|
|||
|
$ttl->label = "Feed TTL";
|
|||
|
$ttl->description = "TTL stands for \"time to live\" in minutes. It indicates how long a channel can be cached before refreshing from the source. Default is 60.";
|
|||
|
$ttl->columnWidth = 50;
|
|||
|
|
|||
|
foreach($this->wire()->fields as $field) {
|
|||
|
$fieldtype = $field->type;
|
|||
|
|
|||
|
if($fieldtype instanceof FieldtypeTextarea) {
|
|||
|
$f2->addOption($field->name);
|
|||
|
$f4->addOption($field->name);
|
|||
|
|
|||
|
} else if($fieldtype instanceof FieldtypeText) {
|
|||
|
$f1->addOption($field->name);
|
|||
|
$f2->addOption($field->name);
|
|||
|
|
|||
|
} else if($fieldtype instanceof FieldtypeDatetime) {
|
|||
|
$f3->addOption($field->name);
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
$inputfields->add($f1);
|
|||
|
$inputfields->add($f3);
|
|||
|
$inputfields->add($f2);
|
|||
|
$inputfields->add($f2a);
|
|||
|
$inputfields->add($f4);
|
|||
|
$inputfields->add($ttl);
|
|||
|
|
|||
|
return $form;
|
|||
|
|
|||
|
}
|
|||
|
|
|||
|
}
|
|||
|
|
|||
|
|