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 descriptions to this max length or 0 to allow HTML * '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 2023 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 string $itemTitleField * @property string $itemContentField * @property string $itemDateField * @property string $itemDescriptionField Field to use for item description. * @property string $itemDescriptionLength Max length for item description or 0 to allow HTML markup with any length (default=1024) * @property string $itemAuthorField * @property string $itemAuthorElement * @property string $header * @property array|PageArray $feedPages * @property bool $stripTags Strip tags from item description? Applies only if `itemDescriptionLength>0`. (default=true) * * */ 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' => 105, '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 '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 = '' . "\n"; if($xsl) $out .= "\n"; if($css) $out .= "\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 .= "\n" . "\n" . "\t$title\n" . "\t$url\n" . "\t\n" . "\t$description\n" . "\t$pubDate\n"; if($copyright) $out .= "\t$copyright\n"; if($ttl) $out .= "\t$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" . date(DATE_RSS, $ts) . "\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>$authoritemAuthorElement>\n"; } else { $author = ''; } } if($this->itemDescriptionField) { // description summary $description = $page->get($this->itemDescriptionField); if($description !== null) { if($this->itemDescriptionLength == 0) { // direct markup allowed in item description $description = $this->relativeToAbsoluteHtml($description, $page); } else { $description = $sanitizer->unentities($description, true); $description = $this->truncateDescription($description); $description = $this->ent($description); } $description = ''; } else { $description = ''; } } if($this->itemContentField) { // full HTML content, like that from CKEditor $content = (string) $page->get($this->itemContentField); $content = $this->relativeToAbsoluteHtml($content, $page); $content = "\t\t\n"; } $out = "\t\n" . "\t\t$title\n" . "\t\t$description\n" . $pubDate . $author . $content . "\t\t$page->httpUrl\n" . "\t\t$page->httpUrl\n" . "\t\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 .= "\n\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; 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)); } /** * Update links and other references in HTML content to be suitable for RSS * * @param string $content * @param Page $page * @return string * */ protected function relativeToAbsoluteHtml($content, Page $page) { $rootUrl = $this->wire()->config->urls->httpRoot; $pageUrl = $page->httpUrl(); $a = array( ' href="/' => ' href="' . $rootUrl, " href='/" => " href='" . $rootUrl, ' src="/' => ' src="' . $rootUrl, " src='/" => " src='" . $rootUrl, ' href="#' => ' href="' . $pageUrl . '#', " href='#" => " href='" . $pageUrl . '#', ' '<![CDATA[', ']]>' => ']]>' ); return str_replace(array_keys($a), array_values($a), $content); } /** * 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); /** @var InputfieldURL $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. When greater than 0, HTML tags will be removed or encoded."; $f2a->notes = "Specify `0` for no max length AND to allow HTML in the description."; /** @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; } }