Leaderboard
Popular Content
Showing content with the highest reputation on 11/18/2023 in all areas
-
This week the core dev branch version has been bumped to 3.0.231. There are about 15 commits in this version relative to the previous. In the dev branch commit log you'll find a good mix of issue fixes and improvements. If you are already using the dev branch, this version is worth the upgrade. If using the main/master branch then it should be a safe upgrade as well, though none of the updates are urgent. And it won't be long till they are also merged to the main/master branch soon too. This week I've also been working on 2 new related modules: FieldtypeDateRange and InputfieldDateRange. These modules allow selection of starting and ending dates to support a date range. It also calculates and stores the number of days and nights for querying and sorting purposes. The "date from" and "date to" can be independently queried from $pages->find(), as can the days or nights. The InputfieldDateRange module can be used independently of its companion Fieldtype module, making it possible to use the date range Inputfield in FormBuilder or other Inputfield forms. One context where the Inputfield might be useful is when selecting travel dates on a front-end form, such as one from FormBuilder. When used as a Fieldtype, you might use it to specify availability of something, start and end dates to publish content, event dates, or any number of other use cases. Below is a screenshot of the Inputfield as well as its configuration tab. The JS-based input widget comes from an existing package that I've made some modifications to, and it works really nicely, with a polished and easy-to-use UI. I originally found it booking some travel online, and really liked the way it worked. I was able to track it down on GitHub here and thought it would be useful to build an Inputfield module around it. It can be set up to work like the core date picker where it appears when you focus in the input, or it can be configured "inline" where it is alway s visible (and the related text input is hidden). In the following screenshot, I've specified that Sundays can't be used for start/end selections and that November 23 is not available. The selected range spans two months. If you want it to span more months, you could click the right arrow in the December calendar to find your desired month, leaving the first calendar in November. In this manner, you can select ranges that span multiple months, or even years: Here's the Inputfield configuration screen so far. All of these settings are hookable as well, as some of them are more likely to be useful dynamically, especially min/max start and end dates, non-selectable dates, etc. (Note the screenshot below does not necessarily reflect the settings in the screenshots above). More on this next week. Have a great weekend!10 points
-
I want to show a new website that I made at the beginning of this year using @bernhard's RockPageBuilder module: https://www.kurrat-terrassendaecher.de/ The client: Kurrat Terrassendächer (which translates to "Kurrat Terrace Roofs") is a dealer from germany that is specialized in building custom made terrace roofings, awnings and solar protection. The goal: The customer had a old website that was used as a basis for the new design. The new website should offer a more "catalogue-like" look with lots of information for the customer. Fortunately the client had access to high quality images of the products that really make the whole website shine. The page features three main categories: 1. Terrace Roofs 3. Solar Protection 3. Winter Gardens Each category subpage is made of modular content blocks: The user is able to make custom page layouts using these blocks directly in the frontend. With the RockPageBuilder module it is super fun an super straight forward to work with these content blocks. If you don't know this module yet I highly recommend to check it out! If you like the RepaterMatrix you will love this module. It is far superior IMHO! Inserting a content block looks like this: It is also possible to edit content on-the-fly in the frontend: As with the RepeaterMatrix each content block an also be added end edited in the backend of the page: Here you see the list of content blocks used on the home page. To edit a block, just click on it and you can edit it just like using the RepaterMatrix. Screenshots from the website: Modules used for this page: - RockFrontend (for asset bundling, minifying, etc.) - RockPageBuilder (instead of RepeaterMatrix! For building and editing all content in the frontend) - WireMailSMTP - PageImageSource (for creating webp image variations) - PrivacyWire (for cookie consent banner) - SEO Maestro - Redirects The frontend framework is UIKit8 points
-
Hey @ryan that looks great! Are you also planning to add an API to select pages matching a date range? That would be great, because when working with date ranges the INPUT is only one part of the equation. May I ask you to have a look at this thread? https://processwire.com/talk/topic/23097-previewdiscussion-rockdaterange-fieldtype-inputfield-to-easily-pick-daterange-or-timerange/ It shows what I came up with some time ago. I didn't proceed with the module, but some parts of selecting pages where quite promising: <?php // find events in 2023 (meaning from 2023-01-01 00:00:00 to 2023-12-31 23:59:59) $pages->find("template=event, my_range=2023"); // find events in taking place in 2023-11 (meaning from 2023-11-01 00:00:00 to 2023-11-30 23:59:59) // this would also find an event starting in 2023-10 lasting to 2023-12 $pages->find("template=event, my_range=2023-11"); // find events in taking place in 2023-11-17 (meaning from 2023-11-17 00:00:00 to 2023-11-17 23:59:59) $pages->find("template=event, my_range=2023-11-17"); // find events starting after 2023-11-17 00:00:00 $pages->find("template=event, my_range.starts >= 2023-11-17"); // find events ending before 2023-11-17 00:00:00 $pages->find("template=event, my_range.ends < 2023-11-17"); Not sure how/if that works when dealing with different timezones, but for my use case it was of great help to have this easy human readable API, because when using timestamps it quickly get's complicated and prone to errors (like forgetting to use <= instead of < or such): <?php // find events taking place in 2023-11 $from = strtotime("2023-11-01"); $to = strtotime("2023-12-01"); $pages->find("template=event, my_range.starts >= $from, my_range.ends < $to"); That example is actually not working, because for real world queries you'd need to take more possibilities into account: So if 2023-11 starts at the first black line and ends at the second, then you'd need this query to find events that take place in 2023-11: // find events that either // start within 2023-11 (meaning start after or at 2023-11-01 00:00:00 and start before 2023-12-01 00:00:00) // or end within 2023-11 (meaning end after or at 2023-11-01 00:00:00 and end before 2023-12-01 00:00:00) // or start before 2023-11-01 00:00:00 and end after or at 2023-12-01 00:00:00 That shows that it quickly gets very complicated and you need to be really careful with all the times and comparison operators! IMHO using the other syntax makes code a lot easier to read and maintain. For my use case (showing events in a calendar) it was a lot easier to use this syntax, because you don't have to find the timestamps of the first second of the month and the last second of the month or the first second of the next month etc.; You just use "range=2023-11" and that's it.6 points
-
@bernhard Since it has a Fieldtype the ability to query it from selectors comes baked in. Though it uses the terms "date from" and "date to" rather than "starts" and "ends". Each are MySQL DATE (not DATETIME) columns, so time isn't a factor. Days and nights will also queryable from selectors. I've not yet added a custom getMatchQuery() method to the Fieldtype, but I think most of the examples you mentioned may even be possible with it using the one inherited from the base Fieldtype class, but I've not tested that far yet. I'm planning to add a custom getMatchQuery() method either way for the days/nights support, so may tweak it further too. @PavelRadvan The disabled days or days of week would be possible. Though if this is for restaurant reservations, I'm not sure that a date range is what you'd need, unless a reservation spans more than one day? Plus, aren't reservations about time as much as date? (There's no time selection in date range). Since it would likely be just one day, I'm thinking the regular/core FieldtypeDatetime/InputfieldDatetime is probably better, plus it also supports time. It uses the jQuery UI datepicker, though I don't think that one supports disabled days. In any case, you could always use a hook or JS to alert the user when they've selected a day that reservations aren't allowed on. If you want to explore it more post in the FormBuilder board and let's see what's possible. @BitPoet I wasn't thrilled with that label either. I converted all (or most) of the JS library's config settings to a PW settings screen and used the descriptions from the JS library in some cases, at least to start. I don't yet fully understand what that option does, so was going to figure it out and then come up with a better label/description.2 points
-
You're welcome! ?. I can confirm that this solves the issue. Orphaned blocks are indeed removed when the page is saved. Thanks! Ian.2 points
-
Thx for the kick in my *** ? This has been on my list for quite some time ? I've just pushed v4.7.4 to github and it's ready for you to download on my website ? Blocks will now be deleted whenever a page with a rockpagebuilder field is saved. Let me know if everything works as expected!2 points
-
Hey @bernhard, me again! When developing a new block, I was adding it to a page repeatedly, but not saving the page. Instead I would cancel editing and ignore the 'changes not saved' popup, to add more features to the block and try it again. I then realised that this was creating pages under the RockPageBuilderBlocks tree that seemed to be orphaned, containing just my default settings. Of course I can delete these pages, but do you think there might be a way to identify any such orphaned blocks and remove them automatically? Thx, Ian.1 point
-
Happy to announce the launch of the completely rebuilt San Francisco Contemporary Music Players website, using ProcessWire. https://sfcmp.org/ The previous website was a hornet's nest of disorganized content, dozens of 3rd party plugins, duct taped together within WordPress... difficult to use, time consuming and confusing to manage. And didn't look so good either. Lot of fun to rebuild this, though took several months. YOOtheme Pro and UiKit were a dream for me to work with, just love those. Made it so possible to create all of the custom sections, widgets, sliders, cards, mega menus, and so on. Don't consider myself a front-end focused web dev, so have a deep appreciation for the time, care and effort that Yoo has put into both UiKit and YOOtheme Pro. Almost no additonal CSS was needed to be written for this; the stock UiKit classes and attributes make it possible to just build things in HTML and not have to fiddle with CSS. Ryan's commercial modules played a major role in the build. ProFields, ListerPro, ProCache, and FormBuilder were all important. FormBuilder+Stripe allowed me to confidently migrate their need for a stripe checkout from some WP plugin to the clean and simple setup now using FB. ( https://sfcmp.org/donate/print-for-sale-dirge-by-hung-liu/ ) Some libraries also were of great use and value, namely PLYR for audio, and tabulator for some table display type of stuff. As always, the API was a dream to work with, many custom import scripts were created along the way to import legacy Press, Albums, Repertoire works, Program Booklets library, Players etc.. The image API is doing wonders with SRC sets, and webp images. The PW documentation site was a daily companion. This forum likewise was always a most valuable and enjoyable resource to search and rely on for solving the occasional conundrum. There is such a wealth of info here that i never found it necessary to post a question. Lastly, to underscore just how unparalleled, flexible and user-friendly the PW backend is, we had the backend training session a couple of weeks after the site was launched, and within 30 minutes, the person who will be managing the content was able to know how to create and manage concerts, blog posts, albums, press articles and more.1 point
-
I am as many others love ❤️the Latte template engine, and I use RockFrontend to integrate it into ProcessWire. One cool feature of Latte is, that you can easily create a comma-separated (or any other separator) list of items, where the last item does not have a separator (comma) afterwards, without needing PHP. Take this code as an example {foreach $pages->get('/myparentpage')->children as $item} {$item->title|noescape}{sep}, {/sep} {/foreach} Which will output a list like: first title, second title, third title If you want to know more use cases take a look at the documentation at https://latte.nette.org/en/tags#toc-first-last-sep1 point
-
1 point
-
This is great! Just a tiny nitpick: I'd swap around the logic for "Don't allow selection in both directions" and get rid of the "Don't", or maybe change it to "Disallow". It's a very cultural and regional thing whether questions with a negation get answered with a yes or no to confirm then, and it's bitten me in the backside myself when I rolled out an app with such a toggle to our international employees.1 point
-
Related tutorial is located here:1 point
-
I have found the solution inside the FieldtypeComments.module from Ryan: * Unlike $pages->find(), pagination of comments is not automatically tied to * $input->pageNum(). As a result, if you paginate, you should specify both * “start=n” and “limit=n” in your selector: * * ~~~~~~ * $limit = 20; * $start = ($input->pageNum() - 1) * $limit; So adding setStart() and setLimit() method will solve the problem. I have only added setLimit() before. $commentPerPage = 20; // how much comments should be displayed $pageNum = wire('input')->pageNum(); $start = ($pageNum - 1) * $commentPerPage; $allComments->setStart($start); Page 1: Page 2:1 point
-
Okay, colleague, I'm writing the report for the boss. Let me know if I've summarized it well: Then I asked ChatGPT to improve it in a more funny way:1 point
-
@monollonom Big help! This problem was caused by my replacing <button> elements with <a> tags for the translate triggers under fields. The fact that my links don't have an href="#" was an oversight because I always add those, but either way that would have caused an issue. I did that so that links could match any custom styles that were done to the admin, which was something that isn't possible if I used a <button> element. I could revert to using a button, but it would be a welcome fix the JS could be patched with more specificity since the # href is very common. In any case, open to input from @ryan In the meantime you can use the previous release as the only changes after this were for cosmetic reasons. Tables will work here: https://github.com/SkyLundy/Fluency/releases/tag/v0.9.0b I've identified the culprit, my code wasn't accounting for hidden template elements that InputfieldTable uses to create new items. I haven't nested a table field in a repeater before, so I'd never have seen this coming. Working on a fix. Thanks for the bug report and the assistance!1 point
-
You could do that with latte also like so: {$pages->get('/')->children->each('title') | join(', ')}1 point
-
Version 0.1.3 is released. This adds a button to generate all currently queued variations: The readme is also updated to add some important notes about 404 configuration in .htaccess and ProCache configuration.1 point
-
I fear I don't have any idea for you, but have you considered using DDEV? I think it would be worth trying, because it is such a great and hassle free dev environment and maybe your issue will be solved as well!1 point
-
When writing API code I often refer to the PW admin to get particular page IDs or field names. And when I'm writing client instructions I often need to insert the labels of particular fields. To make this quicker and more convenient I added some custom JavaScript to the PW admin that copies these things to the clipboard on Alt + click and Ctrl + click. The Page List item and inputfield header are briefly highlighted in yellow to signify that the copying has occurred. This is tested in Windows and I'm not sure if the Alt key / Ctrl key detection is the same for other operating systems but you could adjust the key detection as needed. In /site/ready.php // Add custom JS file to $config->scripts FilenameArray // This adds the custom JS fairly early in the FilenameArray which allows for stopping // event propagation so clicks on InputfieldHeader do not also expand/collapse InputfieldContent $wire->addHookBefore('ProcessController::execute', function(HookEvent $event) { // Optional: for superuser only if(!$event->wire()->user->isSuperuser()) return; $config = $event->wire()->config; $modified = filemtime($config->paths->templates . 'admin-assets/copy-on-click.js'); $js_url = $config->urls->templates . "admin-assets/copy-on-click.js?m=$modified"; $config->scripts->add($js_url); }); In /site/templates/admin-assets/copy-on-click.js $(document).ready(function() { // Copy a string to the clipboard function copyToClipboard(string) { const $temp = $('<input type="text" value="' + string + '">'); $('body').append($temp); $temp.select(); document.execCommand('copy'); $temp.remove(); } // Copy page ID when Page List row is Alt + clicked $(document).on('click', '.PageListItem', function(event) { if(event.altKey) { const classes = $(this).attr('class').split(' '); for(const item of classes) { if(item.startsWith('PageListID')) { const id = item.replace('PageListID', ''); copyToClipboard(id); $(this).effect('highlight', {}, 500); break; } } } }); // When InputfieldHeader is clicked $(document).on('click', '.InputfieldHeader', function(event) { let text = ''; // If Alt + clicked then copy the input name the label is for, or the ID as a fallback if(event.altKey) { event.preventDefault(); event.stopImmediatePropagation(); text = $(this).attr('for'); if(!text) text = $(this).parent().attr('id'); text = text.replace(/^Inputfield_|wrap_Inputfield_|wrap_/, '').trim(); } // If Ctrl + clicked then copy the label text else if(event.ctrlKey) { event.preventDefault(); event.stopImmediatePropagation(); text = $(this).text().trim(); // If AdminOnSteroids is installed use the below instead to exclude text within the AOS field edit link // text = $(this).clone().find('.aos_EditField').remove().end().text().trim(); } if(text) { copyToClipboard(text); $(this).effect('highlight', {}, 500); } }); }); Demo (using the Tracy console only as a convenient place to paste into for demonstration): Copying the field label is useful for getting the name of config fields too, for when you need them in your API code.1 point
-
ProcessWire automatically sanitises the names of files that are uploaded to a Files field. For example, a file named "Café meals under $30.pdf" will become "cafe_meals_under_30.pdf" after it is uploaded. Since v3.0.212 ProcessWire stores the original unsanitised filename of each uploaded file, and this is accessible via $pagefile->uploadName https://processwire.com/blog/posts/pw-3.0.226/#file-and-image-improvements https://processwire.com/api/ref/pagefile/upload-name/ So if I have a field named "files" on my page and I want to provide downloads of the files with their original filename I can output links like this: $out = ''; foreach($page->files as $file) { // uploadName is entity-encoded when output formatting is on $original_name_unencoded = html_entity_decode($file->uploadName); $out .= "<p><a href='$file->url' download='$original_name_unencoded'>$file->uploadName</a></p>"; } echo $out; So far, so good. But I want my site visitors to be able to view PDFs in the browser rather than force a download, yet if they do download them after viewing them I want the files to get the original filename. For this I can use a URL hook to deliver the PDF via PHP rather than directly loading the file. In /site/ready.php: $wire->addHook('/view-pdf/{page_id}/{filename}', function($event) { $id = (int) $event->page_id; $filename = $event->wire()->sanitizer->text($event->filename); if(!$id || !$filename) return 'Invalid request'; // Get the Pagefile via PagefilesManager $pm = $event->wire()->pages->get($id)->filesManager; $file = $pm->getFile($filename); if(!$file) return 'File not found'; // uploadName is entity-encoded when output formatting is on $original_name_unencoded = html_entity_decode($file->uploadName); // Set headers and output the PDF content header("Content-Type: application/pdf"); header("Content-Disposition: inline; filename=$original_name_unencoded"); header("Content-Transfer-Encoding: binary"); header("Accept-Ranges: bytes"); @readfile($file->filename); return true; }); In the page template file: $out = ''; foreach($page->files as $file) { if($file->ext === 'pdf') { // Deliver PDF files via the URL hook $out .= "<p><a href='/view-pdf/$page->id/$file->basename'>$file->uploadName</a></p>"; } else { // Other files receive a download attribute // uploadName is entity-encoded when output formatting is on $original_name_unencoded = html_entity_decode($file->uploadName); $out .= "<p><a href='$file->url' download='$original_name_unencoded'>$file->uploadName</a></p>"; } } echo $out;1 point
-
The upload name is shown in a tooltip when you hover on the file icon in the inputfield: If you want something that's always visible you could hook into the inputfield rendering: // Display upload name in InputfieldFile $wire->addHookAfter('InputfieldFile::renderItem', function(HookEvent $event) { /** @var Pagefile $pagefile */ $pagefile = $event->arguments(0); $event->return = "<p class='upload-name'>$pagefile->uploadName</p>" . $event->return; }); And then style it with some custom admin CSS: .InputfieldFileItem .upload-name { color:white; line-height:1.33; padding:6px 10px; margin:0; background-color:#606060; } There are several ways you can add custom CSS to the PW admin - here is one: // Add custom CSS to admin $wire->addHookAfter('AdminTheme::getExtraMarkup', function(HookEvent $event) { $config = $event->wire()->config; $parts = $event->return; $css_url = $config->urls->templates . 'admin-assets/admin-custom.css'; $modified = filemtime(rtrim($config->paths->root, '/') . $css_url); $parts['head'] .= "<link rel='stylesheet' href='$css_url?m=$modified'>"; $event->return = $parts; }); Result:1 point
-
I have recently started to integrate Twig in my ProcessWire projects to have a better separation between logic and views as well as have cleaner, smaller template files as opposed to large multi-purpose PHP-templates. Though there is a Twig module, I have opted to initialize twig manually to have more control over the structure and settings of the twig environment. This is certainly not required to get started, but I find that having no "secret sauce" gives the me as a developer more agency over my code structure, and better insights into how the internals of the libraries I'm using work. Framework like Drupal or Craft have their own Twig integration, which comes with some opinionated standards on how to organize your templates. Since Twig is not native to ProcessWire, integrating Twig requires one to build a solid template structure to be able to keep adding pages and partials without repeating oneself or having templates grow to unwieldy proportions. This will be an (opinionated) guide on how to organize your own flexible, extensible template system with ProcessWire and Twig, based on the system I developed for some projects at work. Somehow this post got way too long, so I'm splitting it in two parts. I will cover the following topics: Part 1: Extendible template structures How to initialize a custom twig environment and integrate it into ProcessWire How to build an extendible base template for pages, and overwrite it for different ProcessWire templates with custom layouts and logic How to build custom section templates based on layout regions and Repeater Matrix content sections Part 2: Custom functionality and integrations How to customize and add functionality to the twig environment How to bundle your custom functionality into a reusable library Thoughts on handling translations A drop-in template & functions for responsive images as a bonus However, I will not include a general introduction to the Twig language. If you are unfamiliar with Twig, read the Twig guide for Template Designers and Twig for Developers and then come back to this tutorial. That's a lot of stuff, so let's get started ? Initializing the Twig environment First, we need to install Twig. If you set up your site as described in my tutorial on setting up Composer, you can simply install it as a dependency: composer require "twig/twig:^2.0" I'll initialize the twig environment inside a prependTemplateFile and call the main render function inside the appendTemplateFile. You can use this in your config.php: $config->prependTemplateFile = '_init.php'; $config->appendTemplateFile = '_main.php'; Twig needs two things: A FilesystemLoader to load the templates and an Environment to render them. The FilesystemLoader needs the path to the twig template folder. I'll put my templates inside site/twig: $twig_main_dir = $config->paths->site . 'twig'; $twig_loader = new \Twig\Loader\FilesystemLoader($twig_main_dir); As for the environment, there are a few options to consider: $twig_env = new \Twig\Environment( $twig_loader, [ 'cache' => $config->paths->cache . 'twig', 'debug' => $config->debug, 'auto_reload' => $config->debug, 'strict_variables' => false, 'autoescape' => true, ] ); if ($config->debug) { $twig_env->addExtension(new \Twig\Extension\DebugExtension()); } Make sure to include a cache directory, or twig can't cache the compiled templates. The development settings (debug, auto_reload) will be dependent on the ProcessWire debug mode. I turned strict_variables off, since it's easier to check for non-existing and non-empty fields with some parts of the ProcessWire API. You need to decide on an escaping strategy. You can either use the autoescape function of twig, or use textformatters to filter out HTML tags (you can't use both, as it will double escape entities, which will result in a broken frontend). I'm using twig's inbuilt autoescaping, as it's more secure and I don't have to add the HTML entities filter for every single field. This means pretty much no field should use any entity encoding textformatter. I also added the Debug Extension when $config->debug is active. This way, you can use the dump function, which makes debugging templates much easier. Now, all that's left is to add a few handy global variables that all templates will have access to, and initialize an empty array to hold additional variables defined by the individual ProcessWire templates. // add the most important fuel variables to the environment foreach (['page', 'pages', 'config', 'user', 'languages', 'sanitizer'] as $variable) { $twig_env->addGlobal($variable, wire($variable)); }; $twig_env->addGlobal('homepage', $pages->get('/')); $twig_env->addGlobal('settings', $pages->get('/site-settings/')); // each template can add custom variables to this, it // will be passed down to the page template $variables = []; This includes most common fuel variables from ProcessWire, but not all of them. You could also just iterate over all fuel variables and add them all, but I prefer to include only those I will actually need in my templates. The "settings" variable I'm including is simply one page that holds a couple of common settings specific to the site, such as the site logo and site name. Page templates By default, ProcessWire loads a PHP file in the site/templates folder with the same name of the template of the current page; so for a project template/page, that would be site/templates/project.php. In this setup, those files will only include logic and preprocessing that is required for the current page, while the Twig templates will be responsible for actually rendering the markup. We'll get back to the PHP template file later, but for the moment, it can be empty (it just needs to exist, otherwise ProcessWire won't load the page at all). For our main template, we want to have a base html skeleton that all sites inherit, as well as multiple default regions (header, navigation, content, footer, ...) that each template can overwrite as needed. I'll make heavy use of block inheritance for this, so make sure you understand how that works in twig. For example, here's a simplified version of the html skeleton I used for a recent project: {# site/twig/pages/page.twig #} <!doctype html> <html lang="en" dir="ltr"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{% block title %}{{ '%s | %s'|format(page.get('title'), homepage.title) }}{% endblock %}</title> <link rel="stylesheet" type="text/css" href="{{ config.urls.site }}css/main.css"> </head> <body class="{{ page.template }}"> {% block navigation %}{{ include("components/navigation.twig") }}{% endblock %} {% block header %}{{ include("components/header.twig") }}{% endblock %} {% block before_content %}{% endblock %} {% block content %} {# Default content #} {% endblock %} {% block after_content %}{% endblock %} {% block footer %}{{ include("components/footer.twig") }}{% endblock %} </body> </html> All layout regions are defined as twig blocks, so each page template can override them individually, without having to touch those it doesn't need. I'll fill the content block with default content soon, but for now this will do. Now, templates for our content types can just extend this base. This is the template for the homepage: {# site/twig/templates/pages/page--home.twig #} {% extends "pages/page.twig" %} {# No pipe-seperated site name on the homepage #} {% block title page.get('title') %} {# The default header isn't used on the homepage #} {% block header %}{% endblock %} {# The homepage has a custom slider instead of the normal header #} {% block before_content %} {{ include('sections/section--homepage-slider.twig', { classes: ['section--homepage-slider'] }) }} {% endblock %} Note that I don't do most of the actual html markup in the page templates, but in individual section templates (e.g. section--homepage-slider.twig) that can be reused across content types. More on this in the next section. We still need to actually render the template. The page template (which will be the entry point for twig) will be loaded in our _main.php, which we defined as the appendTemplateFile earlier, so it will always be included after the template specific PHP file. $template_file = 'pages/page--' . $page->template->name . '.twig'; $twig_template = file_exists($twig_main_dir . '/' . $template_file) ? $template_file : 'pages/page.twig'; echo $twig_env->render($twig_template, $variables); This function checks if a specific template for the current content type exists (e.g. pages/page--home.twig) and falls back to the default page template if it doesn't (e.g. pages/page.twig). This way, if you want a blank slate for a specific content type, you can just write a twig template that doesn't extend page.twig, and you will get a blank page ready to be filled with whatever you want. Note that it passes the $variables we initialized in the _init.php. This way, if you need to do any preprocessing or data crunching for this request, you can do it inside the PHP template and include the results in the $variables array. At this point, you can start building your default components (header, footer, navigation, et c.) and they will be included on every site that extends the base page template. Custom sections Now we've done a great deal of setup but haven't actually written much markup yet. But now that we have a solid foundation, we can add layout components very easily. For most of my projects, I use the brilliant Repeater Matrix module to set up dynamic content sections / blocks (this is not the focus of this tutorial, here's a detailed explanation). The module does have it's own built-in template file structure to render it's blocks, but since it won't work with my Twig setup, I'll create some custom twig templates for this. The approach will be the same as with the page template itself: create a "base" section template that includes some boilerplate HTML for recurring markup (such as a container element to wrap the section content in) and defines sections that can be overwritted by section-specific templates. First, let's create a template that will iterate over our the Repeater Matrix field (here it's called sections) and include the relevant template for each repeater matrix type: {# components/sections.twig #} {% for section in page.sections %} {% set template = 'sections/section--' ~ section.type ~ '.twig' %} {{ include( [template, 'sections/section.twig'], { section: section }, with_context = false ) }} {% endfor %} Note that the array syntax in the include function tells Twig to render the first template that exists. So for a section called downloads, it will look look for the template sections/section--downloads.twig and fallback to the generic sections/section.twig. The generic section template will only include the fields that are common to all sections. In my case, each section will have a headline (section_headline) and a select field to choose a background colour (section_background) : {# sections/section.twig #} {% set section_classes = [ 'section', section.type ? 'section--' ~ section.type, section.section_background.first.value ? 'section--' ~ section.section_background.first.value ] %} <div class="{{ section_classes|join(' ')|trim }}"> <section class="container"> {% block section_headline %} {% if section.section_headline %} <h2 class="section__headline">{{ section.section_headline }}</h2> {% endif %} {% endblock %} {% block section_content %} {{ section.type }} {% endblock %} </section> </div> This section template generates classes based on the section type and background colour (for example: section section--downloads section--green) so that I can add corresponding styling with CSS / SASS. The specific templates for each section will extend this base template and fill the block section_content with their custom markup. For example, for our downloads section (assuming it contains a multivalue files field download_files): {# sections/section--download.twig #} {% extends "sections/section.twig" %} {% block section_content %} <ul class="downloads"> {% for download in section.download_files %} <li class="downloads__row"> <a href="{{ download.file.url }}" download class="downloads__link"> {{ download.description ?: download.basename }} </a> </li> {% endfor %} </ul> {% endblock %} It took some setup, but now every section needs only care about their own unique fields and markup without having to include repetitive boilerplate markup. And it's still completely extensible: If you need, for example, a full-width block, you can just not extend the base section template and get a clean slate. Also, you can always go back to the base template and add more blocks as needed, without having to touch all the other sections. By the way, you can also extend the base section template from everywhere you want, not only from repeater matrix types. Now we only need to include the sections component inside the page template {# pages/page.twig #} {% block content %} {% if page.hasField('sections') and page.sections.count %} {{ include('components/sections.twig' }} {% endif %} {% endblock %} Conclusion This first part was mostly about a clean environment setup and template structure. By now you've probably got a good grasp on how I organize my twig templates into folders, depending on their role and importance: blocks: This contains reusable blocks that will be included many times, such as a responsive image block or a link block. components: This contains special regions such as the header and footer that will probably be only used once per page. sections: This will contain reusable, self-contained sections mostly based on the Repeater Matrix types. They may be used multiple times on one page. pages: High-level templates corresponding to ProcessWire templates. Those contain very little markup, but mostly include the appropriate components and sections. Depending on the size of the project, you could increase or decrease the granularity of this as needed, for example by grouping the sections into different subfolders. But it's a solid start for most medium-sized projects I tackle. Anyway, thanks for reading! I'll post the next part in a couple of days, where I will go over how to add more functionality into your environment in a scalable and reusable way. In the meantime, let me know how you would improve this setup!1 point