-
Posts
530 -
Joined
-
Last visited
-
Days Won
35
Everything posted by FireWire
-
Modernism Week is a nonprofit organization dedicated to celebrating and providing education about midcentury modern architecture in Palm Springs, California- the epicenter of the modernist architecture movement. The 10 day event takes place in every year in February and features over 200 unique activities that draw 125,000 people from around the world. At Modernism Week you can tour iconic homes and neighborhoods, attend a cocktail party at Frank Sinatra's house, see live music, watch films, and attend engaging lectures. The organization also engages in preservation efforts to keep historic buildings and landmarks protected for future generations, as well as providing scholarships for students pursuing degrees in architecture, preservation, engineering, and design. https://modernismweek.com Tech Alpine.js htmx Tailwind CSS For a full list of technology, software, members of the web team, and a list of thanks to module developers who made this site possible, view the humans.txt file here. I posted a deep dive into the behind the scenes features and application development that handles automation and extensive use of powerful ProcessWire features over on this forum post. It was a pleasure to work with ProcessWire on this project and its amazing API and features made it all possible.
-
Hey all. This year I launched a new website for Modernism Week, a nonprofit located in Palm Springs, California that celebrates midcentury architecture and hosts engaging educational annual events. https://modernismweek.com This is my most complex ProcessWire website to date and I wanted to share some of the things that were implemented behind the scenes. Project Requirements When I met with stakeholders at the organization, their website was a 5 page brochure site with minimal content and links to buy tickets. The ticketing platform they use is robust, but doesn't provide an experience you would expect from an event this significant. The project was a blank canvas and after an assessment I provided a list of features that would benefit the organization, address shortcomings, identify opportunities, and support future growth and business goals. These included: A vibrant design that mirrors the architecture they celebrate to create a more inspiring experience for visitors Implement stronger adherence to brand standards and new branding to celebrating the 20th anniversary of Modernism Week Feature dedicated pages for events, activities, and offers Promote event and organization sponsors Challenges At first glance, the site looks pretty standard however, it features a full event section that displays everything visitors can do at each activity during the 10 day event. Leading up to and during the event, the information changes to stay up to date with newly added activities and updates to locations, ticket availability, descriptions, pictures, etc. During planning we identified unique challenges. Annual site growth of over 300 new pages for activities and events on top of any normal site growth Reduce the amount of work needed to initially enter the information for each event Add complexity without compounding increases in work to maintain actively changing information Maintain content accuracy by mirroring regularly changing information that is available on pages located on the ticketing platform Synchronize event and activity information with a ticketing platform that does not provide a public API Allow visitors to browse activities when previewing them prior to the ticket sales launch day Keep cross-referenced content across the site, such as promoted activities and offers, current Provide an integrated workflow between people managing the ticketing platform and maintaining the website Improve SEO and implement structured data derived from cumulative page content for all activities Ensure that all activities in an event are centrally searchable and filterable not only when using whole-website search, but within a dedicated activity search feature for each event Deliver a fast and performant experience for visitors Manage server and database loads to prevent overloading resources or rendering the site or admin unusable Oh, and the deadline was the day tickets went on sale, on a Friday, where traffic spikes to thousands of users per minute. We broke every rule in the book. Manually entering and managing each page is a task too large for one or even two people. Events and activities must be added to the site while they are simultaneously added to the ticketing platform in the months leading up to events. The amount of overhead for project management and task delegation would overburden a team already working tirelessly to successfully put on an event of this size. The timeline to design, build, test, and deploy the site was only 4 months. The solution was automation. The Application Layer The only way that the site could effectively and successfully be managed accurately is to automate tasks and consolidate complex processes into single-button clicks and cronjobs. This is where ProcessWire really flexes its muscle. The API and practically limitless flexibility in custom implementation provided the ability to build out everything that was required. The site has its own custom module that provides an interface for executing back end work to synchronize data between the ticketing platform and the site. The site also employs over 60 custom hooks and hook-supporting classes (some of them I shared here) that make chained and background actions possible. As mentioned, the ticketing platform does not provide an API so there was no conventional method to easily retrieve data. This means that all of the data pulled from the ticketing platform is pulled via web scraping. To more easily and accurately retrieve this data, I built a separate internal tool for the ticketing team where they enter activity information, pre-formats it for them to a design, and adds encoded data attributes on elements in the markup that helps make scraping more accurate. The data this retrieves is then parsed, cleaned, and fed through processors that compare, convert, and input into ProcessWire where pages are created or updated. The RoachPHP library facilitated scraping and it's great. If you're interested in web scraping, it's a great tool. Performance There are a lot of relatively expensive operations required to sync activities in large numbers. The way that unique data is stored, such as ticket pricing, availability, and schedule requires more complex fields like RepeaterMatrix. Often the data required to calculate or display information makes using selectors alone to query/filter data difficult, inefficient, or not possible. I chose to use RepeaterMatrix fields over individual pages because of the the reduction in complexity when managing the schedule, pricing, and ticket availability. Some activities occur once, other occur multiple times per day over the course of 10 days. I fully expect that the amount and complexity of information stored will increase over time due to future improvements and feature implementations. Being able to manage all of this data, compare it, and adjust it one one page is important for accuracy and time spent on task when manual edits need to be made and cross referencing multiple pages would be cumbersome. Regardless of page or field choices, nested loops of up to 200 pages involves significant server loads, especially when that process may involve resizing images to multiple dimensions and generating required output data from multiple fields. A good example is the live search feature. Thousands of visitors querying the database is not feasible for even the reasonably powered VPS it's on and I wanted to create something closer to "live search". To address the workload required, the search modal is empty on page load and a separate page is used on the back end that is separately cached using ProCache. This allows one page to serve modals being cached with every page. If the modal content was cached with all activity pages then event changing the ticket availability, title, summary, image, etc. would require expiring the cache of over 200 pages that have complex data. Opening the "All Activities" modal executes an htmx request, and the cached modal contents are loaded near instantaneously. The slowest part of that is the animating the loading animation in/out. Underneath the hood, the live search/filter uses keywords that are embedded in data attributes on each row. These keywords are taken from multiple fields, parsed, stripped/prepared for filtering, and combined. Doing this in a loop is noticeably slow to perform every time the page is called. Filters for categories. sold out, activities happening today, search by keyword, and viewing activities on a specific day is all done on the client side and require calculating per-day ticket availability, first and last occurrence dates, and during the event whether an activity has passed- which when displayed overrides a sold out status. Expiring this page is still an expensive task and calculating the dates, times, and statuses to produce the data needed has taken up to an unacceptable 6+ seconds. If too many pages are not cached this can cause performance issues for multiple users on multiple pages. It's not just about front end performance, it's also important to think about admin performance. To remedy this, I implemented a type of field pre-caching. Front end performance is entirely dependent on ProCache. Without it the server load would be far too high and performance hits would affect time on site and ticket sales. Overall, the goal is to touch the database as little as possible. ProcessWire is indeed fast, but there are always limitations across many different moving parts. Synchronizing Event/Activity Data Importing data can involve multiple background HTTP requests, reading and updating high numbers of pages and dozens of repeater pages and fields. Syncing is done by scraping pages on the ticketing platform. There may be instances that after analyzing the data retrieved does not require updating a page because there were no changes. If there are no changes then the page shouldn't be saved. Rather than check all of the fields for changes in values, data is hashed and stored for later comparison. Every activity page can be updated from the ticketing platform via either the main event page that summarizes useful data, or the individual ticketing page. Sync can be enabled or disabled for the activity entirely, if it is enabled, then sync can be toggled on/off for every individual field in case local management of data is preferred. The retrieved data is hashed and compared to the page hash value. If the last sync datetime is older than the time limit set in the event settings, or the hash does not match, the the page is updated and saved. If not, the page is then ignored. This saves a lot of processing time, power, and database hits while maintaining state. Example of an activity page. Ticketing occurrence data is parsed and run through regex to extract individual data and formatting for dedicated fields. The three fields on the bottom are taken from the Instances Summary field when parsed on import. Looping within loops to create renderable values for the front end is taxing. But using custom page classes to organize business logic with methods that handle processing, formatting, and caching data are immensely powerful and make short work of interacting with pages when bespoke functionality is required. Because of the high number of hooks on various operations, the ability to save quietly without triggering them is used extensively as an efficiency measure and also to prevent unwanted side effects when operations do not call for them. Sync uses this feature the most. Field Pre-Caching A global field called "Field Support Metadata" is used to move significant amounts of data processing from the page view event over to the page save event. This takes the multiple field value processing and image sizing out of loops for rendering and takes the extra time on the back end operations side. The activity page example above uses this method almost entirely for front end rendering and makes pre-calculated values accessible in one field. This was inspired by the SearchEngine module by @teppo that uses a single field to cache values for search. It really saved my bacon π€£ big thanks! Page data is saved as JSON and then retrieved on demand. Metadata includes a wide array of values. Because it's JSON, adding additional data later is very easy. While i could have used the the $page->meta feature, being able to check the contents of the field visually in the admin without dumping or testing values has been useful. All of that data is processed and stored on a Pages::saveReady hook using methods defined on the custom page class. This process is skipped entirely if the activity has no occurrences scheduled. <?php wire()->addHookAfter('Pages::saveReady(template=event_activity)', function(HookEvent $e) { $page = $e->arguments('page'); $occurrences = $page->activity_occurrences; if (!$occurrences->count()) { return; } $firstOccurrenceDate = $page->firstOccurrenceDate(fromMetadata: false); $lastOccurrenceDate = $page->lastOccurrenceDate(fromMetadata: false); $timezone = wire('config')->timezone; $allOccurrenceDateTimeStart = array_map( fn ($date) => CarbonImmutable::parse($date)->setTimezone($timezone)->toDateTimeString(), $occurrences->explode('date_time_start'), ); $page->pushToFieldMetadata([ 'totalOccurrences' => $occurrences->count(), 'totalInstances' => array_sum($occurrences->explode('number')), 'firstOccurrenceDate' => $firstOccurrenceDate?->toDateTimeString(), 'lastOccurrenceDate' => $lastOccurrenceDate?->toDateTimeString(), 'isSoldOut' => $page->isSoldOut(fromMetadata: false), 'isUncategorized' => $page->isUncategorized(fromMetadata: false), 'ticketingUrl' => $page->ticketingUrl(fromMetadata: false), 'activityDates' => $page->activityDates(fromMetadata: false), 'allOccurrenceDateTimeStart' => $allOccurrenceDateTimeStart, 'listFilterKeywordString' => $page->listFilterKeywordString(fromMetadata: false), 'structuredData' => $page->getStructuredData( includeOffers: false, includeLocation: true, includeOrganizer: false, includeDescription: true, includeImages: true, includeParentEvent: false, fromMetadata: false, ) ]); }); Editing one hook makes adding or modifying pre-processed data available everywhere on demand at runtime via one field which is pretty nice. The 'fromMetadate' set to true will return cached values, if false, it will return values that are generated from field data. This allows for the same method to be used anywhere in the application with control over the data source. Any data that can be memoized is via the page class so that if a value is calculated at runtime (wasn't yet cached to metadata yet for some reason) and accessed more than once during a request/response loop then it will be pulled from memory. Granular ProCache Control Not every field changed should invalidate cache. For example, if sync is toggled on/off, that does not affect the rendered page so the cache for that page should not be cleared. Page save events for activity pages fire ProCache hooks that analyze which fields on the page have changed and only clear the cache if it will affect pages that rely on it. Activities are referenced elsewhere and added as featured items to help promote them on other parts of the website. So if an activity page is saved where the title is changed and that activity is featured on various other pages, that could clear that page, the Event page, the All Events modal page, the category page for that activity, and the home page. Using hooks allows for very specific cache operations that cannot be configured in the admin UI. Example of how activities can be featured and promoted in many places elsewhere on the site. There's a little more about this on a ProCache support thread I posted to discuss efficient caching strategies. Thanks to @ryan for the insight and recommendations π Protip: Pre-Warming Your Cache ProCache is excellent but it can't cache a page until it's visited. You can seriously speed this up by using quicklink. Quicklink watches the page for links as they're scrolled into view where the browser makes a background prefetch request and caches the HTML document locally. If ProCache has already cached the page, the background request/response is so quick that the browser pulls every link on the screen near instantly. When you click on the link, you get response times like this. Which aren't response times, they're the speed that the browser cache operates locally. Total insanity. And the nice thing is that if they don't visit the page, they've kindly cached it for everyone else. Thank you, kind visitor. Dynamically Generated Content One of the features built in thanks to hooks is automatic content generation. Examples are the "more in category" and "you might also be interested in" type suggestions at the bottom of every activity page. This is designed to help visitors explore other things that they might like. This provides a great navigation experience on the site that can also increase ticket sales. These are automatically selected when a page is created during first sync/import using a Page::saveReady hook. Activities are given tags on the ticketing platform to help organize them. These are pulled during import, analyzed, and activities are automatically selected by ranking the most similar according to matching tag count. This boosts usable content on the page with zero work by a human. While they're automated, they can also be changed or re-ordered when editing an activity page. You can delete any or all of them and they'll be repopulated with new activities when the page is saved. The code that populates activities lives in a custom page class method for the activity so it can be called anywhere. Future Features The site as it stands currently is not in it's final state. Some planned features had to be cut due to prioritizing event and activities which are revenue generating. Future features include: A full robust blog that delivers a magazine-like experience which also requires importing posts dating back to 2015 from a separate WordPress site. An improved workflow that provides partners the ability to submit information about their activities for events via forms, integrates that tool the ticketing team now uses while adding the ability to save their progress and pre-create ProcessWire activity pages pages to reduce the overhead of major data import operations on the site. Adding a separate section that contains a web app that attendees can use during activities for additional educational information while at the event Create an "itinerary" feature that lets visitors browse the site and add activities that they plan to purchase tickets for. Use this feature to send email reminders when tickets are available and marketing leading up to the event Possibly implement an event calendar that lets people get an overview of the schedule and avoid schedule conflicts. A "Past Events" section of the site where events are moved to but can still be browsed. Where It Can Be Improved There are places in the code that should be using selectors rather than pulling larger numbers of pages since selectors and queries are optimized and efficient. Having a short deadline takes a toll on planning and execution. Offloading more to cronjobs. I had to keep a lot of operations manually triggered that I want truly handled by background automation but I needed an opportunity to analyzej real-world performance in production and have the chance to review the quality and accuracy of the data imported. Scraping is great, but sometimes it takes fine tuning to get things right. Not to mention the amount of regex used vs my skills with regex... More robust features for browsing activities. More filters and options to choose which activities to browse. A lot of great ideas have come out of living with the site and there are some basics I'd like to see implemented. At its heart, this site is still a website but ProcessWire really shines when it's given a big job. It eliminates the need to consider a full application framework and is faster to develop for when you need strong core features for content management. As you can imagine, trying to build something like this on a platform that is not as developer oriented as ProcessWire is would be very not fun and the high quality Pro modules, community modules, and outstanding API made it possible to plan and execute without compromise. If you've made it this far, thanks for reading!
- 1 reply
-
- 16
-
-
-
Quick update- previously Plates for ProcessWire required that you manually install plates via Composer. The module now comes pre-packaged with Plates. If you have already installed Plates via Composer or prefer to do so, this module will prioritize the version that you have installed. If not, the module will use the version that comes with it. I prefer to manage packages via Composer, but that may not always be the case for everyone and I want this module to be easy to get started with, just like Plates itself, regardless of preference or experience. I have pushed some enhancements and improvements to the functions provided by the custom extensions. Going forward extensions will be focused on bugfixes and nonbreaking changes for stability.
-
Hey @bernhard not an issue with RPB but something that I tweaked to help out in a situation that came up while working. I deleted a settings array in settings.php and then didn't touch that project for almost a week. When I came back to it I got an error when trying to edit a page. I didn't realize that one of the block files was attempting to use the global setting that I had deleted. I was able to read through and find out what I did wrong but I had to check back and forth between the block file and settings.php to figure out which one was missing. I added this and it creates a more descriptive stack trace that can save some time. <?php public function use($arr): BlockSettingsArray { $result = new BlockSettingsArray(); foreach ($arr as $name) { $blockSettings = $this->get($name); // Now if I try to use a global settings array that doesn't exist it shows the missing settings name if (is_null($blockSettings)) { throw new LogicException( "Could not find settings with the name '{$name}'. Does it exist?" ); } $result->add($blockSettings->getArray()); } return $result; } This helped me while managing blocks and settings in different files, and also because I leave my work in a broken state for long enough to forget what I did π
-
@David Karich That would be a great feature. I've scanned the API docs and the solution would put the work of managing glossaries entirely on Fluency. I'm familiar with the feature but am learning more in depth about it right now. Bummer. The process of managing them is also a bit of a task: Of course all of this is doable and I'd like to take it on but I'm really overloaded right now with work. I may be able to hack around a little bit but unfortunately couldn't provide a guarantee or estimate on when it could be added to the module. It's definitely a feature I'd like to have on the roadmap. If I have some time I can put towards it I will. Sorry I can't be of more help at the moment π«€
- 241 replies
-
- 1
-
-
- translation
- language
-
(and 1 more)
Tagged with:
-
@bernhard I'll do some more testing myself and come back with anything I can find.
-
@Stefanowitsch Thank you very much for taking the time to check. It's gotta be something odd on my end or some conflict unique to my case.
-
Copied and pasted, still not working for me. Not sure what it is but if it's working for you it has to be some sort of conflict on my end. When I have time I'll do some digging and come back with anything I find. Thanks for checking it out!
-
Great tweaks, and I just learned about insertBefore()/insertAfter() for the first time. Excellent!
-
@bernhard Awesome! π
-
New blog βΒ Whatβs new in ProcessWire 3.0.244?
FireWire replied to ryan's topic in News & Announcements
@ryan So many great new and exciting features. A few are gamechangers. Thank you for all the hard work by both you and contributors. Repeater page classes are π€- 1 reply
-
- 6
-
-
@ryan The right choice! Enjoy!
-
I tried to create a showIf with two select fields but couldn't get it to work properly. I copy/pasted the example from the docs directly in case I was missing something but I still can't get it to work. My attempt: <?php $settings->add([ 'name' => 'image_location_horizontal', 'label' => 'Image Location', 'value' => $field->input('image_location_horizontal', 'select', [ '*right' => 'Right', 'left' => 'Left', 'center' => 'Center', ]), ]); $settings->add([ 'name' => 'body_location_vertical', 'label' => 'Body Location', 'value' => $field->input('body_location_vertical', 'select', [ '*above_feature' => 'Above Feature', 'below_feature' => 'Below Feature', ]), 'showIf' => 'image_location_horizontal=center', ]); Tried searching to see if this has come up for anyone else but didn't see anything.
-
@bernhard Humble suggestion for docs π Nothing critical but noticed as a regular user of RPB. <?php // For global settings, it may be useful to add an example of setting the order when using local settings with prepend(). public function settingsTable(\ProcessWire\RockFieldsField $field) { $settings = $this->getGlobalSettings($field) ->use([ 'style', 'demo-checkbox', ]); $settings->prepend([ 'name' => 'prepended-custom-checkbox', 'label' => 'Prepended custom checkbox', 'value' => $field->input('prepended-custom-checkbox', 'checkbox'), ]); $settings->add([ 'name' => 'custom-checkbox', 'label' => 'Custom checkbox', 'value' => $field->input('custom-checkbox', 'checkbox'), ]); return $settings; } //----------- // Under the simple checkbox example it shows how to call the add method but doesn't specify where the $settings variable is initialized $settings->add([ 'name' => 'demo-checkbox', 'label' => 'Demo Checkbox', 'value' => $field->input('demo-checkbox', 'checkbox'), ]); // May be good to show a settings table that doesn't use globals initialize the $settings variable. It's in the default stub, but may be helpful anyway public function settingsTable(\ProcessWire\RockFieldsField $field) { $settings = $this->getDefaultSettings($field); $settings->add([ 'name' => 'demo-checkbox', 'label' => 'Demo Checkbox', 'value' => $field->input('demo-checkbox', 'checkbox'), ]); return $settings; } New settings are working perfectly, love the upgrade π
-
Story of my/our lives. HA. Thank you for your work on this, it looks great and the syntax is very nice. Excellent work π
-
@bernhard It was completely my oversight, I didn't know that RPB already had a solution for managing settings to be used globally. So it wasn't due to anything other than not knowing about it ahead of time. I like this implementation! Very elegant. Having it in the RPB templates folder is great and that array definition style is really intuitive.
-
@bernhard Maybe I should mark this post as "solved" π
-
@bernhard Of course you thought of this... I need to go back and read the docs more ha! I completely missed that. Everyone save yourself the trouble and RTFM π€£
-
Hey there! I posted this before I took advantage of a feature in RockPageBuilder to manage globally sharable settings for blocks in RPB. @bernhard has since added some very nice features that make my tips below a little out of date. I recommend viewing his comment below and taking advantage of RPB's new native solution. Thanks to Bernhard for his ongoing work of making RPB event better for developers and users π ---------- Sharing a strategy that I have found useful when working with blocks and block settings. I use the block settings feature heavily and in many instances most blocks have some of the same settings fields. To help make managing these easier, I created a workflow to help me manage these and it's saved me a good amount of time while being able to reuse settings between projects very easily. In my case, some of these are: Spacing between blocks/sections on the page Background/accent colors Content location within blocks Block presentation My approach is to create a dedicated class with static methods that return a settings array. Example: <?php // /site/init.php /** * This creates a namespace for a /site/templates/RockPageBuilderSupport directory */ wire('classLoader')->addNamespace('RockPageBuilderSupport', __DIR__ . '/templates/RockPageBuilderSupport'); This file contains the fast helper methods that create settings fields <?php // /site/templates/RockPgeBuilderSupport/BlockSettings.php namespace RockPageBuilderSupport; use ProcessWire\RockFieldsField; /** * Reusable block settings */ class BlockSettings { public static function sectionPresentation( RockFieldsField $field, array $config = [], array $additionalValues = [], ): array { $name = 'section_presentation'; return [ 'name' => $name, 'label' => 'Section Presentation', 'value' => $field->input($name, 'select', [ '*normal' => 'Normal', 'standalone' => 'Standalone', 'standalone_drop_shadow' => 'Standalone + Drop Shadow', ...$additionalValues, ]), ...$config, ]; } public static function bodyWidth( RockFieldsField $field, array $config = [], array $additionalValues = [], ): array { $name = 'body_width'; return [ 'name' => $name, 'label' => 'Body Width', 'value' => $field->input($name, 'select', [ '*constrained' => 'Constrained', 'full' => 'Full', ...$additionalValues, ]), ...$config, ]; } public static function backgroundColor( RockFieldsField $field, array $config = [], array $additionalValues = [], ): array { $name = 'background_color'; return [ 'name' => $name, 'label' => 'Background Color', 'value' => $field->input($name, 'select', [ '*white' => 'White', 'seafoam' => 'Seafoam', 'champagne' => 'Champagne', 'none' => 'None', ...$additionalValues, ]), ...$config, ]; } // ...as many commonly used settings methods as you need } Settings are easily reusable in any block. <?php declare(strict_types=1); namespace RockPageBuilderBlock; use ProcessWire\RockFieldsField; use RockPageBuilder\{Block, BlockSettingsArray}; use RockPageBuilderSupport\BlockSettings; class BlogFeed extends Block { const prefix = "rpb_blogfeed_"; /** * Block config info */ public function info(): array { return [ 'title' => 'News Article Feed', 'description' => 'An array of blog posts', // ...other info ]; } /** * Runtime block settings */ public function settingsTable(RockFieldsField $field): BlockSettingsArray { $settings = $this->getDefaultSettings($field); $settings->add( BlockSettings::bodyWidth($field) ); $settings->add( BlockSettings::bodyLocationVertical($field) ); $settings->add( BlockSettings::actionLocationVertical($field, config: [ 'label' => "Below 'view all' link" ]) ); $settings->add( BlockSettings::sectionPresentation($field, additionalValues: [ 'full' => 'Full news feed design', ]) ); return $settings; } // ... ommitted for brevity } This has helped me easily manage common settings for 20 different blocks by editing the settings in one place. By adding the $config and $additionalValues parameters to the BlockSettings methods, overrides can be added when individual blocks need customization. Thanks to the runtime nature of block settings, making these changes on the fly is extremely easy. This has saved me a lot of time and helps keep things organized. I was able to carry over the bulk of my work with settings from one project to another and it really helped out a lot. Would love to hear if others out there have developed some tips and tricks that help you build your sites!
-
@zilli Some great questions! I spent many years writing vanilla PHP template markup. For Occam's Razor, have the leeway to apply as many or few features a tool offers. Your mentioning Markup Regions is a good example, I use many features of ProcessWire but have never used Markup Regions (I have no opinion, it just coincidentally never became part of my workflow). Both MR and templating engines aim to overcome challenges introduced when using PHP (like any language) alone for output. So it's up to the preference of the developer and needs of a project, a la "there are no wrong answers". My limited experience with MR make me a less than ideal candidate to draw comparisons or speak to compatibility. My first thought it to flip the question of complexity and see how it applies to tools you/me/we may already use. Page Classes are a layer of complexity to abstract logic out of presentation, ProcessWire itself is a layer of complexity to abstract database transactions/data management. Templating strategies are a layer of complexity that make your workflow less complex. Tools like MR and templating packages do that as well while sometimes affording some extra tools. My experience with templating solutions was born out of bumping my head on limitations and moments of thinking "there has to be a better way to do this". I ended up using creative tricks to make 'require' and 'require_once' carry the load but ended up making my code harder to manage, more files to make it work, and felt like I was breaking good practice rules. Consider this simple example: <!-- /site/templates/inc/header.inc.php --> <!DOCTYPE html> <html lang="en"> <head> <title><?=$page->title?></title> <?php if ($includeGoogleAnalytics ?? false): ?> <?php require_once '/path/to/google_analytics.inc.php' ?> <!-- GA code here --> <?php endif ?> </head> <body id="$pageId" class="<?=$pageClass?>"> <header> <img src="/path/to/logo.png"> <h1><?=$headline?></h1> </header> Then I have my home.php and to make this work, I have to start setting big scope variables to be made available to the 'require_once' code. This got worse as the site needed more features and the header and footer became more complex. Later I had to create a new headers for other parts of the site, so multiply the code above a few more times, and add some more 'require_once' lines to each of them. <?php namespace ProcessWire; // /site/templates/home.php $pageId = 'stuff-here'; $pageClass = 'stuff-there'; $includeGoogleAnalytics = true; require_once __DIR__ . 'partials/header.inc.php'; ?> <!-- We haven't even added one line of markup/code for the home page --> <?php $includeCallToAction = true; $includeTestimonials = false; $anotherOne = true; $iHearYouLikeOutOfScopeVariables = false; require_once __DIR__ . 'partials/footer.inc.php'; ?> Here's an actual snippet from a site I build years ago. If you look at a partial or component and it has a lot of if statements, sometimes but not always, it says "I'm a file that does too much" and it becomes increasingly difficult to manage. <!-- Real snippet of production code from /site/templates/partials/footer.inc.php --> <?php if ($mainNav): ?> <?php require_once __DIR__ . '/../inc/site_nav_overlay.inc.php' ?> <?php endif ?> <?php if ($salesCtaModal): ?> <?php require_once __DIR__ . '/../inc/cta_sales_quote_modal.inc.php' ?> <?php endif ?> <?php if ($solarCleaningCtaModal): ?> <?php require_once __DIR__ . '/../inc/cta_solar_cleaning_modal.inc.php' ?> <?php endif ?> <?php if ($solarRepairCtaModal): ?> <?php require_once __DIR__ . '/../inc/cta_solar_repair_modal.inc.php' ?> <?php endif ?> Is this how everyone does it? Maybe not, hopefully not, but every project runs the risk of becoming Frankenstein's monster. With a templating solution approach this would have been solved with 4 files. You can significantly reduce the number of if statements because each of these have a job to do, they know what it is, and when you're maintaining the site, or adding new features, there is zero confusion about where to go. Working on the blog parts? Go edit the blog templates and blog layout. The service/maintenance pages know they'll need the "cleaning" and "solar repair" modals so that whole if block doesn't exist anymore. Cake. <?php namespace ProcessWire; // This is the base layout. The layouts below declare this as *their* layout and pass data if needed $this->layout('layouts/base'); // For the majority of pages on the site $this->layout('layouts/main'); // For the pages dedicated to customer service and maintenance rather than sales $this->layout('layouts/service_mainenance'); // For all blog and blog related pages $this->layout('layouts/blog'); ?> Long story short, the complexity would have been immediately reduced by introducing a templating tool with a predictable set of features and functions to use. To contrast, as a longtime user of preprocessors myself, I can say that those have the potential to introduce a lot of things that fundamentally change how you work with the language itself. Sass/Less bring nesting, loops, variables (before custom properties were available), mixins, includes, file imports, modifier functions, if/else control flows, custom functions, (the list goes on) and an entire JavaScript toolset to handle it. You can solve a lot of problems you don't have! This isn't an argument against preprocessors, but there is a higher level of discipline you must have to keep this from going off the rails and being so clever you outsmart yourself. On the other hand, templating is so incredibly fundamental to producing apps and websites that the constrains start to tighten much sooner and it becomes more clear how many workarounds and "bending the rules" of good practice are present. The benefits of introducing templating tools bring a higher number of benefits that are more impactful faster. You're also adopting a common development practice, so it is leveling up your skillset. I have to mention that these benefits are not unique to Plates. Layouts, and enhanced insert/include features, are common to pretty much all templating tools. Pick the one that feels right be it Latte, Twig, Smarty, or Plates. My only argument in favor of Plates is that if templating is something that you're introducing into your projects for the first time you may be up to speed faster because: a) the core features set of Plates is very limited (intentionally), and b) it's the same PHP syntax you already use. The concepts and strategy you use with Plates will translate to other templating tools. In the case of this module, the potential amount of complexity is almost entirely due to the custom extensions I built which is why they are optional and disabled by default. You can safely ignore everything in my post above from "Plates for ProcessWIre Extensions" on down and still get simple yet powerful tools. If you have any Q's, post them here.
-
Plates for ProcessWire is a module to make using Plates with your ProcessWire templates plug-and-play. Plates is an extremely lightweight pure PHP templating system that provides features that developers have come to expect when building applications and sites in ProcessWire and beyond. From the Plates website: Highlights from the documentation: Native PHP templates, no new syntax to learn Plates is a template system, not a template language Plates encourages the use of existing PHP functions Increased code reuse with template layouts and inheritance Template folders for grouping templates into namespaces Data sharing across templates Pre-assign data to specific templates Built-in escaping helpers Easy to extend using functions and extensions Plates is an extremely stable application that has been in development and use in production since 2014. This module is also a simple adapter so I am confident in it's stability as I've already used it myself. However, the custom extensions included should be considered early releases and bug reports are welcome, pull requests are very welcome as well! If you're familiar with Plates or just want to get started now, you can download the module from the Github repository. Batteries are included, documentation provided. Previously this module requires that you install Plates manually via Composer. To make the module more accessible and a true "drop-in and code" solution, Plates is now included. If you prefer to manage your packages separately via Composer, or have already installed this module and Plates, Plates for ProcessWire will use the version you have installed then fall back to the version shipped with the module if it is not present. So, FireWire, why another templating engine? There are many stellar templating engines available. I've used several of them and they have truly great features. I also appreciate the simplicity of working with PHP. While templating engines do sometimes offer more terse syntax, it's not a killer feature for everyone like code reuse, nesting, and layouts may be. Code editors will always offer first-class support for PHP and syntax highlighting makes some of the arguments about readability less of a feature benefit for alternatives. Plates takes care of the limitations that come with writing pure PHP templates. Plates feels very at home with the ProcessWire API. Like ProcessWire, it also scales infinitely and its focus on core features over the large library approach makes for a great developer experience. If you've worked with templating engines in the past, the features are familiar. If you haven't, you'll be up to speed remarkably fast. I wrote this module with the intention of making it a "drop-in and code" experience, and I've worked on using the extensibility of Plates to add some extra superpowers to boot. Plates is another great option that may be useful to you whether because it's more your style, it fits your use case, or maybe your first time adding a little extra oomph to your ProcessWire templates. The first 10 minutes you spend with the Plates documentation might be the last 10 minutes. A Simple Example Start with files and folders. Things to know off the bat: Plates for ProcessWire comes pre-configured to look for Plates templates in your /site/templates folder By default it will look for files with the extension '.plates.php' to help differentiate from ProcessWire files, however this may be customized to any extension you prefer on the module config page The folder structure here is entirely up to you, this example can be used but is not required. /site /templates /components image_gallery.plates.php /layouts main.plates.php /views home.plates.php home.php ready.php Your ProcessWire templates will contain one line that hands off rendering to Plates <!-- /site/templates/home.php --> <?=$plates->templates->render('views/home')?> Start by creating your layout. We'll keep it simple. <?php namespace ProcessWire; // /site/templates/layouts/main.plates.php /** * @property string|null $title Page title * @property string|null $description Meta description */ ?> <!DOCTYPE html> <html> <head> <title><?= $title ?? $page->title; ?></title> <?php if ($description ?? null): ?> <meta name="description" content="<?=$description?>"> <?php endif ?> <link rel="stylesheet" href="<?=$config->paths->templates?>styles/app.css"> </head> <body> <header class> <img src="/path/to/logo.jpg"> <nav> <ul> <?php foreach ($navBase->children->prepend($pages->get('/')) as $navPage): ?> <li> <a href="<?=$navPage->url?>"><?=$navPage->title?></a> </li> <?php endforeach ?> </ul> </nav> </header> <section> <?= $this->section('hero'); ?> </section> <?= $this->section('content') ?> <footer> <?= $this->section('footer'); ?> </footer> <script src="/path/to/your/file.js"></script> </body> </html> I like to add docblocks at the top of my Plates templates because we can pass data to any template or layout wherever needed and this helps understand what is accepted or expected at a glance. This is optional and just a style preference. Some notes: The full ProcessWire API is available Your Plates templates are rendered inside a Plates Template object. To use any Plates function, custom function, or custom extension you use $this Jumping over to home.plates.php <?php namespace ProcessWire; // /site/templates/views/home.plates.php $this->layout('layouts/main', ['description' => $page->description]); ?> <?php $this->start('hero') ?> <h1><?=$page->headline?></h1> <img src="<?=$page->hero_image->url?>" alt="$page->hero_image->description"> <?php $this->end() ?> <section> some stuff here </section> <?php if ($page->gallery->count()): ?> <section> <?php $this->insert('components/image_gallery', [ 'images' => $page->gallery, 'title' => __('Image Gallery'), ]) ?> </section> <?php endif ?> <section> Some stuff there </section> <?php $this->start('footer') ?> <p>Thanks for visiting</p> <?php $this->end() ?> Things to note: The full ProcessWire API is available including language functions Even though this file is located in the 'views' subdirectory, Plates is configured out of the box to use '/site/templates/' as the base directory, so you can write paths without '../' directory traversal We chose the main layout and passed the 'description' variable which is available in main.plates.php as $description $this->start('section name') and $this->stop() capture what is output to those sections in main.plates.php, there is no limit on sections and they can have any name aside from 'content' which is reserved. Any content that exists outside of a defined start/stop section is automatically output to the 'content' section in your layout And the image gallery: <?php namespace ProcessWire; // /site/templates/components/image_gallery.plates.php /** * @property string|null $title Optional gallery title * @property Pageimages $images Images field */ ?> <div> <?php if ($title ?? null): ?> <h3><?=$this->batch($title, 'strtolower|ucfirst')?></h3> <?php endif ?> <ul> <?php foreach ($images as $image): ?> <li> <img src="<?=$image->url?>" alt="<?=$image->description?>"> </li> <?php endforeach ?> </ul> </div> Additional notes: You can use $this->insert() in any Plates file, including layouts. You can also use $this->insert() to nest Plates templates in other Plates templates You can use batch() to execute multiple functions on a value. Any PHP function that accepts one argument (or one argument and the rest optional) can be chained in a batch. This also works with custom functions and Extension functions where you can do some really neat stuff. This is similar to functions and filters in other templating engines. The Syntax The syntax, like ProcessWire, is just PHP and has complementary style and a simple API. Plates only makes style recommendations. One of the key elements to making templates in any engine work is knowing where to put logic and where control structures should do the heavy lifting. With PageClasses and organized code, templates can be clean and concise with just PHP variables, loops, and control structures. At it's core, Plates primarily keeps focus on templates which where other engines that tend to include new syntax and tools because they already have to build a parser or interpreter. The batch() function covers a most use cases and is a welcome tool to use as is or as a complement to more custom functions and extensions. That's all you need to get started using Plates for ProcessWire. I highly recommend reviewing the short documentation to get the most out of templates in your projects. Layouts - A core templating feature for sharing page designs and base code between templates Nesting - Enhanced code reusability by inserting code blocks Inheritance - Use code sharing between templates to build more complex designs with simplicity Functions - Batching functions and writing your own Plates for ProcessWire comes with several custom build extensions for this module that may be useful. All extensions are optional and disabled by default. You can start building with the core Plates system. Extras: Plates for ProcessWire Extensions (optional) NOTE: All examples below are part of custom extensions written for this module. They can be enabled or disabled on the Plates for ProcessWire module config page, all are disabled by default. These custom extensions have full documentation that is accessible on the module config page or by reading the markdown documents directly in the module directory. This module comes with several extensions that add useful tools for building templates. Many also provide some parity with other templating solutions. All custom extensions are optional and can be enabled/disabled on the module config page. Plates for ProcessWire extensions provide over 100 custom functions to use in your templates. Many of them are batchable, many of them are written to use with ProcessWire objects such as Page and WireArray/PageArray. Others are intended to make template code shorter and cleaner, others are just nice to have. The Conditionals Extension brings some efficient output options. <!-- From our example above. Instead of this --> <?php if ($page->gallery->count()): ?> <?php $this->insert('components/image_gallery', [ 'images' => $page->gallery, 'title' => __('Image Gallery'), ]) ?> <?php endif ?> <!-- Consider this --> <?php $this->insertIf('components/image_gallery', $page->gallery->count(), [ 'images' => $page->gallery, 'title' => __('Image Gallery'), ]) ?> Tidy up single line outputs <!-- Instead of this --> <?php if ($page->text_field): ?> <h2><?=$page->text_field?></h2> <?php endif ?> <!-- Consider this. The if function accepts two argument and outputs the second if the first is truthy/falsey --> <?=$this->if($page->text_field, "<h2>{$page->text_field}</h2>")?> Conditionals also provide cleaner syntax for some control flow operations <!-- Instead of this --> <h2> <?php if ($weather === 'sunny'): ?> <?=__('Grab your sunglasses')?> <?php elseif ($weather === 'cold'): ?> <?=__('Wear a coat')?> <?php elseif ($weather === 'cold'): ?> <?=__('Bring an umbrella')?> <?php endif ?> </h2> <!-- Consider this --> <h2> <?=$this->match($weather, [ 'sunny' => __('Grab your sunglasses'), 'cold' => __('Wear a coat'), 'rainy' => __('Bring an umbrella'), ])?> </h2> <!-- When more complex evaluations are needed, consider matchTrue --> <h2> <?=$this->matchTrue([ __('Tickets are available') => $ticketCount > 10, __('Hurry, tickets are almost sold out') => $ticketCount > 1, __('Sold Out') => true, ])?> </h2> The Functions Extension provides a wide array of flexible and batchable functions <!-- Get the sum of all items in a WireArray/PageArray, associative array, or object by field/key/property, Also works on indexed arrays --> <p>Total: <?=$this->sum($page->cart_items, 'price')?></p> <!-- Group items in an associative array, array of objects, or WireArray/PageArray by property or field --> <?php foreach ($this->group($pages->get('template=players'), 'team_name')) as $teamName => $players): ?> <h2><?=$teamName?></h2> <ul> <?php foreach ($players as $player): ?> <li> <?=$player->title?><br> <?=$player->position?> </li> <?php endforeach ?> </ul> <?php endforeach ?> <!-- Get PageArrays inclusive of their parent using withChildren() Assign attributes/values if a page matches the current page using attrIfPage() --> <nav> <ul> <?php foreach ($this->withChildren('/') as $navItem): ?> <li<?=$this->attrIfPage($navItem, 'class', 'active')?>> <a href="<?=$navItem->url?>"> <?=$navItem->title?> </a> </li> <?php endforeach ?> </ul> </nav> <!-- A second argument is a selector for child pages --> <?php foreach ($this->withChildren('/', 'template=team_members') as $navItem): ?> <!-- Generate an unordered list of breadcrumbs --> <?=$this->breadcrumbs(['startPage' => '/', 'separator' => ' | ', 'ulClass' => 'breadcrumb-nav'])?> <!-- Create an indexed array with iterator from index 1 on any iterable object --> <?php foreach ($this->batch($page->images, 'toList|from1') as $i => $image): ?> <img src="<?=$image->url?>" alt="<?=$image->description?>" data-slide-index="<?=$i?>"> <?php endforeach ?> The configurable Asset Loader extension lets you link, inline, and preload assets with automatic cache busting version parameters. Directories and namespaces are yours to choose. <?=$this->preloadAssets([ 'fonts::ProximaNova.woff2', 'fonts::ProximaNovaLight.woff2', 'js::app.js', ])?> <?=$this->linkAsset('styles::app.css')?> <?=$this->inlineAsset('styles::critical.css')?> <?=$this->linkAsset('js::app.js')?> There are more extensions and a lot more functions, with documentation. Many functions that work with arrays also work with WireArray and WireArray derived objects making them batchable. If you're a RockPageBuilder rockstar, check out the README file for details on how to use an included utility function to make Plates and RPB work together π Try It Out! If you want to give it a try, download the module from the Github repository and take it for a spin. When it gets a little more testing I may submit it to the modules directory. I'm a consistent user of plain PHP, Latte, and Blade for templating and I think Plates is a great addition to the developer toolbox. Interested to hear your thoughts.
-
@HerTha PHP 8 made working with string functions a little more strict, but a quick fix is possible. Try using a null coalescing operator to tidy things up. <?php // Checks if a non-null value exists for a variable, if it does then it will be passed to trim(), if it doesn't or it has a null value, will fall back to an empty string. trim($someVar ?? ''); // This should work with preg_replace() as well, or any function that expects a string but may not receive one preg_replace('/someregexstuff/', 'Replacement', $someVar ?? ''); Hope that helps out!
-
I haven't yet. The app that I wrote for a client wasn't resource-heavy enough to offload the DB to a separate server, and it was a single app/single database setup so there weren't going to be any shared resources. If you or anyone gets around to using one I'd be interested in hearing about the results though.
- 16 replies
-
- 1
-
-
- hosting services
- vps
-
(and 1 more)
Tagged with:
-
@bernhard Just a friendly note to say how much RM has been a gamechanger for me. Started out with adopting it in RockPageBuilder but now I'm installing it on older sites that I am coming back to add features to simply for the speed and ease it provides when managing new fields. Bravo and thanks for the great module π