diff --git a/_config/view.yml b/_config/view.yml
new file mode 100644
index 000000000..fd8293c9f
--- /dev/null
+++ b/_config/view.yml
@@ -0,0 +1,6 @@
+---
+Name: view-config
+---
+SilverStripe\Core\Injector\Injector:
+ SilverStripe\View\TemplateEngine:
+ class: 'SilverStripe\View\SSTemplateEngine'
diff --git a/src/Control/Controller.php b/src/Control/Controller.php
index d1abe672f..77582a08f 100644
--- a/src/Control/Controller.php
+++ b/src/Control/Controller.php
@@ -3,11 +3,14 @@
namespace SilverStripe\Control;
use SilverStripe\Core\ClassInfo;
+use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Debug;
+use SilverStripe\Model\ModelData;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\Security\Member;
use SilverStripe\Security\Security;
use SilverStripe\View\SSViewer;
+use SilverStripe\View\TemplateEngine;
use SilverStripe\View\TemplateGlobalProvider;
/**
@@ -88,6 +91,8 @@ class Controller extends RequestHandler implements TemplateGlobalProvider
'handleIndex',
];
+ private ?TemplateEngine $templateEngine = null;
+
public function __construct()
{
parent::__construct();
@@ -401,7 +406,7 @@ class Controller extends RequestHandler implements TemplateGlobalProvider
$templates = array_unique(array_merge($actionTemplates, $classTemplates));
}
- return SSViewer::create($templates);
+ return SSViewer::create($templates, $this->getTemplateEngine());
}
/**
@@ -453,9 +458,10 @@ class Controller extends RequestHandler implements TemplateGlobalProvider
}
$class = static::class;
- while ($class != 'SilverStripe\\Control\\RequestHandler') {
+ $engine = $this->getTemplateEngine();
+ while ($class !== RequestHandler::class) {
$templateName = strtok($class ?? '', '_') . '_' . $action;
- if (SSViewer::hasTemplate($templateName)) {
+ if ($engine->hasTemplate($templateName)) {
return $class;
}
@@ -487,17 +493,25 @@ class Controller extends RequestHandler implements TemplateGlobalProvider
$parentClass = get_parent_class($parentClass ?? '');
}
- return SSViewer::hasTemplate($templates);
+ $engine = $this->getTemplateEngine();
+ return $engine->hasTemplate($templates);
+ }
+
+ public function renderWith($template, ModelData|array $customFields = []): DBHTMLText
+ {
+ // Ensure template engine is used, unless the viewer was already explicitly instantiated
+ if (!($template instanceof SSViewer)) {
+ $template = SSViewer::create($template, $this->getTemplateEngine());
+ }
+ return parent::renderWith($template, $customFields);
}
/**
* Render the current controller with the templates determined by {@link getViewer()}.
*
* @param array $params
- *
- * @return string
*/
- public function render($params = null)
+ public function render($params = null): DBHTMLText
{
$template = $this->getViewer($this->getAction());
@@ -737,4 +751,12 @@ class Controller extends RequestHandler implements TemplateGlobalProvider
'CurrentPage' => 'curr',
];
}
+
+ protected function getTemplateEngine(): TemplateEngine
+ {
+ if (!$this->templateEngine) {
+ $this->templateEngine = Injector::inst()->create(TemplateEngine::class);
+ }
+ return $this->templateEngine;
+ }
}
diff --git a/src/Control/RSS/RSSFeed_Entry.php b/src/Control/RSS/RSSFeed_Entry.php
index 1ebaae7e7..66034d711 100644
--- a/src/Control/RSS/RSSFeed_Entry.php
+++ b/src/Control/RSS/RSSFeed_Entry.php
@@ -47,7 +47,7 @@ class RSSFeed_Entry extends ModelData
*/
public function __construct($entry, $titleField, $descriptionField, $authorField)
{
- $this->failover = $entry;
+ $this->setFailover($entry);
$this->titleField = $titleField;
$this->descriptionField = $descriptionField;
$this->authorField = $authorField;
@@ -58,7 +58,7 @@ class RSSFeed_Entry extends ModelData
/**
* Get the description of this entry
*
- * @return DBField Returns the description of the entry.
+ * @return DBField|null Returns the description of the entry.
*/
public function Title()
{
@@ -68,7 +68,7 @@ class RSSFeed_Entry extends ModelData
/**
* Get the description of this entry
*
- * @return DBField Returns the description of the entry.
+ * @return DBField|null Returns the description of the entry.
*/
public function Description()
{
@@ -85,7 +85,7 @@ class RSSFeed_Entry extends ModelData
/**
* Get the author of this entry
*
- * @return DBField Returns the author of the entry.
+ * @return DBField|null Returns the author of the entry.
*/
public function Author()
{
@@ -96,7 +96,7 @@ class RSSFeed_Entry extends ModelData
* Return the safely casted field
*
* @param string $fieldName Name of field
- * @return DBField
+ * @return DBField|null
*/
public function rssField($fieldName)
{
diff --git a/src/Dev/Backtrace.php b/src/Dev/Backtrace.php
index 62d402efc..9aa7b85ad 100644
--- a/src/Dev/Backtrace.php
+++ b/src/Dev/Backtrace.php
@@ -149,11 +149,11 @@ class Backtrace
if ($showArgs && isset($item['args'])) {
$args = [];
foreach ($item['args'] as $arg) {
- if (!is_object($arg) || method_exists($arg, '__toString')) {
+ if (is_object($arg)) {
+ $args[] = get_class($arg);
+ } else {
$sarg = is_array($arg) ? 'Array' : strval($arg);
$args[] = (strlen($sarg ?? '') > $argCharLimit) ? substr($sarg, 0, $argCharLimit) . '...' : $sarg;
- } else {
- $args[] = get_class($arg);
}
}
diff --git a/src/Forms/DropdownField.php b/src/Forms/DropdownField.php
index ed5da3000..9e3124525 100644
--- a/src/Forms/DropdownField.php
+++ b/src/Forms/DropdownField.php
@@ -68,7 +68,7 @@ use SilverStripe\Model\ArrayData;
* DropdownField::create(
* 'Country',
* 'Country',
- * singleton(MyObject::class)->dbObject('Country')->enumValues()
+ * singleton(MyObject::class)->dbObject('Country')?->enumValues()
* );
*
*
diff --git a/src/Forms/FieldGroup.php b/src/Forms/FieldGroup.php
index 9a0d6c675..c61de2136 100644
--- a/src/Forms/FieldGroup.php
+++ b/src/Forms/FieldGroup.php
@@ -154,7 +154,7 @@ class FieldGroup extends CompositeField
/** @var FormField $subfield */
$messages = [];
foreach ($dataFields as $subfield) {
- $message = $subfield->obj('Message')->forTemplate();
+ $message = $subfield->obj('Message')?->forTemplate();
if ($message) {
$messages[] = rtrim($message ?? '', ".");
}
diff --git a/src/Forms/Form.php b/src/Forms/Form.php
index 7ce206f8d..1a02ca27f 100644
--- a/src/Forms/Form.php
+++ b/src/Forms/Form.php
@@ -899,10 +899,10 @@ class Form extends ModelData implements HasRequestHandler
}
/**
- * Set the SS template that this form should use
+ * Set the template or template candidates that this form should use
* to render with. The default is "Form".
*
- * @param string|array $template The name of the template (without the .ss extension) or array form
+ * @param string|array $template The name of the template (without the file extension) or array of candidates
* @return $this
*/
public function setTemplate($template)
diff --git a/src/Forms/FormField.php b/src/Forms/FormField.php
index 0d210436b..264090010 100644
--- a/src/Forms/FormField.php
+++ b/src/Forms/FormField.php
@@ -15,6 +15,7 @@ use SilverStripe\Core\Validation\ValidationResult;
use SilverStripe\View\AttributesHTML;
use SilverStripe\View\SSViewer;
use SilverStripe\Model\ModelData;
+use SilverStripe\ORM\DataObject;
/**
* Represents a field in a form.
@@ -458,7 +459,7 @@ class FormField extends RequestHandler
*
* By default, makes use of $this->dataValue()
*
- * @param ModelData|DataObjectInterface $record Record to save data into
+ * @param DataObjectInterface $record Record to save data into
*/
public function saveInto(DataObjectInterface $record)
{
@@ -469,7 +470,9 @@ class FormField extends RequestHandler
if (($pos = strrpos($this->name ?? '', '.')) !== false) {
$relation = substr($this->name ?? '', 0, $pos);
$fieldName = substr($this->name ?? '', $pos + 1);
- $component = $record->relObject($relation);
+ if ($record instanceof DataObject) {
+ $component = $record->relObject($relation);
+ }
}
if ($fieldName && $component) {
@@ -1469,12 +1472,12 @@ class FormField extends RequestHandler
'schemaType' => $this->getSchemaDataType(),
'component' => $this->getSchemaComponent(),
'holderId' => $this->HolderID(),
- 'title' => $this->obj('Title')->getSchemaValue(),
+ 'title' => $this->obj('Title')?->getSchemaValue(),
'source' => null,
'extraClass' => $this->extraClass(),
- 'description' => $this->obj('Description')->getSchemaValue(),
- 'rightTitle' => $this->obj('RightTitle')->getSchemaValue(),
- 'leftTitle' => $this->obj('LeftTitle')->getSchemaValue(),
+ 'description' => $this->obj('Description')?->getSchemaValue(),
+ 'rightTitle' => $this->obj('RightTitle')?->getSchemaValue(),
+ 'leftTitle' => $this->obj('LeftTitle')?->getSchemaValue(),
'readOnly' => $this->isReadonly(),
'disabled' => $this->isDisabled(),
'customValidationMessage' => $this->getCustomValidationMessage(),
diff --git a/src/Forms/FormScaffolder.php b/src/Forms/FormScaffolder.php
index 099dabf5d..db43a88e8 100644
--- a/src/Forms/FormScaffolder.php
+++ b/src/Forms/FormScaffolder.php
@@ -115,7 +115,7 @@ class FormScaffolder
$fieldObject = $this
->obj
->dbObject($fieldName)
- ->scaffoldFormField(null, $this->getParamsArray());
+ ?->scaffoldFormField(null, $this->getParamsArray());
}
// Allow fields to opt-out of scaffolding
if (!$fieldObject) {
@@ -145,7 +145,7 @@ class FormScaffolder
$fieldClass = $this->fieldClasses[$fieldName];
$hasOneField = new $fieldClass($fieldName);
} else {
- $hasOneField = $this->obj->dbObject($fieldName)->scaffoldFormField(null, $this->getParamsArray());
+ $hasOneField = $this->obj->dbObject($fieldName)?->scaffoldFormField(null, $this->getParamsArray());
}
if (empty($hasOneField)) {
continue; // Allow fields to opt out of scaffolding
diff --git a/src/Forms/GridField/GridFieldAddExistingAutocompleter.php b/src/Forms/GridField/GridFieldAddExistingAutocompleter.php
index 3c8b0aac0..19ed77c64 100644
--- a/src/Forms/GridField/GridFieldAddExistingAutocompleter.php
+++ b/src/Forms/GridField/GridFieldAddExistingAutocompleter.php
@@ -17,6 +17,7 @@ use SilverStripe\Model\ArrayData;
use SilverStripe\View\SSViewer;
use LogicException;
use SilverStripe\Control\HTTPResponse_Exception;
+use SilverStripe\View\SSViewer_FromString;
/**
* This class is is responsible for adding objects to another object's has_many
@@ -283,12 +284,12 @@ class GridFieldAddExistingAutocompleter extends AbstractGridFieldComponent imple
$json = [];
Config::nest();
SSViewer::config()->set('source_file_comments', false);
- $viewer = SSViewer::fromString($this->resultsFormat);
+ $viewer = SSViewer_FromString::create($this->resultsFormat);
foreach ($results as $result) {
if (!$result->canView()) {
continue;
}
- $title = Convert::html2raw($viewer->process($result));
+ $title = Convert::html2raw($viewer->process($result, cache: false));
$json[] = [
'label' => $title,
'value' => $title,
diff --git a/src/Forms/HTMLEditor/HTMLEditorField.php b/src/Forms/HTMLEditor/HTMLEditorField.php
index 90c3fad75..4527dd1de 100644
--- a/src/Forms/HTMLEditor/HTMLEditorField.php
+++ b/src/Forms/HTMLEditor/HTMLEditorField.php
@@ -5,9 +5,11 @@ namespace SilverStripe\Forms\HTMLEditor;
use SilverStripe\Assets\Shortcodes\ImageShortcodeProvider;
use SilverStripe\Forms\FormField;
use SilverStripe\Forms\TextareaField;
-use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use Exception;
+use SilverStripe\Model\ModelData;
+use SilverStripe\ORM\FieldType\DBField;
+use SilverStripe\View\CastingService;
use SilverStripe\View\Parsers\HTMLValue;
/**
@@ -123,13 +125,9 @@ class HTMLEditorField extends TextareaField
);
}
- /**
- * @param DataObject|DataObjectInterface $record
- * @throws Exception
- */
public function saveInto(DataObjectInterface $record)
{
- if ($record->hasField($this->name) && $record->escapeTypeForField($this->name) != 'xml') {
+ if (!$this->usesXmlFriendlyField($record)) {
throw new Exception(
'HTMLEditorField->saveInto(): This field should save into a HTMLText or HTMLVarchar field.'
);
@@ -225,4 +223,15 @@ class HTMLEditorField extends TextareaField
return $config;
}
+
+ private function usesXmlFriendlyField(DataObjectInterface $record): bool
+ {
+ if ($record instanceof ModelData && !$record->hasField($this->getName())) {
+ return true;
+ }
+
+ $castingService = CastingService::singleton();
+ $castValue = $castingService->cast($this->Value(), $record, $this->getName());
+ return $castValue instanceof DBField && $castValue::config()->get('escape_type') === 'xml';
+ }
}
diff --git a/src/Forms/TreeDropdownField.php b/src/Forms/TreeDropdownField.php
index c503c591a..5bf454f55 100644
--- a/src/Forms/TreeDropdownField.php
+++ b/src/Forms/TreeDropdownField.php
@@ -7,6 +7,7 @@ use InvalidArgumentException;
use SilverStripe\Assets\Folder;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\HTTPResponse;
+use SilverStripe\Model\List\SS_List;
use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\FieldType\DBDatetime;
@@ -519,13 +520,20 @@ class TreeDropdownField extends FormField implements HasOneRelationFieldInterfac
// Allow to pass values to be selected within the ajax request
$value = $request->requestVar('forceValue') ?: $this->value;
- if ($value && ($values = preg_split('/,\s*/', $value ?? ''))) {
+ if ($value instanceof SS_List) {
+ $values = $value;
+ } elseif ($value) {
+ $values = preg_split('/,\s*/', $value ?? '');
+ } else {
+ $values = [];
+ }
+ if (!empty($values)) {
foreach ($values as $value) {
if (!$value || $value == 'unchanged') {
continue;
}
- $object = $this->objectForKey($value);
+ $object = is_object($value) ? $value : $this->objectForKey($value);
if (!$object) {
continue;
}
@@ -870,14 +878,14 @@ class TreeDropdownField extends FormField implements HasOneRelationFieldInterfac
$ancestors = $record->getAncestors(true)->reverse();
foreach ($ancestors as $parent) {
- $title = $parent->obj($this->getTitleField())->getValue();
+ $title = $parent->obj($this->getTitleField())?->getValue();
$titlePath .= $title . '/';
}
}
$data['data']['valueObject'] = [
- 'id' => $record->obj($this->getKeyField())->getValue(),
- 'title' => $record->obj($this->getTitleField())->getValue(),
- 'treetitle' => $record->obj($this->getLabelField())->getSchemaValue(),
+ 'id' => $record->obj($this->getKeyField())?->getValue(),
+ 'title' => $record->obj($this->getTitleField())?->getValue(),
+ 'treetitle' => $record->obj($this->getLabelField())?->getSchemaValue(),
'titlePath' => $titlePath,
];
}
diff --git a/src/Forms/TreeMultiselectField.php b/src/Forms/TreeMultiselectField.php
index a1362f247..449a275fe 100644
--- a/src/Forms/TreeMultiselectField.php
+++ b/src/Forms/TreeMultiselectField.php
@@ -92,10 +92,10 @@ class TreeMultiselectField extends TreeDropdownField
foreach ($items as $item) {
if ($item instanceof DataObject) {
$values[] = [
- 'id' => $item->obj($this->getKeyField())->getValue(),
- 'title' => $item->obj($this->getTitleField())->getValue(),
+ 'id' => $item->obj($this->getKeyField())?->getValue(),
+ 'title' => $item->obj($this->getTitleField())?->getValue(),
'parentid' => $item->ParentID,
- 'treetitle' => $item->obj($this->getLabelField())->getSchemaValue(),
+ 'treetitle' => $item->obj($this->getLabelField())?->getSchemaValue(),
];
} else {
$values[] = $item;
@@ -212,7 +212,7 @@ class TreeMultiselectField extends TreeDropdownField
foreach ($items as $item) {
$idArray[] = $item->ID;
$titleArray[] = ($item instanceof ModelData)
- ? $item->obj($this->getLabelField())->forTemplate()
+ ? $item->obj($this->getLabelField())?->forTemplate()
: Convert::raw2xml($item->{$this->getLabelField()});
}
diff --git a/src/Model/List/ListDecorator.php b/src/Model/List/ListDecorator.php
index 6cfc963b4..fa3c43dae 100644
--- a/src/Model/List/ListDecorator.php
+++ b/src/Model/List/ListDecorator.php
@@ -56,7 +56,9 @@ abstract class ListDecorator extends ModelData implements SS_List, Sortable, Fil
public function setList(SS_List&Sortable&Filterable&Limitable $list): ListDecorator
{
$this->list = $list;
- $this->failover = $this->list;
+ if ($list instanceof ModelData) {
+ $this->setFailover($list);
+ }
return $this;
}
diff --git a/src/Model/ModelData.php b/src/Model/ModelData.php
index 04d5a1fc0..e001de45e 100644
--- a/src/Model/ModelData.php
+++ b/src/Model/ModelData.php
@@ -12,14 +12,14 @@ use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Extensible;
use SilverStripe\Core\Injector\Injectable;
-use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Debug;
use SilverStripe\Core\ArrayLib;
-use SilverStripe\Model\List\ArrayList;
use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\Model\ArrayData;
+use SilverStripe\View\CastingService;
use SilverStripe\View\SSViewer;
+use Stringable;
use UnexpectedValueException;
/**
@@ -29,7 +29,7 @@ use UnexpectedValueException;
* is provided and automatically escaped by ModelData. Any class that needs to be available to a view (controllers,
* {@link DataObject}s, page controls) should inherit from this class.
*/
-class ModelData
+class ModelData implements Stringable
{
use Extensible {
defineMethods as extensibleDefineMethods;
@@ -38,7 +38,7 @@ class ModelData
use Configurable;
/**
- * An array of objects to cast certain fields to. This is set up as an array in the format:
+ * An array of DBField classes to cast certain fields to. This is set up as an array in the format:
*
*
* public static $casting = array (
@@ -47,16 +47,18 @@ class ModelData
*
*/
private static array $casting = [
- 'CSSClasses' => 'Varchar'
+ 'CSSClasses' => 'Varchar',
+ 'forTemplate' => 'HTMLText',
];
/**
- * The default object to cast scalar fields to if casting information is not specified, and casting to an object
+ * The default class to cast scalar fields to if casting information is not specified, and casting to an object
* is required.
+ * This can be any injectable service name but must resolve to a DBField subclass.
+ *
+ * If null, casting will be determined based on the type of value (e.g. integers will be cast to DBInt)
*/
- private static string $default_cast = 'Text';
-
- private static array $casting_cache = [];
+ private static ?string $default_cast = null;
/**
* Acts as a PHP 8.2+ compliant replacement for dynamic properties
@@ -251,8 +253,7 @@ class ModelData
// -----------------------------------------------------------------------------------------------------------------
/**
- * Add methods from the {@link ModelData::$failover} object, as well as wrapping any methods prefixed with an
- * underscore into a {@link ModelData::cachedCall()}.
+ * Add methods from the {@link ModelData::$failover} object
*
* @throws LogicException
*/
@@ -305,12 +306,18 @@ class ModelData
return true;
}
- /**
- * Return the class name (though subclasses may return something else)
- */
public function __toString(): string
{
- return static::class;
+ return $this->forTemplate();
+ }
+
+ /**
+ * Return the HTML markup that represents this model when it is directly injected into a template (e.g. using $Me).
+ * By default this attempts to render the model using templates based on the class hierarchy.
+ */
+ public function forTemplate(): string
+ {
+ return $this->renderWith($this->getViewerTemplates());
}
public function getCustomisedObj(): ?ModelData
@@ -326,14 +333,10 @@ class ModelData
// CASTING ---------------------------------------------------------------------------------------------------------
/**
- * Return the "casting helper" (a piece of PHP code that when evaluated creates a casted value object)
+ * Return the "casting helper" (an injectable service name)
* for a field on this object. This helper will be a subclass of DBField.
- *
- * @param bool $useFallback If true, fall back on the default casting helper if there isn't an explicit one.
- * @return string|null Casting helper As a constructor pattern, and may include arguments.
- * @throws Exception
*/
- public function castingHelper(string $field, bool $useFallback = true): ?string
+ public function castingHelper(string $field): ?string
{
// Get casting if it has been configured.
// DB fields and PHP methods are all case insensitive so we normalise casing before checking.
@@ -346,67 +349,15 @@ class ModelData
// If no specific cast is declared, fall back to failover.
$failover = $this->getFailover();
if ($failover) {
- $cast = $failover->castingHelper($field, $useFallback);
+ $cast = $failover->castingHelper($field);
if ($cast) {
return $cast;
}
}
- if ($useFallback) {
- return $this->defaultCastingHelper($field);
- }
-
return null;
}
- /**
- * Return the default "casting helper" for use when no explicit casting helper is defined.
- * This helper will be a subclass of DBField. See castingHelper()
- */
- protected function defaultCastingHelper(string $field): string
- {
- // If there is a failover, the default_cast will always
- // be drawn from this object instead of the top level object.
- $failover = $this->getFailover();
- if ($failover) {
- $cast = $failover->defaultCastingHelper($field);
- if ($cast) {
- return $cast;
- }
- }
-
- // Fall back to raw default_cast
- $default = $this->config()->get('default_cast');
- if (empty($default)) {
- throw new Exception('No default_cast');
- }
- return $default;
- }
-
- /**
- * Get the class name a field on this object will be casted to.
- */
- public function castingClass(string $field): string
- {
- // Strip arguments
- $spec = $this->castingHelper($field);
- return trim(strtok($spec ?? '', '(') ?? '');
- }
-
- /**
- * Return the string-format type for the given field.
- *
- * @return string 'xml'|'raw'
- */
- public function escapeTypeForField(string $field): string
- {
- $class = $this->castingClass($field) ?: $this->config()->get('default_cast');
-
- /** @var DBField $type */
- $type = Injector::inst()->get($class, true);
- return $type->config()->get('escape_type');
- }
-
// TEMPLATE ACCESS LAYER -------------------------------------------------------------------------------------------
/**
@@ -417,9 +368,9 @@ class ModelData
* - an SSViewer instance
*
* @param string|array|SSViewer $template the template to render into
- * @param ModelData|array|null $customFields fields to customise() the object with before rendering
+ * @param ModelData|array $customFields fields to customise() the object with before rendering
*/
- public function renderWith($template, ModelData|array|null $customFields = null): DBHTMLText
+ public function renderWith($template, ModelData|array $customFields = []): DBHTMLText
{
if (!is_object($template)) {
$template = SSViewer::create($template);
@@ -429,9 +380,10 @@ class ModelData
if ($customFields instanceof ModelData) {
$data = $data->customise($customFields);
+ $customFields = [];
}
if ($template instanceof SSViewer) {
- return $template->process($data, is_array($customFields) ? $customFields : null);
+ return $template->process($data, $customFields);
}
throw new UnexpectedValueException(
@@ -440,27 +392,11 @@ class ModelData
}
/**
- * Generate the cache name for a field
- *
- * @param string $fieldName Name of field
- * @param array $arguments List of optional arguments given
- * @return string
+ * Get a cached value from the field cache for a field
*/
- protected function objCacheName($fieldName, $arguments)
- {
- return $arguments
- ? $fieldName . ":" . var_export($arguments, true)
- : $fieldName;
- }
-
- /**
- * Get a cached value from the field cache
- *
- * @param string $key Cache key
- * @return mixed
- */
- protected function objCacheGet($key)
+ public function objCacheGet(string $fieldName, array $arguments = []): mixed
{
+ $key = $this->objCacheName($fieldName, $arguments);
if (isset($this->objCache[$key])) {
return $this->objCache[$key];
}
@@ -468,14 +404,11 @@ class ModelData
}
/**
- * Store a value in the field cache
- *
- * @param string $key Cache key
- * @param mixed $value
- * @return $this
+ * Store a value in the field cache for a field
*/
- protected function objCacheSet($key, $value)
+ public function objCacheSet(string $fieldName, array $arguments, mixed $value): static
{
+ $key = $this->objCacheName($fieldName, $arguments);
$this->objCache[$key] = $value;
return $this;
}
@@ -485,7 +418,7 @@ class ModelData
*
* @return $this
*/
- protected function objCacheClear()
+ public function objCacheClear()
{
$this->objCache = [];
return $this;
@@ -497,82 +430,40 @@ class ModelData
*
* @return object|DBField|null The specific object representing the field, or null if there is no
* property, method, or dynamic data available for that field.
- * Note that if there is a property or method that returns null, a relevant DBField instance will
- * be returned.
*/
public function obj(
string $fieldName,
array $arguments = [],
- bool $cache = false,
- ?string $cacheName = null
+ bool $cache = false
): ?object {
- $hasObj = false;
- if (!$cacheName && $cache) {
- $cacheName = $this->objCacheName($fieldName, $arguments);
- }
-
// Check pre-cached value
- $value = $cache ? $this->objCacheGet($cacheName) : null;
- if ($value !== null) {
- return $value;
- }
+ $value = $cache ? $this->objCacheGet($fieldName, $arguments) : null;
+ if ($value === null) {
+ $hasObj = false;
+ // Load value from record
+ if ($this->hasMethod($fieldName)) {
+ // Try methods first - there's a LOT of logic that assumes this will be checked first.
+ $hasObj = true;
+ $value = call_user_func_array([$this, $fieldName], $arguments ?: []);
+ } else {
+ // Try fields and getters if there was no method with that name.
+ $hasObj = $this->hasField($fieldName) || ($this->hasMethod("get{$fieldName}") && $this->isAccessibleMethod("get{$fieldName}"));
+ $value = $this->$fieldName;// @TODO may need _get() explicitly here
+ }
- // Load value from record
- if ($this->hasMethod($fieldName)) {
- $hasObj = true;
- $value = call_user_func_array([$this, $fieldName], $arguments ?: []);
- } else {
- $hasObj = $this->hasField($fieldName) || ($this->hasMethod("get{$fieldName}") && $this->isAccessibleMethod("get{$fieldName}"));
- $value = $this->$fieldName;
- }
+ // Record in cache
+ if ($value !== null && $cache) {
+ $this->objCacheSet($fieldName, $arguments, $value);
+ }
- // Return null early if there's no backing for this field
- // i.e. no poperty, no method, etc - it just doesn't exist on this model.
- if (!$hasObj && $value === null) {
- return null;
- }
-
- // Try to cast object if we have an explicit cast set
- if (!is_object($value)) {
- $castingHelper = $this->castingHelper($fieldName, false);
- if ($castingHelper !== null) {
- $valueObject = Injector::inst()->create($castingHelper, $fieldName);
- $valueObject->setValue($value, $this);
- $value = $valueObject;
+ // Return null early if there's no backing for this field
+ // i.e. no poperty, no method, etc - it just doesn't exist on this model.
+ if (!$hasObj && $value === null) {
+ return null;
}
}
- // Wrap list arrays in ModelData so templates can handle them
- if (is_array($value) && array_is_list($value)) {
- $value = ArrayList::create($value);
- }
-
- // Fallback on default casting
- if (!is_object($value)) {
- // Force cast
- $castingHelper = $this->defaultCastingHelper($fieldName);
- $valueObject = Injector::inst()->create($castingHelper, $fieldName);
- $valueObject->setValue($value, $this);
- $value = $valueObject;
- }
-
- // Record in cache
- if ($cache) {
- $this->objCacheSet($cacheName, $value);
- }
-
- return $value;
- }
-
- /**
- * A simple wrapper around {@link ModelData::obj()} that automatically caches the result so it can be used again
- * without re-running the method.
- *
- * @return Object|DBField
- */
- public function cachedCall(string $fieldName, array $arguments = [], ?string $cacheName = null): object
- {
- return $this->obj($fieldName, $arguments, true, $cacheName);
+ return CastingService::singleton()->cast($value, $this, $fieldName, true);
}
/**
@@ -677,4 +568,14 @@ class ModelData
{
return ModelDataDebugger::create($this);
}
+
+ /**
+ * Generate the cache name for a field
+ */
+ private function objCacheName(string $fieldName, array $arguments = []): string
+ {
+ return empty($arguments)
+ ? $fieldName
+ : $fieldName . ":" . var_export($arguments, true);
+ }
}
diff --git a/src/Model/ModelDataCustomised.php b/src/Model/ModelDataCustomised.php
index 6ae73be21..bc86d4a72 100644
--- a/src/Model/ModelDataCustomised.php
+++ b/src/Model/ModelDataCustomised.php
@@ -49,17 +49,22 @@ class ModelDataCustomised extends ModelData
return isset($this->customised->$property) || isset($this->original->$property) || parent::__isset($property);
}
+ public function forTemplate(): string
+ {
+ return $this->original->forTemplate();
+ }
+
public function hasMethod($method)
{
return $this->customised->hasMethod($method) || $this->original->hasMethod($method);
}
- public function cachedCall(string $fieldName, array $arguments = [], ?string $cacheName = null): object
+ public function castingHelper(string $field): ?string
{
- if ($this->customisedHas($fieldName)) {
- return $this->customised->cachedCall($fieldName, $arguments, $cacheName);
+ if ($this->customisedHas($field)) {
+ return $this->customised->castingHelper($field);
}
- return $this->original->cachedCall($fieldName, $arguments, $cacheName);
+ return $this->original->castingHelper($field);
}
public function obj(
@@ -74,10 +79,15 @@ class ModelDataCustomised extends ModelData
return $this->original->obj($fieldName, $arguments, $cache, $cacheName);
}
- private function customisedHas(string $fieldName): bool
+ public function customisedHas(string $fieldName): bool
{
return property_exists($this->customised, $fieldName) ||
$this->customised->hasField($fieldName) ||
$this->customised->hasMethod($fieldName);
}
+
+ public function getCustomisedModelData(): ?ModelData
+ {
+ return $this->customised;
+ }
}
diff --git a/src/ORM/DataList.php b/src/ORM/DataList.php
index d703d3b90..e8d69f27f 100644
--- a/src/ORM/DataList.php
+++ b/src/ORM/DataList.php
@@ -19,6 +19,7 @@ use SilverStripe\Model\List\Limitable;
use SilverStripe\Model\List\Map;
use SilverStripe\Model\List\Sortable;
use SilverStripe\Model\List\SS_List;
+use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\Filters\SearchFilterable;
/**
@@ -1852,7 +1853,7 @@ class DataList extends ModelData implements SS_List, Filterable, Sortable, Limit
return $relation;
}
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
return singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php
index 2b6bed1da..913250645 100644
--- a/src/ORM/DataObject.php
+++ b/src/ORM/DataObject.php
@@ -104,9 +104,6 @@ use stdClass;
* }
*
*
- * If any public method on this class is prefixed with an underscore,
- * the results are cached in memory through {@link cachedCall()}.
- *
* @property int $ID ID of the DataObject, 0 if the DataObject doesn't exist in database.
* @property int $OldID ID of object, if deleted
* @property string $Title
@@ -3033,7 +3030,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
/**
* {@inheritdoc}
*/
- public function castingHelper(string $field, bool $useFallback = true): ?string
+ public function castingHelper(string $field): ?string
{
$fieldSpec = static::getSchema()->fieldSpec(static::class, $field);
if ($fieldSpec) {
@@ -3051,7 +3048,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
}
}
- return parent::castingHelper($field, $useFallback);
+ return parent::castingHelper($field);
}
/**
@@ -3234,11 +3231,11 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
* - it still returns an object even when the field has no value.
* - it only matches fields and not methods
* - it matches foreign keys generated by has_one relationships, eg, "ParentID"
+ * - if the field exists, the return value is ALWAYS a DBField instance
*
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
+ * Returns null if the field doesn't exist
*/
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
// Check for field in DB
$schema = static::getSchema();
@@ -3306,7 +3303,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
} elseif ($component instanceof Relation || $component instanceof DataList) {
// $relation could either be a field (aggregate), or another relation
$singleton = DataObject::singleton($component->dataClass());
- $component = $singleton->dbObject($relation) ?: $component->relation($relation);
+ $component = $singleton->dbObject($relation) ?? $component->relation($relation);
} elseif ($component instanceof DataObject && ($dbObject = $component->dbObject($relation))) {
$component = $dbObject;
} elseif ($component instanceof ModelData && $component->hasField($relation)) {
@@ -4399,7 +4396,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
// has_one fields should not use dbObject to check if a value is given
$hasOne = static::getSchema()->hasOneComponent(static::class, $field);
if (!$hasOne && ($obj = $this->dbObject($field))) {
- return $obj->exists();
+ return $obj && $obj->exists();
} else {
return parent::hasValue($field, $arguments, $cache);
}
diff --git a/src/ORM/EagerLoadedList.php b/src/ORM/EagerLoadedList.php
index d65a49d37..ad53ad42e 100644
--- a/src/ORM/EagerLoadedList.php
+++ b/src/ORM/EagerLoadedList.php
@@ -171,7 +171,7 @@ class EagerLoadedList extends ModelData implements Relation, SS_List, Filterable
return $this->dataClass;
}
- public function dbObject($fieldName): ?DBField
+ public function dbObject(string $fieldName): ?DBField
{
return singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/ORM/FieldType/DBComposite.php b/src/ORM/FieldType/DBComposite.php
index 7060417ea..6c9ea2a05 100644
--- a/src/ORM/FieldType/DBComposite.php
+++ b/src/ORM/FieldType/DBComposite.php
@@ -73,7 +73,7 @@ abstract class DBComposite extends DBField
foreach ($this->compositeDatabaseFields() as $field => $spec) {
// Write sub-manipulation
$fieldObject = $this->dbObject($field);
- $fieldObject->writeToManipulation($manipulation);
+ $fieldObject?->writeToManipulation($manipulation);
}
}
@@ -137,7 +137,7 @@ abstract class DBComposite extends DBField
// By default all fields
foreach ($this->compositeDatabaseFields() as $field => $spec) {
$fieldObject = $this->dbObject($field);
- if (!$fieldObject->exists()) {
+ if (!$fieldObject?->exists()) {
return false;
}
}
diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php
index 38efb5758..8d7254aa3 100644
--- a/src/ORM/FieldType/DBField.php
+++ b/src/ORM/FieldType/DBField.php
@@ -520,11 +520,6 @@ abstract class DBField extends ModelData implements DBIndexable
DBG;
}
- public function __toString(): string
- {
- return (string)$this->forTemplate();
- }
-
public function getArrayValue()
{
return $this->arrayValue;
diff --git a/src/ORM/FieldType/DBVarchar.php b/src/ORM/FieldType/DBVarchar.php
index 3081ad34b..86608a197 100644
--- a/src/ORM/FieldType/DBVarchar.php
+++ b/src/ORM/FieldType/DBVarchar.php
@@ -47,7 +47,7 @@ class DBVarchar extends DBString
* can be useful if you want to have text fields with a length limit that
* is dictated by the DB field.
*
- * TextField::create('Title')->setMaxLength(singleton('SiteTree')->dbObject('Title')->getSize())
+ * TextField::create('Title')->setMaxLength(singleton('SiteTree')->dbObject('Title')?->getSize())
*
* @return int The size of the field
*/
diff --git a/src/ORM/Filters/SearchFilter.php b/src/ORM/Filters/SearchFilter.php
index f622252fb..bc70ec5d4 100644
--- a/src/ORM/Filters/SearchFilter.php
+++ b/src/ORM/Filters/SearchFilter.php
@@ -339,7 +339,7 @@ abstract class SearchFilter
/** @var DBField $dbField */
$dbField = singleton($this->model)->dbObject($this->name);
- $dbField->setValue($this->value);
+ $dbField?->setValue($this->value);
return $dbField->RAW();
}
diff --git a/src/ORM/Relation.php b/src/ORM/Relation.php
index 62b2b266c..93c63e961 100644
--- a/src/ORM/Relation.php
+++ b/src/ORM/Relation.php
@@ -45,9 +45,6 @@ interface Relation extends SS_List, Filterable, Sortable, Limitable
/**
* Return the DBField object that represents the given field on the related class.
- *
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
*/
- public function dbObject($fieldName);
+ public function dbObject(string $fieldName): ?DBField;
}
diff --git a/src/ORM/UnsavedRelationList.php b/src/ORM/UnsavedRelationList.php
index e01ff241e..ab2780288 100644
--- a/src/ORM/UnsavedRelationList.php
+++ b/src/ORM/UnsavedRelationList.php
@@ -307,11 +307,8 @@ class UnsavedRelationList extends ArrayList implements Relation
/**
* Return the DBField object that represents the given field on the related class.
- *
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
*/
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
return DataObject::singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/PolyExecution/PolyOutput.php b/src/PolyExecution/PolyOutput.php
index a10d4646e..35b52af39 100644
--- a/src/PolyExecution/PolyOutput.php
+++ b/src/PolyExecution/PolyOutput.php
@@ -226,9 +226,6 @@ class PolyOutput extends Output
{
$listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)];
$listType = $listInfo['type'];
- if ($listType === PolyOutput::LIST_ORDERED) {
- echo '';
- }
if ($options === null) {
$options = $listInfo['options'];
}
diff --git a/src/Security/Member.php b/src/Security/Member.php
index e5d198650..a4a7b3467 100644
--- a/src/Security/Member.php
+++ b/src/Security/Member.php
@@ -343,7 +343,7 @@ class Member extends DataObject
{
/** @var DBDatetime $lockedOutUntilObj */
$lockedOutUntilObj = $this->dbObject('LockedOutUntil');
- if ($lockedOutUntilObj->InFuture()) {
+ if ($lockedOutUntilObj?->InFuture()) {
return true;
}
@@ -370,7 +370,7 @@ class Member extends DataObject
/** @var DBDatetime $firstFailureDate */
$firstFailureDate = $attempts->first()->dbObject('Created');
$maxAgeSeconds = $this->config()->get('lock_out_delay_mins') * 60;
- $lockedOutUntil = $firstFailureDate->getTimestamp() + $maxAgeSeconds;
+ $lockedOutUntil = $firstFailureDate?->getTimestamp() + $maxAgeSeconds;
$now = DBDatetime::now()->getTimestamp();
if ($now < $lockedOutUntil) {
return true;
@@ -426,7 +426,7 @@ class Member extends DataObject
$currentValue = $this->PasswordExpiry;
$currentDate = $this->dbObject('PasswordExpiry');
- if ($dataValue && (!$currentValue || $currentDate->inFuture())) {
+ if ($dataValue && (!$currentValue || $currentDate?->inFuture())) {
// Only alter future expiries - this way an admin could see how long ago a password expired still
$this->PasswordExpiry = DBDatetime::now()->Rfc2822();
} elseif (!$dataValue && $this->isPasswordExpired()) {
diff --git a/src/Security/PermissionCheckboxSetField.php b/src/Security/PermissionCheckboxSetField.php
index bad09fa4f..7592dc681 100644
--- a/src/Security/PermissionCheckboxSetField.php
+++ b/src/Security/PermissionCheckboxSetField.php
@@ -117,7 +117,7 @@ class PermissionCheckboxSetField extends FormField
$uninheritedCodes[$permission->Code][] = _t(
'SilverStripe\\Security\\PermissionCheckboxSetField.AssignedTo',
'assigned to "{title}"',
- ['title' => $record->dbObject('Title')->forTemplate()]
+ ['title' => $record->dbObject('Title')?->forTemplate()]
);
}
@@ -135,7 +135,7 @@ class PermissionCheckboxSetField extends FormField
'SilverStripe\\Security\\PermissionCheckboxSetField.FromRole',
'inherited from role "{title}"',
'A permission inherited from a certain permission role',
- ['title' => $role->dbObject('Title')->forTemplate()]
+ ['title' => $role->dbObject('Title')?->forTemplate()]
);
}
}
@@ -159,8 +159,8 @@ class PermissionCheckboxSetField extends FormField
'inherited from role "{roletitle}" on group "{grouptitle}"',
'A permission inherited from a role on a certain group',
[
- 'roletitle' => $role->dbObject('Title')->forTemplate(),
- 'grouptitle' => $parent->dbObject('Title')->forTemplate()
+ 'roletitle' => $role->dbObject('Title')?->forTemplate(),
+ 'grouptitle' => $parent->dbObject('Title')?->forTemplate()
]
);
}
@@ -176,7 +176,7 @@ class PermissionCheckboxSetField extends FormField
'SilverStripe\\Security\\PermissionCheckboxSetField.FromGroup',
'inherited from group "{title}"',
'A permission inherited from a certain group',
- ['title' => $parent->dbObject('Title')->forTemplate()]
+ ['title' => $parent->dbObject('Title')?->forTemplate()]
);
}
}
diff --git a/src/View/CastingService.php b/src/View/CastingService.php
new file mode 100644
index 000000000..0d6f7510b
--- /dev/null
+++ b/src/View/CastingService.php
@@ -0,0 +1,100 @@
+castingHelper($fieldName);
+ }
+
+ // Cast to object if there's an explicit casting for this field
+ // Explicit casts take precedence over array casting
+ if ($service) {
+ $castObject = Injector::inst()->create($service, $fieldName);
+ if (!ClassInfo::hasMethod($castObject, 'setValue')) {
+ throw new LogicException('Explicit casting service must have a setValue method.');
+ }
+ $castObject->setValue($data, $source);
+ return $castObject;
+ }
+
+ // Wrap arrays in ModelData so templates can handle them
+ if (is_array($data)) {
+ return array_is_list($data) ? ArrayList::create($data) : ArrayData::create($data);
+ }
+
+ // Fall back to default casting
+ $service = $this->defaultService($data, $source, $fieldName);
+ $castObject = Injector::inst()->create($service, $fieldName);
+ if (!ClassInfo::hasMethod($castObject, 'setValue')) {
+ throw new LogicException('Default service must have a setValue method.');
+ }
+ $castObject->setValue($data, $source);
+ return $castObject;
+ }
+
+ /**
+ * Get the default service to use if no explicit service is declared for this field on the source model.
+ */
+ private function defaultService(mixed $data, mixed $source = null, string $fieldName = ''): ?string
+ {
+ $default = null;
+ if ($source instanceof ModelData) {
+ $default = $source::config()->get('default_cast');
+ if ($default === null) {
+ $failover = $source->getFailover();
+ if ($failover) {
+ $default = $this->defaultService($data, $failover, $fieldName);
+ }
+ }
+ }
+ if ($default !== null) {
+ return $default;
+ }
+
+ return match (gettype($data)) {
+ 'boolean' => DBBoolean::class,
+ 'string' => DBText::class,
+ 'double' => DBFloat::class,
+ 'integer' => DBInt::class,
+ default => DBText::class,
+ };
+ }
+}
diff --git a/src/View/Dev/SSViewerTestState.php b/src/View/Dev/SSViewerTestState.php
index 56f946e46..bb4b8e5f7 100644
--- a/src/View/Dev/SSViewerTestState.php
+++ b/src/View/Dev/SSViewerTestState.php
@@ -11,7 +11,7 @@ class SSViewerTestState implements TestState
{
public function setUp(SapphireTest $test)
{
- SSViewer::set_themes(null);
+ SSViewer::set_themes([]);
SSViewer::setRewriteHashLinksDefault(null);
ContentNegotiator::setEnabled(null);
}
diff --git a/src/View/Exception/MissingTemplateException.php b/src/View/Exception/MissingTemplateException.php
new file mode 100644
index 000000000..7864290d7
--- /dev/null
+++ b/src/View/Exception/MissingTemplateException.php
@@ -0,0 +1,11 @@
+` template commands.
+ *
+ * Caching
+ *
+ * Compiled templates are cached, usually on the filesystem.
+ * If you put ?flush=1 on your URL, it will force the template to be recompiled.
+ *
+ */
+class SSTemplateEngine implements TemplateEngine, Flushable
+{
+ use Injectable;
+
+ /**
+ * List of models being processed
+ */
+ protected static array $topLevel = [];
+
+ /**
+ * @internal
+ */
+ private static bool $template_cache_flushed = false;
+
+ /**
+ * @internal
+ */
+ private static bool $cacheblock_cache_flushed = false;
+
+ /**
+ */
+ private ?CacheInterface $partialCacheStore = null;
+
+ /**
+ */
+ private ?TemplateParser $parser = null;
+
+ /**
+ * A template or pool of candidate templates to choose from.
+ */
+ private string|array $templateCandidates = [];
+
+ /**
+ * Absolute path to chosen template file which will be used in the call to render()
+ */
+ private ?string $chosen = null;
+
+ /**
+ * Templates to use when looking up 'Layout' or 'Content'
+ */
+ private array $subTemplates = [];
+
+ public function __construct(string|array $templateCandidates = [])
+ {
+ if (!empty($templateCandidates)) {
+ $this->setTemplate($templateCandidates);
+ }
+ }
+
+ /**
+ * Execute the given template, passing it the given data.
+ * Used by the <% include %> template tag to process included templates.
+ *
+ * @param array $overlay Associative array of fields (e.g. args into an include template) to inject into the
+ * template as properties. These override properties and methods with the same name from $data and from global
+ * template providers.
+ */
+ public static function execute_template(array|string $template, ViewLayerData $data, array $overlay = [], ?SSViewer_Scope $scope = null): string
+ {
+ $engine = static::create($template);
+ return $engine->render($data, $overlay, $scope);
+ }
+
+ /**
+ * Get the current model being processed
+ */
+ public static function topLevel(): ?ViewLayerData
+ {
+ if (SSTemplateEngine::$topLevel) {
+ return SSTemplateEngine::$topLevel[sizeof(SSTemplateEngine::$topLevel)-1];
+ }
+ return null;
+ }
+
+ /**
+ * Triggered early in the request when someone requests a flush.
+ */
+ public static function flush()
+ {
+ SSTemplateEngine::flush_template_cache(true);
+ SSTemplateEngine::flush_cacheblock_cache(true);
+ }
+
+ public function hasTemplate(array|string $templateCandidates): bool
+ {
+ return (bool) ThemeResourceLoader::inst()->findTemplate($templateCandidates, SSViewer::get_themes());
+ }
+
+ public function renderString(string $template, ViewLayerData $model, array $overlay = [], bool $cache = true): string
+ {
+ $hash = sha1($template);
+ $cacheFile = TEMP_PATH . DIRECTORY_SEPARATOR . ".cache.$hash";
+
+ // Generate a file whether we're caching or not.
+ // This is an inefficiency that's required due to the way rendered templates get processed.
+ if (!file_exists($cacheFile ?? '') || isset($_GET['flush'])) {
+ $content = $this->parseTemplateContent($template, "string sha1=$hash");
+ $fh = fopen($cacheFile ?? '', 'w');
+ fwrite($fh, $content ?? '');
+ fclose($fh);
+ }
+
+ $output = $this->includeGeneratedTemplate($cacheFile, $model, $overlay, []);
+
+ if (!$cache) {
+ unlink($cacheFile ?? '');
+ }
+
+ return $output;
+ }
+
+ public function render(ViewLayerData $model, array $overlay = [], ?SSViewer_Scope $scope = null): string
+ {
+ SSTemplateEngine::$topLevel[] = $model;
+ $template = $this->chosen;
+
+ // If there's no template, throw an exception
+ if (!$template) {
+ if (empty($this->templateCandidates)) {
+ throw new MissingTemplateException(
+ 'No template to render. '
+ . 'Try calling setTemplate() or passing template candidates into the constructor.'
+ );
+ }
+ $message = 'None of the following templates could be found: ';
+ $message .= print_r($this->templateCandidates, true);
+ $themes = SSViewer::get_themes();
+ if (!$themes) {
+ $message .= ' (no theme in use)';
+ } else {
+ $message .= ' in themes "' . print_r($themes, true) . '"';
+ }
+ throw new MissingTemplateException($message);
+ }
+
+ $cacheFile = TEMP_PATH . DIRECTORY_SEPARATOR . '.cache'
+ . str_replace(['\\','/',':'], '.', Director::makeRelative(realpath($template ?? '')) ?? '');
+ $lastEdited = filemtime($template ?? '');
+
+ if (!file_exists($cacheFile ?? '') || filemtime($cacheFile ?? '') < $lastEdited) {
+ $content = file_get_contents($template ?? '');
+ $content = $this->parseTemplateContent($content, $template);
+
+ $fh = fopen($cacheFile ?? '', 'w');
+ fwrite($fh, $content ?? '');
+ fclose($fh);
+ }
+
+ $underlay = ['I18NNamespace' => basename($template ?? '')];
+
+ // Makes the rendered sub-templates available on the parent model,
+ // through $Content and $Layout placeholders.
+ foreach (['Content', 'Layout'] as $subtemplate) {
+ // Detect sub-template to use
+ $sub = $this->getSubtemplateFor($subtemplate);
+ if (!$sub) {
+ continue;
+ }
+
+ // Create lazy-evaluated underlay for this subtemplate
+ $underlay[$subtemplate] = function () use ($model, $overlay, $sub) {
+ $subtemplateViewer = clone $this;
+ // Select the right template and render if the template exists
+ $subtemplateViewer->setTemplate($sub);
+ // If there's no template for that underlay, just don't render anything.
+ // This mirrors how SSViewer_Scope handles null values.
+ if (!$subtemplateViewer->chosen) {
+ return null;
+ }
+ // Render and wrap in DBHTMLText so it doesn't get escaped
+ return DBHTMLText::create()->setValue($subtemplateViewer->render($model, $overlay));
+ };
+ }
+
+ $output = $this->includeGeneratedTemplate($cacheFile, $model, $overlay, $underlay, $scope);
+
+ array_pop(SSTemplateEngine::$topLevel);
+
+ return $output;
+ }
+
+ /**
+ */
+ public function setTemplate(string|array $templates): static
+ {
+ $this->templateCandidates = $templates;
+ $this->chosen = $this->chooseTemplate($templates);
+ $this->subTemplates = [];
+ return $this;
+ }
+
+ /**
+ * Find the template to use for a given list
+ *
+ * @param array|string $templates
+ * @return string
+ */
+ public function chooseTemplate($templates)
+ {
+ return ThemeResourceLoader::inst()->findTemplate($templates, SSViewer::get_themes());
+ }
+
+ /**
+ * Returns the filenames of the template that will be rendered. It is a map that may contain
+ * 'Content' & 'Layout', and will have to contain 'main'
+ *
+ * @return array
+ */
+ public function templates()
+ {
+ return array_merge(['main' => $this->chosen], $this->subTemplates);
+ }
+
+ /**
+ * @param string $type "Layout" or "main"
+ * @param string $file Full system path to the template file
+ */
+ public function setTemplateFile($type, $file)
+ {
+ if (!$type || $type == 'main') {
+ $this->chosen = $file;
+ } else {
+ $this->subTemplates[$type] = $file;
+ }
+ }
+
+ /**
+ * Set the template parser that will be used in template generation
+ */
+ public function setParser(TemplateParser $parser): static
+ {
+ $this->parser = $parser;
+ return $this;
+ }
+
+ /**
+ * Returns the parser that is set for template generation
+ */
+ public function getParser(): TemplateParser
+ {
+ if (!$this->parser) {
+ $this->setParser(Injector::inst()->get(SSTemplateParser::class));
+ }
+ return $this->parser;
+ }
+
+ /**
+ * Parse given template contents
+ *
+ * @param string $content The template contents
+ * @param string $template The template file name
+ * @return string
+ */
+ public function parseTemplateContent($content, $template = "")
+ {
+ return $this->getParser()->compileString(
+ $content,
+ $template,
+ Director::isDev() && SSViewer::config()->uninherited('source_file_comments')
+ );
+ }
+
+ /**
+ * An internal utility function to set up variables in preparation for including a compiled
+ * template, then do the include
+ *
+ * @param string $cacheFile The path to the file that contains the template compiled to PHP
+ * @param ViewLayerData $model The model to use as the root scope for the template
+ * @param array $overlay Any variables to layer on top of the scope
+ * @param array $underlay Any variables to layer underneath the scope
+ * @param SSViewer_Scope $inheritedScope The current scope of a parent template including a sub-template
+ * @return string The result of executing the template
+ */
+ protected function includeGeneratedTemplate($cacheFile, $model, $overlay, $underlay, $inheritedScope = null)
+ {
+ if (isset($_GET['showtemplate']) && $_GET['showtemplate'] && Permission::check('ADMIN')) {
+ $lines = file($cacheFile ?? '');
+ echo "
"; + foreach ($lines as $num => $line) { + echo str_pad($num+1, 5) . htmlentities($line, ENT_COMPAT, 'UTF-8'); + } + echo ""; + } + + $cache = $this->getPartialCacheStore(); + $scope = new SSViewer_Scope($model, $overlay, $underlay, $inheritedScope); + $val = ''; + + // Placeholder for values exposed to $cacheFile + [$cache, $scope, $val]; + include($cacheFile); + + return $val; + } + + /** + * Get the appropriate template to use for the named sub-template, or null if none are appropriate + * + * @param string $subtemplate Sub-template to use + * + * @return array|null + */ + protected function getSubtemplateFor($subtemplate) + { + // Get explicit subtemplate name + if (isset($this->subTemplates[$subtemplate])) { + return $this->subTemplates[$subtemplate]; + } + + // Don't apply sub-templates if type is already specified (e.g. 'Includes') + if (isset($this->templateCandidates['type'])) { + return null; + } + + // Filter out any other typed templates as we can only add, not change type + $templates = array_filter( + (array) $this->templateCandidates, + function ($template) { + return !isset($template['type']); + } + ); + if (empty($templates)) { + return null; + } + + // Set type to subtemplate + $templates['type'] = $subtemplate; + return $templates; + } + + /** + * Clears all parsed template files in the cache folder. + * + * Can only be called once per request (there may be multiple SSViewer instances). + * + * @param bool $force Set this to true to force a re-flush. If left to false, flushing + * may only be performed once a request. + */ + public static function flush_template_cache($force = false) + { + if (!SSTemplateEngine::$template_cache_flushed || $force) { + $dir = dir(TEMP_PATH); + while (false !== ($file = $dir->read())) { + if (strstr($file ?? '', '.cache')) { + unlink(TEMP_PATH . DIRECTORY_SEPARATOR . $file); + } + } + SSTemplateEngine::$template_cache_flushed = true; + } + } + + /** + * Clears all partial cache blocks. + * + * Can only be called once per request (there may be multiple SSViewer instances). + * + * @param bool $force Set this to true to force a re-flush. If left to false, flushing + * may only be performed once a request. + */ + public static function flush_cacheblock_cache($force = false) + { + if (!SSTemplateEngine::$cacheblock_cache_flushed || $force) { + $cache = Injector::inst()->get(CacheInterface::class . '.cacheblock'); + $cache->clear(); + + + SSTemplateEngine::$cacheblock_cache_flushed = true; + } + } + + /** + * Set the cache object to use when storing / retrieving partial cache blocks. + * + * @param CacheInterface $cache + */ + public function setPartialCacheStore($cache) + { + $this->partialCacheStore = $cache; + } + + /** + * Get the cache object to use when storing / retrieving partial cache blocks. + * + * @return CacheInterface + */ + public function getPartialCacheStore() + { + if ($this->partialCacheStore) { + return $this->partialCacheStore; + } + + return Injector::inst()->get(CacheInterface::class . '.cacheblock'); + } +} diff --git a/src/View/SSTemplateParser.peg b/src/View/SSTemplateParser.peg index b893ef4ae..d10bb74b9 100644 --- a/src/View/SSTemplateParser.peg +++ b/src/View/SSTemplateParser.peg @@ -247,7 +247,7 @@ class SSTemplateParser extends Parser implements TemplateParser } $res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] : - str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + str_replace('$$FINAL', 'getValueAsArgument', $sub['php'] ?? ''); } /*!* @@ -274,8 +274,8 @@ class SSTemplateParser extends Parser implements TemplateParser } /** - * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'obj' to - * get the next ModelData in the sequence, and LastLookupStep calls different methods (XML_val, hasValue, obj) + * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'scopeToIntermediateValue' to + * get the next ModelData in the sequence, and LastLookupStep calls different methods (getOutputValue, hasValue, scopeToIntermediateValue) * depending on the context the lookup is used in. */ function Lookup_AddLookupStep(&$res, $sub, $method) @@ -286,15 +286,17 @@ class SSTemplateParser extends Parser implements TemplateParser if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) { $arguments = $sub['Call']['CallArguments']['php']; - $res['php'] .= "->$method('$property', [$arguments], true)"; + $type = ViewLayerData::TYPE_METHOD; + $res['php'] .= "->$method('$property', [$arguments], '$type')"; } else { - $res['php'] .= "->$method('$property', [], true)"; + $type = ViewLayerData::TYPE_PROPERTY; + $res['php'] .= "->$method('$property', [], '$type')"; } } function Lookup_LookupStep(&$res, $sub) { - $this->Lookup_AddLookupStep($res, $sub, 'obj'); + $this->Lookup_AddLookupStep($res, $sub, 'scopeToIntermediateValue'); } function Lookup_LastLookupStep(&$res, $sub) @@ -357,7 +359,7 @@ class SSTemplateParser extends Parser implements TemplateParser function InjectionVariables_Argument(&$res, $sub) { - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '') . ','; + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '') . ','; } function InjectionVariables__finalise(&$res) @@ -392,7 +394,7 @@ class SSTemplateParser extends Parser implements TemplateParser */ function Injection_STR(&$res, $sub) { - $res['php'] = '$val .= '. str_replace('$$FINAL', 'XML_val', $sub['Lookup']['php'] ?? '') . ';'; + $res['php'] = '$val .= '. str_replace('$$FINAL', 'getOutputValue', $sub['Lookup']['php'] ?? '') . ';'; } /*!* @@ -535,10 +537,10 @@ class SSTemplateParser extends Parser implements TemplateParser if (!empty($res['php'])) { $res['php'] .= $sub['string_php']; } else { - $res['php'] = str_replace('$$FINAL', 'XML_val', $sub['lookup_php'] ?? ''); + $res['php'] = str_replace('$$FINAL', 'getOutputValue', $sub['lookup_php'] ?? ''); } } else { - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? ''); } } @@ -567,7 +569,7 @@ class SSTemplateParser extends Parser implements TemplateParser } else { $php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']); // TODO: kinda hacky - maybe we need a way to pass state down the parse chain so - // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val + // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of getOutputValue $res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? ''); } } @@ -697,7 +699,7 @@ class SSTemplateParser extends Parser implements TemplateParser $res['php'] = ''; } - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? ''); } /*!* @@ -827,7 +829,7 @@ class SSTemplateParser extends Parser implements TemplateParser { $entity = $sub['String']['text']; if (strpos($entity ?? '', '.') === false) { - $res['php'] .= "\$scope->XML_val('I18NNamespace').'.$entity'"; + $res['php'] .= "\$scope->getOutputValue('I18NNamespace').'.$entity'"; } else { $res['php'] .= "'$entity'"; } @@ -915,7 +917,7 @@ class SSTemplateParser extends Parser implements TemplateParser break; default: - $res['php'] .= str_replace('$$FINAL', 'obj', $sub['php'] ?? '') . '->self()'; + $res['php'] .= str_replace('$$FINAL', 'scopeToIntermediateValue', $sub['php'] ?? '') . '->self()'; break; } } @@ -947,8 +949,8 @@ class SSTemplateParser extends Parser implements TemplateParser $template = $res['template']; $arguments = $res['arguments']; - // Note: 'type' here is important to disable subTemplates in SSViewer::getSubtemplateFor() - $res['php'] = '$val .= \\SilverStripe\\View\\SSViewer::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getItem(), [' . + // Note: 'type' here is important to disable subTemplates in SSTemplateEngine::getSubtemplateFor() + $res['php'] = '$val .= \\SilverStripe\\View\\SSTemplateEngine::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getCurrentItem(), [' . implode(',', $arguments)."], \$scope, true);\n"; if ($this->includeDebuggingComments) { // Add include filename comments on dev sites @@ -1037,7 +1039,8 @@ class SSTemplateParser extends Parser implements TemplateParser //loop without arguments loops on the current scope if ($res['ArgumentCount'] == 0) { - $on = '$scope->locally()->obj(\'Me\', [], true)'; + $type = ViewLayerData::TYPE_METHOD; + $on = "\$scope->locally()->scopeToIntermediateValue('Me', [], '$type')"; // @TODO use self instead or move $Me to scope explicitly } else { //loop in the normal way $arg = $res['Arguments'][0]; if ($arg['ArgumentMode'] == 'string') { @@ -1045,13 +1048,13 @@ class SSTemplateParser extends Parser implements TemplateParser } $on = str_replace( '$$FINAL', - 'obj', + 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php'] ); } return - $on . '; $scope->pushScope(); while (($key = $scope->next()) !== false) {' . PHP_EOL . + $on . '; $scope->pushScope(); while ($scope->next() !== false) {' . PHP_EOL . $res['Template']['php'] . PHP_EOL . '}; $scope->popScope(); '; } @@ -1071,7 +1074,7 @@ class SSTemplateParser extends Parser implements TemplateParser throw new SSTemplateParseException('Control block cant take string as argument.', $this); } - $on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); + $on = str_replace('$$FINAL', 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); return $on . '; $scope->pushScope();' . PHP_EOL . $res['Template']['php'] . PHP_EOL . @@ -1118,6 +1121,7 @@ class SSTemplateParser extends Parser implements TemplateParser /** * This is an open block handler, for the <% debug %> utility tag + * @TODO find out if this even works in CMS 5, and if so make sure it keeps working */ function OpenBlock_Handle_Debug(&$res) { diff --git a/src/View/SSTemplateParser.php b/src/View/SSTemplateParser.php index 4e4842489..d177990c4 100644 --- a/src/View/SSTemplateParser.php +++ b/src/View/SSTemplateParser.php @@ -572,7 +572,7 @@ class SSTemplateParser extends Parser implements TemplateParser } $res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] : - str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + str_replace('$$FINAL', 'getValueAsArgument', $sub['php'] ?? ''); } /* Call: Method:Word ( "(" < :CallArguments? > ")" )? */ @@ -765,8 +765,8 @@ class SSTemplateParser extends Parser implements TemplateParser } /** - * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'obj' to - * get the next ModelData in the sequence, and LastLookupStep calls different methods (XML_val, hasValue, obj) + * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'scopeToIntermediateValue' to + * get the next ModelData in the sequence, and LastLookupStep calls different methods (getOutputValue, hasValue, scopeToIntermediateValue) * depending on the context the lookup is used in. */ function Lookup_AddLookupStep(&$res, $sub, $method) @@ -777,15 +777,17 @@ class SSTemplateParser extends Parser implements TemplateParser if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) { $arguments = $sub['Call']['CallArguments']['php']; - $res['php'] .= "->$method('$property', [$arguments], true)"; + $type = ViewLayerData::TYPE_METHOD; + $res['php'] .= "->$method('$property', [$arguments], '$type')"; } else { - $res['php'] .= "->$method('$property', [], true)"; + $type = ViewLayerData::TYPE_PROPERTY; + $res['php'] .= "->$method('$property', [], '$type')"; } } function Lookup_LookupStep(&$res, $sub) { - $this->Lookup_AddLookupStep($res, $sub, 'obj'); + $this->Lookup_AddLookupStep($res, $sub, 'scopeToIntermediateValue'); } function Lookup_LastLookupStep(&$res, $sub) @@ -1009,7 +1011,7 @@ class SSTemplateParser extends Parser implements TemplateParser function InjectionVariables_Argument(&$res, $sub) { - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '') . ','; + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '') . ','; } function InjectionVariables__finalise(&$res) @@ -1158,7 +1160,7 @@ class SSTemplateParser extends Parser implements TemplateParser function Injection_STR(&$res, $sub) { - $res['php'] = '$val .= '. str_replace('$$FINAL', 'XML_val', $sub['Lookup']['php'] ?? '') . ';'; + $res['php'] = '$val .= '. str_replace('$$FINAL', 'getOutputValue', $sub['Lookup']['php'] ?? '') . ';'; } /* DollarMarkedLookup: SimpleInjection */ @@ -1187,7 +1189,7 @@ class SSTemplateParser extends Parser implements TemplateParser $matchrule = "QuotedString"; $result = $this->construct($matchrule, $matchrule, null); $_154 = NULL; do { - $stack[] = $result; $result = $this->construct( $matchrule, "q" ); + $stack[] = $result; $result = $this->construct( $matchrule, "q" ); if (( $subres = $this->rx( '/[\'"]/' ) ) !== FALSE) { $result["text"] .= $subres; $subres = $result; $result = array_pop($stack); @@ -1197,7 +1199,7 @@ class SSTemplateParser extends Parser implements TemplateParser $result = array_pop($stack); $_154 = FALSE; break; } - $stack[] = $result; $result = $this->construct( $matchrule, "String" ); + $stack[] = $result; $result = $this->construct( $matchrule, "String" ); if (( $subres = $this->rx( '/ (\\\\\\\\ | \\\\. | [^'.$this->expression($result, $stack, 'q').'\\\\])* /' ) ) !== FALSE) { $result["text"] .= $subres; $subres = $result; $result = array_pop($stack); @@ -1818,10 +1820,10 @@ class SSTemplateParser extends Parser implements TemplateParser if (!empty($res['php'])) { $res['php'] .= $sub['string_php']; } else { - $res['php'] = str_replace('$$FINAL', 'XML_val', $sub['lookup_php'] ?? ''); + $res['php'] = str_replace('$$FINAL', 'getOutputValue', $sub['lookup_php'] ?? ''); } } else { - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? ''); } } @@ -1840,7 +1842,7 @@ class SSTemplateParser extends Parser implements TemplateParser $pos_255 = $this->pos; $_254 = NULL; do { - $stack[] = $result; $result = $this->construct( $matchrule, "Not" ); + $stack[] = $result; $result = $this->construct( $matchrule, "Not" ); if (( $subres = $this->literal( 'not' ) ) !== FALSE) { $result["text"] .= $subres; $subres = $result; $result = array_pop($stack); @@ -1887,7 +1889,7 @@ class SSTemplateParser extends Parser implements TemplateParser } else { $php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']); // TODO: kinda hacky - maybe we need a way to pass state down the parse chain so - // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val + // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of getOutputValue $res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? ''); } } @@ -2235,7 +2237,7 @@ class SSTemplateParser extends Parser implements TemplateParser else { $_330 = FALSE; break; } if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; } else { $_330 = FALSE; break; } - $stack[] = $result; $result = $this->construct( $matchrule, "Call" ); + $stack[] = $result; $result = $this->construct( $matchrule, "Call" ); $_326 = NULL; do { $matcher = 'match_'.'Word'; $key = $matcher; $pos = $this->pos; @@ -2470,7 +2472,7 @@ class SSTemplateParser extends Parser implements TemplateParser $res['php'] = ''; } - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? ''); } /* CacheBlockTemplate: (Comment | Translate | If | Require | OldI18NTag | Include | ClosedBlock | @@ -2740,7 +2742,7 @@ class SSTemplateParser extends Parser implements TemplateParser $_423 = NULL; do { if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; } - $stack[] = $result; $result = $this->construct( $matchrule, "Conditional" ); + $stack[] = $result; $result = $this->construct( $matchrule, "Conditional" ); $_419 = NULL; do { $_417 = NULL; @@ -3166,7 +3168,7 @@ class SSTemplateParser extends Parser implements TemplateParser if (( $subres = $this->literal( '<%' ) ) !== FALSE) { $result["text"] .= $subres; } else { $_555 = FALSE; break; } if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; } - $stack[] = $result; $result = $this->construct( $matchrule, "CacheTag" ); + $stack[] = $result; $result = $this->construct( $matchrule, "CacheTag" ); $_508 = NULL; do { $_506 = NULL; @@ -3225,7 +3227,7 @@ class SSTemplateParser extends Parser implements TemplateParser $_524 = NULL; do { if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; } - $stack[] = $result; $result = $this->construct( $matchrule, "Conditional" ); + $stack[] = $result; $result = $this->construct( $matchrule, "Conditional" ); $_520 = NULL; do { $_518 = NULL; @@ -3587,7 +3589,7 @@ class SSTemplateParser extends Parser implements TemplateParser { $entity = $sub['String']['text']; if (strpos($entity ?? '', '.') === false) { - $res['php'] .= "\$scope->XML_val('I18NNamespace').'.$entity'"; + $res['php'] .= "\$scope->getOutputValue('I18NNamespace').'.$entity'"; } else { $res['php'] .= "'$entity'"; } @@ -3792,7 +3794,7 @@ class SSTemplateParser extends Parser implements TemplateParser break; default: - $res['php'] .= str_replace('$$FINAL', 'obj', $sub['php'] ?? '') . '->self()'; + $res['php'] .= str_replace('$$FINAL', 'scopeToIntermediateValue', $sub['php'] ?? '') . '->self()'; break; } } @@ -3896,8 +3898,8 @@ class SSTemplateParser extends Parser implements TemplateParser $template = $res['template']; $arguments = $res['arguments']; - // Note: 'type' here is important to disable subTemplates in SSViewer::getSubtemplateFor() - $res['php'] = '$val .= \\SilverStripe\\View\\SSViewer::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getItem(), [' . + // Note: 'type' here is important to disable subTemplates in SSTemplateEngine::getSubtemplateFor() + $res['php'] = '$val .= \\SilverStripe\\View\\SSTemplateEngine::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getCurrentItem(), [' . implode(',', $arguments)."], \$scope, true);\n"; if ($this->includeDebuggingComments) { // Add include filename comments on dev sites @@ -4165,7 +4167,7 @@ class SSTemplateParser extends Parser implements TemplateParser unset( $pos_685 ); } if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; } - $stack[] = $result; $result = $this->construct( $matchrule, "Zap" ); + $stack[] = $result; $result = $this->construct( $matchrule, "Zap" ); if (( $subres = $this->literal( '%>' ) ) !== FALSE) { $result["text"] .= $subres; $subres = $result; $result = array_pop($stack); @@ -4265,7 +4267,8 @@ class SSTemplateParser extends Parser implements TemplateParser //loop without arguments loops on the current scope if ($res['ArgumentCount'] == 0) { - $on = '$scope->locally()->obj(\'Me\', [], true)'; + $type = ViewLayerData::TYPE_METHOD; + $on = "\$scope->locally()->scopeToIntermediateValue('Me', [], '$type')"; // @TODO use self instead or move $Me to scope explicitly } else { //loop in the normal way $arg = $res['Arguments'][0]; if ($arg['ArgumentMode'] == 'string') { @@ -4273,13 +4276,13 @@ class SSTemplateParser extends Parser implements TemplateParser } $on = str_replace( '$$FINAL', - 'obj', + 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php'] ); } return - $on . '; $scope->pushScope(); while (($key = $scope->next()) !== false) {' . PHP_EOL . + $on . '; $scope->pushScope(); while ($scope->next() !== false) {' . PHP_EOL . $res['Template']['php'] . PHP_EOL . '}; $scope->popScope(); '; } @@ -4299,7 +4302,7 @@ class SSTemplateParser extends Parser implements TemplateParser throw new SSTemplateParseException('Control block cant take string as argument.', $this); } - $on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); + $on = str_replace('$$FINAL', 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); return $on . '; $scope->pushScope();' . PHP_EOL . $res['Template']['php'] . PHP_EOL . @@ -4403,6 +4406,7 @@ class SSTemplateParser extends Parser implements TemplateParser /** * This is an open block handler, for the <% debug %> utility tag + * @TODO find out if this even works in CMS 5, and if so make sure it keeps working */ function OpenBlock_Handle_Debug(&$res) { @@ -4575,7 +4579,7 @@ class SSTemplateParser extends Parser implements TemplateParser if (( $subres = $this->literal( '<%' ) ) !== FALSE) { $result["text"] .= $subres; } else { $_743 = FALSE; break; } if (( $subres = $this->whitespace( ) ) !== FALSE) { $result["text"] .= $subres; } - $stack[] = $result; $result = $this->construct( $matchrule, "Tag" ); + $stack[] = $result; $result = $this->construct( $matchrule, "Tag" ); $_737 = NULL; do { if (( $subres = $this->literal( 'end_' ) ) !== FALSE) { $result["text"] .= $subres; } diff --git a/src/View/SSViewer.php b/src/View/SSViewer.php index 63f0edc34..64bc78a65 100644 --- a/src/View/SSViewer.php +++ b/src/View/SSViewer.php @@ -5,41 +5,20 @@ namespace SilverStripe\View; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\ClassInfo; -use Psr\SimpleCache\CacheInterface; use SilverStripe\Core\Convert; -use SilverStripe\Core\Flushable; -use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Control\Director; use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBHTMLText; -use SilverStripe\Security\Permission; use InvalidArgumentException; -use SilverStripe\Model\ModelData; +use SilverStripe\Core\Injector\Injector; /** - * Parses a template file with an *.ss file extension. + * Class that manages themes and interacts with TemplateEngine classes to render templates. * - * In addition to a full template in the templates/ folder, a template in - * templates/Content or templates/Layout will be rendered into $Content and - * $Layout, respectively. - * - * A single template can be parsed by multiple nested {@link SSViewer} instances - * through $Layout/$Content placeholders, as well as <% include MyTemplateFile %> template commands. - * - * Themes - * - * See http://doc.silverstripe.org/themes and http://doc.silverstripe.org/themes:developing - * - * Caching - * - * Compiled templates are cached via {@link Cache}, usually on the filesystem. - * If you put ?flush=1 on your URL, it will force the template to be recompiled. - * - * @see http://doc.silverstripe.org/themes - * @see http://doc.silverstripe.org/themes:developing + * Ensures rendered templates are normalised, e.g have appropriate resources from the Requirements API. */ -class SSViewer implements Flushable +class SSViewer { use Configurable; use Injectable; @@ -57,18 +36,8 @@ class SSViewer implements Flushable /** * A list (highest priority first) of themes to use * Only used when {@link $theme_enabled} is set to TRUE. - * - * @config - * @var string */ - private static $themes = []; - - /** - * Overridden value of $themes config - * - * @var array - */ - protected static $current_themes = null; + private static array $themes = []; /** * Use the theme. Set to FALSE in order to disable themes, @@ -76,33 +45,29 @@ class SSViewer implements Flushable * such as an administrative interface separate from the website theme. * It retains the theme settings to be re-enabled, for example when a website content * needs to be rendered from within this administrative interface. - * - * @config - * @var bool */ - private static $theme_enabled = true; + private static bool $theme_enabled = true; /** * Default prepended cache key for partial caching - * - * @config - * @var string */ - private static $global_key = '$CurrentReadingMode, $CurrentUser.ID'; + private static string $global_key = '$CurrentReadingMode, $CurrentUser.ID'; /** - * @config - * @var bool + * If true, rendered templates will include comments indicating which template file was used. + * May not be supported for some rendering engines. */ - private static $source_file_comments = false; + private static bool $source_file_comments = false; /** * Set if hash links should be rewritten - * - * @config - * @var bool */ - private static $rewrite_hash_links = true; + private static bool $rewrite_hash_links = true; + + /** + * Overridden value of $themes config + */ + protected static array $current_themes = []; /** * Overridden value of rewrite_hash_links config @@ -120,59 +85,10 @@ class SSViewer implements Flushable protected $rewriteHashlinks = null; /** - * @internal - * @ignore */ - private static $template_cache_flushed = false; + protected bool $includeRequirements = true; - /** - * @internal - * @ignore - */ - private static $cacheblock_cache_flushed = false; - - /** - * List of items being processed - * - * @var array - */ - protected static $topLevel = []; - - /** - * List of templates to select from - * - * @var array - */ - protected $templates = null; - - /** - * Absolute path to chosen template file - * - * @var string - */ - protected $chosen = null; - - /** - * Templates to use when looking up 'Layout' or 'Content' - * - * @var array - */ - protected $subTemplates = []; - - /** - * @var bool - */ - protected $includeRequirements = true; - - /** - * @var TemplateParser - */ - protected $parser; - - /** - * @var CacheInterface - */ - protected $partialCacheStore = null; + private TemplateEngine $templateEngine; /** * @param string|array $templates If passed as a string with .ss extension, used as the "main" template. @@ -181,86 +97,41 @@ class SSViewer implements Flushable *
* array('MySpecificPage', 'MyPage', 'Page')
*
- * @param TemplateParser $parser
*/
- public function __construct($templates, TemplateParser $parser = null)
+ public function __construct(string|array $templates, ?TemplateEngine $templateEngine = null)
{
- if ($parser) {
- $this->setParser($parser);
+ if ($templateEngine) {
+ $templateEngine->setTemplate($templates);
+ } else {
+ $templateEngine = Injector::inst()->create(TemplateEngine::class, $templates);
}
-
- $this->setTemplate($templates);
-
- if (!$this->chosen) {
- $message = 'None of the following templates could be found: ';
- $message .= print_r($templates, true);
-
- $themes = SSViewer::get_themes();
- if (!$themes) {
- $message .= ' (no theme in use)';
- } else {
- $message .= ' in themes "' . print_r($themes, true) . '"';
- }
-
- user_error($message ?? '', E_USER_WARNING);
- }
- }
-
- /**
- * Triggered early in the request when someone requests a flush.
- */
- public static function flush()
- {
- SSViewer::flush_template_cache(true);
- SSViewer::flush_cacheblock_cache(true);
- }
-
- /**
- * Create a template from a string instead of a .ss file
- *
- * @param string $content The template content
- * @param bool|void $cacheTemplate Whether or not to cache the template from string
- * @return SSViewer
- */
- public static function fromString($content, $cacheTemplate = null)
- {
- $viewer = SSViewer_FromString::create($content);
- if ($cacheTemplate !== null) {
- $viewer->setCacheTemplate($cacheTemplate);
- }
- return $viewer;
+ $this->setTemplateEngine($templateEngine);
}
/**
* Assign the list of active themes to apply.
* If default themes should be included add $default as the last entry.
- *
- * @param array $themes
*/
- public static function set_themes($themes = [])
+ public static function set_themes(array $themes): void
{
static::$current_themes = $themes;
}
/**
* Add to the list of active themes to apply
- *
- * @param array $themes
*/
- public static function add_themes($themes = [])
+ public static function add_themes(array $themes)
{
$currentThemes = SSViewer::get_themes();
$finalThemes = array_merge($themes, $currentThemes);
// array_values is used to ensure sequential array keys as array_unique can leave gaps
- static::set_themes(array_values(array_unique($finalThemes ?? [])));
+ static::set_themes(array_values(array_unique($finalThemes)));
}
/**
* Get the list of active themes
- *
- * @return array
*/
- public static function get_themes()
+ public static function get_themes(): array
{
$default = [SSViewer::PUBLIC_THEME, SSViewer::DEFAULT_THEME];
@@ -270,7 +141,7 @@ class SSViewer implements Flushable
// Explicit list is assigned
$themes = static::$current_themes;
- if (!isset($themes)) {
+ if (empty($themes)) {
$themes = SSViewer::config()->uninherited('themes');
}
if ($themes) {
@@ -283,7 +154,7 @@ class SSViewer implements Flushable
/**
* Traverses the given the given class context looking for candidate template names
* which match each item in the class hierarchy. The resulting list of template candidates
- * may or may not exist, but you can invoke {@see SSViewer::chooseTemplate} on any list
+ * may or may not exist, but you can call hasTemplate() on a TemplateEngine
* to determine the best candidate based on the current themes.
*
* @param string|object $classOrObject Valid class name, or object
@@ -323,16 +194,58 @@ class SSViewer implements Flushable
}
/**
- * Get the current item being processed
+ * Get an associative array of names to information about callable template provider methods.
*
- * @return ModelData
+ * @var boolean $createObject If true, methods will be called on instantiated objects rather than statically on the class.
*/
- public static function topLevel()
+ public static function getMethodsFromProvider(string $providerInterface, $methodName, bool $createObject = false): array
{
- if (SSViewer::$topLevel) {
- return SSViewer::$topLevel[sizeof(SSViewer::$topLevel)-1];
+ $implementors = ClassInfo::implementorsOf($providerInterface);
+ if ($implementors) {
+ foreach ($implementors as $implementor) {
+ // Create a new instance of the object for method calls
+ if ($createObject) {
+ $implementor = new $implementor();
+ $exposedVariables = $implementor->$methodName();
+ } else {
+ $exposedVariables = $implementor::$methodName();
+ }
+
+ foreach ($exposedVariables as $varName => $details) {
+ if (!is_array($details)) {
+ $details = ['method' => $details];
+ }
+
+ // If just a value (and not a key => value pair), use method name for both key and value
+ if (is_numeric($varName)) {
+ $varName = $details['method'];
+ }
+
+ // Add in a reference to the implementing class (might be a string class name or an instance)
+ $details['implementor'] = $implementor;
+
+ // And a callable array
+ if (isset($details['method'])) {
+ $details['callable'] = [$implementor, $details['method']];
+ }
+
+ // Save with both uppercase & lowercase first letter, so either works
+ $lcFirst = strtolower($varName[0] ?? '') . substr($varName ?? '', 1);
+ $result[$lcFirst] = $details;
+ $result[ucfirst($varName)] = $details;
+ }
+ }
}
- return null;
+
+ return $result;
+ }
+
+ /**
+ * Get the template engine used to render templates for this viewer
+ */
+ public function getTemplateEngine(): TemplateEngine
+ {
+ return $this->templateEngine;
}
/**
@@ -384,81 +297,15 @@ class SSViewer implements Flushable
static::$current_rewrite_hash_links = $rewrite;
}
- /**
- * @param string|array $templates
- */
- public function setTemplate($templates)
- {
- $this->templates = $templates;
- $this->chosen = $this->chooseTemplate($templates);
- $this->subTemplates = [];
- }
-
- /**
- * Find the template to use for a given list
- *
- * @param array|string $templates
- * @return string
- */
- public static function chooseTemplate($templates)
- {
- return ThemeResourceLoader::inst()->findTemplate($templates, SSViewer::get_themes());
- }
-
- /**
- * Set the template parser that will be used in template generation
- *
- * @param TemplateParser $parser
- */
- public function setParser(TemplateParser $parser)
- {
- $this->parser = $parser;
- }
-
- /**
- * Returns the parser that is set for template generation
- *
- * @return TemplateParser
- */
- public function getParser()
- {
- if (!$this->parser) {
- $this->setParser(Injector::inst()->get('SilverStripe\\View\\SSTemplateParser'));
- }
- return $this->parser;
- }
-
- /**
- * Returns true if at least one of the listed templates exists.
- *
- * @param array|string $templates
- *
- * @return bool
- */
- public static function hasTemplate($templates)
- {
- return (bool)ThemeResourceLoader::inst()->findTemplate($templates, SSViewer::get_themes());
- }
-
/**
* Call this to disable rewriting of links. This is useful in Ajax applications.
* It returns the SSViewer objects, so that you can call new SSViewer("X")->dontRewriteHashlinks()->process();
- *
- * @return $this
*/
- public function dontRewriteHashlinks()
+ public function dontRewriteHashlinks(): static
{
return $this->setRewriteHashLinks(false);
}
- /**
- * @return string
- */
- public function exists()
- {
- return $this->chosen;
- }
-
/**
* @param string $identifier A template name without '.ss' extension or path
* @param string $type The template type, either "main", "Includes" or "Layout"
@@ -469,116 +316,14 @@ class SSViewer implements Flushable
return ThemeResourceLoader::inst()->findTemplate(['type' => $type, $identifier], SSViewer::get_themes());
}
- /**
- * Clears all parsed template files in the cache folder.
- *
- * Can only be called once per request (there may be multiple SSViewer instances).
- *
- * @param bool $force Set this to true to force a re-flush. If left to false, flushing
- * may only be performed once a request.
- */
- public static function flush_template_cache($force = false)
- {
- if (!SSViewer::$template_cache_flushed || $force) {
- $dir = dir(TEMP_PATH);
- while (false !== ($file = $dir->read())) {
- if (strstr($file ?? '', '.cache')) {
- unlink(TEMP_PATH . DIRECTORY_SEPARATOR . $file);
- }
- }
- SSViewer::$template_cache_flushed = true;
- }
- }
-
- /**
- * Clears all partial cache blocks.
- *
- * Can only be called once per request (there may be multiple SSViewer instances).
- *
- * @param bool $force Set this to true to force a re-flush. If left to false, flushing
- * may only be performed once a request.
- */
- public static function flush_cacheblock_cache($force = false)
- {
- if (!SSViewer::$cacheblock_cache_flushed || $force) {
- $cache = Injector::inst()->get(CacheInterface::class . '.cacheblock');
- $cache->clear();
-
-
- SSViewer::$cacheblock_cache_flushed = true;
- }
- }
-
- /**
- * Set the cache object to use when storing / retrieving partial cache blocks.
- *
- * @param CacheInterface $cache
- */
- public function setPartialCacheStore($cache)
- {
- $this->partialCacheStore = $cache;
- }
-
- /**
- * Get the cache object to use when storing / retrieving partial cache blocks.
- *
- * @return CacheInterface
- */
- public function getPartialCacheStore()
- {
- if ($this->partialCacheStore) {
- return $this->partialCacheStore;
- }
-
- return Injector::inst()->get(CacheInterface::class . '.cacheblock');
- }
-
/**
* Flag whether to include the requirements in this response.
- *
- * @param bool $incl
*/
- public function includeRequirements($incl = true)
+ public function includeRequirements(bool $incl = true)
{
$this->includeRequirements = $incl;
}
- /**
- * An internal utility function to set up variables in preparation for including a compiled
- * template, then do the include
- *
- * Effectively this is the common code that both SSViewer#process and SSViewer_FromString#process call
- *
- * @param string $cacheFile The path to the file that contains the template compiled to PHP
- * @param ModelData $item The item to use as the root scope for the template
- * @param array $overlay Any variables to layer on top of the scope
- * @param array $underlay Any variables to layer underneath the scope
- * @param ModelData $inheritedScope The current scope of a parent template including a sub-template
- * @return string The result of executing the template
- */
- protected function includeGeneratedTemplate($cacheFile, $item, $overlay, $underlay, $inheritedScope = null)
- {
- if (isset($_GET['showtemplate']) && $_GET['showtemplate'] && Permission::check('ADMIN')) {
- $lines = file($cacheFile ?? '');
- echo ""; - foreach ($lines as $num => $line) { - echo str_pad($num+1, 5) . htmlentities($line, ENT_COMPAT, 'UTF-8'); - } - echo ""; - } - - $cache = $this->getPartialCacheStore(); - $scope = new SSViewer_DataPresenter($item, $overlay, $underlay, $inheritedScope); - $val = ''; - - // Placeholder for values exposed to $cacheFile - [$cache, $scope, $val]; - include($cacheFile); - - return $val; - } - /** * The process() method handles the "meat" of the template processing. * @@ -590,70 +335,24 @@ class SSViewer implements Flushable * * Note: You can call this method indirectly by {@link ModelData->renderWith()}. * - * @param ModelData $item - * @param array|null $arguments Arguments to an included template - * @param ModelData $inheritedScope The current scope of a parent template including a sub-template - * @return DBHTMLText Parsed template output. + * @param array $overlay Associative array of fields for use in the template. + * These will override properties and methods with the same name from $data and from global + * template providers. */ - public function process($item, $arguments = null, $inheritedScope = null) + public function process(mixed $item, array $overlay = []): DBHTMLText { + $item = ViewLayerData::create($item); // Set hashlinks and temporarily modify global state $rewrite = $this->getRewriteHashLinks(); $origRewriteDefault = static::getRewriteHashLinksDefault(); static::setRewriteHashLinksDefault($rewrite); - SSViewer::$topLevel[] = $item; - - $template = $this->chosen; - - $cacheFile = TEMP_PATH . DIRECTORY_SEPARATOR . '.cache' - . str_replace(['\\','/',':'], '.', Director::makeRelative(realpath($template ?? '')) ?? ''); - $lastEdited = filemtime($template ?? ''); - - if (!file_exists($cacheFile ?? '') || filemtime($cacheFile ?? '') < $lastEdited) { - $content = file_get_contents($template ?? ''); - $content = $this->parseTemplateContent($content, $template); - - $fh = fopen($cacheFile ?? '', 'w'); - fwrite($fh, $content ?? ''); - fclose($fh); - } - - $underlay = ['I18NNamespace' => basename($template ?? '')]; - - // Makes the rendered sub-templates available on the parent item, - // through $Content and $Layout placeholders. - foreach (['Content', 'Layout'] as $subtemplate) { - // Detect sub-template to use - $sub = $this->getSubtemplateFor($subtemplate); - if (!$sub) { - continue; - } - - // Create lazy-evaluated underlay for this subtemplate - $underlay[$subtemplate] = function () use ($item, $arguments, $sub) { - $subtemplateViewer = clone $this; - // Disable requirements - this will be handled by the parent template - $subtemplateViewer->includeRequirements(false); - // Select the right template - $subtemplateViewer->setTemplate($sub); - - // Render if available - if ($subtemplateViewer->exists()) { - return $subtemplateViewer->process($item, $arguments); - } - return null; - }; - } - - $output = $this->includeGeneratedTemplate($cacheFile, $item, $arguments, $underlay, $inheritedScope); + $output = $this->getTemplateEngine()->render($item, $overlay);// this is where we tell the engine to render the template if ($this->includeRequirements) { $output = Requirements::includeInHTML($output); } - array_pop(SSViewer::$topLevel); - // If we have our crazy base tag, then fix # links referencing the current page. if ($rewrite) { if (strpos($output ?? '', '
[out:Arg1]
[out:Arg2]
[out:Arg2.Count]
' + '[out:Arg1]
[out:Arg2]
[out:Arg2.Count]
', + $this->render('<% include SSViewerTestIncludeWithArguments %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A %>'), - 'A
[out:Arg2]
[out:Arg2.Count]
' + 'A
[out:Arg2]
[out:Arg2.Count]
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A, Arg2=B %>'), - 'A
B
' + 'A
B
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A, Arg2=B %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A Bare String, Arg2=B Bare String %>'), - 'A Bare String
B Bare String
' + 'A Bare String
B Bare String
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1=A Bare String, Arg2=B Bare String %>') ); $this->assertEquals( + 'A
Bar
', $this->render( '<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=$B %>', new ArrayData(['B' => 'Bar']) - ), - 'A
Bar
' + ) ); $this->assertEquals( + 'A
Bar
', $this->render( '<% include SSViewerTestIncludeWithArguments Arg1="A" %>', new ArrayData(['Arg1' => 'Foo', 'Arg2' => 'Bar']) - ), - 'A
Bar
' + ) ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=0 %>'), - 'A
0
' + 'A
0
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=0 %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=false %>'), - 'A
' + 'A
', + $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=false %>') ); $this->assertEquals( - $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=null %>'), - 'A
' + 'A
', + // Note Arg2 is explicitly overridden with null + $this->render('<% include SSViewerTestIncludeWithArguments Arg1="A", Arg2=null %>') ); $this->assertEquals( + 'SomeArg - Foo - Bar - SomeArg', $this->render( '<% include SSViewerTestIncludeScopeInheritanceWithArgsInLoop Title="SomeArg" %>', new ArrayData( @@ -1179,19 +1182,19 @@ after' ] )] ) - ), - 'SomeArg - Foo - Bar - SomeArg' + ) ); $this->assertEquals( + 'A - B - A', $this->render( '<% include SSViewerTestIncludeScopeInheritanceWithArgsInWith Title="A" %>', new ArrayData(['Item' => new ArrayData(['Title' =>'B'])]) - ), - 'A - B - A' + ) ); $this->assertEquals( + 'A - B - C - B - A', $this->render( '<% include SSViewerTestIncludeScopeInheritanceWithArgsInNestedWith Title="A" %>', new ArrayData( @@ -1202,11 +1205,11 @@ after' ] )] ) - ), - 'A - B - C - B - A' + ) ); $this->assertEquals( + 'A - A - A', $this->render( '<% include SSViewerTestIncludeScopeInheritanceWithUpAndTop Title="A" %>', new ArrayData( @@ -1217,8 +1220,7 @@ after' ] )] ) - ), - 'A - A - A' + ) ); $data = new ArrayData( @@ -1232,8 +1234,8 @@ after' ] ); - $tmpl = SSViewer::fromString('<% include SSViewerTestIncludeObjectArguments A=$Nested.Object, B=$Object %>'); - $res = $tmpl->process($data); + $tmpl = SSViewer_FromString::create('<% include SSViewerTestIncludeObjectArguments A=$Nested.Object, B=$Object %>'); + $res = $tmpl->process($data, cache: false); $this->assertEqualIgnoringWhitespace('A B', $res, 'Objects can be passed as named arguments'); } @@ -1339,29 +1341,29 @@ after' // Value casted as "Text" $this->assertEquals( '<b>html</b>', - $t = SSViewer::fromString('$TextValue')->process($vd) + $t = SSViewer_FromString::create('$TextValue')->process($vd, cache: false) ); $this->assertEquals( 'html', - $t = SSViewer::fromString('$TextValue.RAW')->process($vd) + $t = SSViewer_FromString::create('$TextValue.RAW')->process($vd, cache: false) ); $this->assertEquals( '<b>html</b>', - $t = SSViewer::fromString('$TextValue.XML')->process($vd) + $t = SSViewer_FromString::create('$TextValue.XML')->process($vd, cache: false) ); // Value casted as "HTMLText" $this->assertEquals( 'html', - $t = SSViewer::fromString('$HTMLValue')->process($vd) + $t = SSViewer_FromString::create('$HTMLValue')->process($vd, cache: false) ); $this->assertEquals( 'html', - $t = SSViewer::fromString('$HTMLValue.RAW')->process($vd) + $t = SSViewer_FromString::create('$HTMLValue.RAW')->process($vd, cache: false) ); $this->assertEquals( '<b>html</b>', - $t = SSViewer::fromString('$HTMLValue.XML')->process($vd) + $t = SSViewer_FromString::create('$HTMLValue.XML')->process($vd, cache: false) ); // Uncasted value (falls back to ModelData::$default_cast="Text") @@ -1369,15 +1371,15 @@ after' $vd->UncastedValue = 'html'; $this->assertEquals( '<b>html</b>', - $t = SSViewer::fromString('$UncastedValue')->process($vd) + $t = SSViewer_FromString::create('$UncastedValue')->process($vd, cache: false) ); $this->assertEquals( 'html', - $t = SSViewer::fromString('$UncastedValue.RAW')->process($vd) + $t = SSViewer_FromString::create('$UncastedValue.RAW')->process($vd, cache: false) ); $this->assertEquals( '<b>html</b>', - $t = SSViewer::fromString('$UncastedValue.XML')->process($vd) + $t = SSViewer_FromString::create('$UncastedValue.XML')->process($vd, cache: false) ); } @@ -2202,7 +2204,66 @@ EOC; } } - public function testCallsWithArguments() + public static function provideCallsWithArguments(): array + { + return [ + [ + 'template' => '$Level.output(1)', + 'expected' => '1-1', + ], + [ + 'template' => '$Nest.Level.output($Set.First.Number)', + 'expected' => '2-1', + ], + [ + 'template' => '<% with $Set %>$Up.Level.output($First.Number)<% end_with %>', + 'expected' => '1-1', + ], + [ + 'template' => '<% with $Set %>$Top.Nest.Level.output($First.Number)<% end_with %>', + 'expected' => '2-1', + ], + [ + 'template' => '<% loop $Set %>$Up.Nest.Level.output($Number)<% end_loop %>', + 'expected' => '2-12-22-32-42-5', + ], + [ + 'template' => '<% loop $Set %>$Top.Level.output($Number)<% end_loop %>', + 'expected' => '1-11-21-31-41-5', + ], + [ + 'template' => '<% with $Nest %>$Level.output($Top.Set.First.Number)<% end_with %>', + 'expected' => '2-1', + ], + [ + 'template' => '<% with $Level %>$output($Up.Set.Last.Number)<% end_with %>', + 'expected' => '1-5', + ], + [ + 'template' => '<% with $Level.forWith($Set.Last.Number) %>$output("hi")<% end_with %>', + 'expected' => '5-hi', + ], + [ + 'template' => '<% loop $Level.forLoop($Set.First.Number) %>$Number<% end_loop %>', + 'expected' => '!0', + ], + [ + 'template' => '<% with $Nest %> + <% with $Level.forWith($Up.Set.First.Number) %>$output("hi")<% end_with %> + <% end_with %>', + 'expected' => '1-hi', + ], + [ + 'template' => '<% with $Nest %> + <% loop $Level.forLoop($Top.Set.Last.Number) %>$Number<% end_loop %> + <% end_with %>', + 'expected' => '!0!1!2!3!4', + ], + ]; + } + + #[DataProvider('provideCallsWithArguments')] + public function testCallsWithArguments(string $template, string $expected): void { $data = new ArrayData( [ @@ -2222,28 +2283,7 @@ EOC; ] ); - $tests = [ - '$Level.output(1)' => '1-1', - '$Nest.Level.output($Set.First.Number)' => '2-1', - '<% with $Set %>$Up.Level.output($First.Number)<% end_with %>' => '1-1', - '<% with $Set %>$Top.Nest.Level.output($First.Number)<% end_with %>' => '2-1', - '<% loop $Set %>$Up.Nest.Level.output($Number)<% end_loop %>' => '2-12-22-32-42-5', - '<% loop $Set %>$Top.Level.output($Number)<% end_loop %>' => '1-11-21-31-41-5', - '<% with $Nest %>$Level.output($Top.Set.First.Number)<% end_with %>' => '2-1', - '<% with $Level %>$output($Up.Set.Last.Number)<% end_with %>' => '1-5', - '<% with $Level.forWith($Set.Last.Number) %>$output("hi")<% end_with %>' => '5-hi', - '<% loop $Level.forLoop($Set.First.Number) %>$Number<% end_loop %>' => '!0', - '<% with $Nest %> - <% with $Level.forWith($Up.Set.First.Number) %>$output("hi")<% end_with %> - <% end_with %>' => '1-hi', - '<% with $Nest %> - <% loop $Level.forLoop($Top.Set.Last.Number) %>$Number<% end_loop %> - <% end_with %>' => '!0!1!2!3!4', - ]; - - foreach ($tests as $template => $expected) { - $this->assertEquals($expected, trim($this->render($template, $data) ?? '')); - } + $this->assertEquals($expected, trim($this->render($template, $data) ?? '')); } public function testRepeatedCallsAreCached() @@ -2326,15 +2366,6 @@ EOC; unlink($cacheFile ?? ''); } - // Test global behaviors - $this->render($content, null, null); - $this->assertFalse(file_exists($cacheFile ?? ''), 'Cache file was created when caching was off'); - - SSViewer_FromString::config()->set('cache_template', true); - $this->render($content, null, null); - $this->assertTrue(file_exists($cacheFile ?? ''), 'Cache file wasn\'t created when it was meant to'); - unlink($cacheFile ?? ''); - // Test instance behaviors $this->render($content, null, false); $this->assertFalse(file_exists($cacheFile ?? ''), 'Cache file was created when caching was off'); @@ -2360,7 +2391,7 @@ EOC; public function testMe(): void { $myArrayData = new class extends ArrayData { - public function forTemplate() + public function forTemplate(): string { return ''; } @@ -2380,4 +2411,41 @@ EOC; $output = $modelData->renderWith('SSViewerTestLoopArray', ['MyArray' => $theArray]); $this->assertEqualIgnoringWhitespace('one two red blue', $output); } + + public static function provideGetterMethod(): array + { + return [ + 'as property (not getter)' => [ + 'template' => '$MyProperty', + 'expected' => 'Nothing passed in', + ], + 'as method (not getter)' => [ + 'template' => '$MyProperty()', + 'expected' => 'Nothing passed in', + ], + 'as method (not getter), with arg' => [ + 'template' => '$MyProperty("Some Value")', + 'expected' => 'Was passed in: Some Value', + ], + 'as property (getter)' => [ + 'template' => '$getMyProperty', + 'expected' => 'Nothing passed in', + ], + 'as method (getter)' => [ + 'template' => '$getMyProperty()', + 'expected' => 'Nothing passed in', + ], + 'as method (getter), with arg' => [ + 'template' => '$getMyProperty("Some Value")', + 'expected' => 'Was passed in: Some Value', + ], + ]; + } + + #[DataProvider('provideGetterMethod')] + public function testGetterMethod(string $template, string $expected): void + { + $model = new SSViewerTest\TestObject(); + $this->assertSame($expected, $this->render($template, $model)); + } } diff --git a/tests/php/View/SSViewerTest/TestFixture.php b/tests/php/View/SSViewerTest/TestFixture.php index f0abb39bc..bdb97fe3b 100644 --- a/tests/php/View/SSViewerTest/TestFixture.php +++ b/tests/php/View/SSViewerTest/TestFixture.php @@ -2,23 +2,78 @@ namespace SilverStripe\View\Tests\SSViewerTest; -use SilverStripe\Model\List\ArrayList; -use SilverStripe\Model\ModelData; +use ReflectionClass; +use SilverStripe\Dev\TestOnly; +use SilverStripe\View\SSViewer_Scope; +use Stringable; /** * A test fixture that will echo back the template item */ -class TestFixture extends ModelData +class TestFixture implements TestOnly, Stringable { - protected $name; + private ?string $name; public function __construct($name = null) { $this->name = $name; - parent::__construct(); } - private function argedName($fieldName, $arguments) + public function __call(string $name, array $arguments = []): static|array|null + { + return $this->getValue($name, $arguments); + } + + public function __get(string $name): static|array|null + { + return $this->getValue($name); + } + + public function __isset(string $name): bool + { + if (preg_match('/NotSet/i', $name)) { + return false; + } + $reflectionScope = new ReflectionClass(SSViewer_Scope::class); + $globalProperties = $reflectionScope->getStaticPropertyValue('globalProperties'); + if (array_key_exists($name, $globalProperties)) { + return false; + } + return true; + } + + public function __toString(): string + { + if (preg_match('/NotSet/i', $this->name ?? '')) { + return ''; + } + if (preg_match('/Raw/i', $this->name ?? '')) { + return $this->name ?? ''; + } + return '[out:' . $this->name . ']'; + } + + private function getValue(string $name, array $arguments = []): static|array|null + { + $childName = $this->argedName($name, $arguments); + + // Special field name Loop### to create a list + if (preg_match('/^Loop([0-9]+)$/', $name ?? '', $matches)) { + $output = []; + for ($i = 0; $i < $matches[1]; $i++) { + $output[] = new TestFixture($childName); + } + return $output; + } + + if (preg_match('/NotSet/i', $name)) { + return null; + } + + return new TestFixture($childName); + } + + private function argedName(string $fieldName, array $arguments): string { $childName = $this->name ? "$this->name.$fieldName" : $fieldName; if ($arguments) { @@ -27,46 +82,4 @@ class TestFixture extends ModelData return $childName; } } - - public function obj( - string $fieldName, - array $arguments = [], - bool $cache = false, - ?string $cacheName = null - ): ?object { - $childName = $this->argedName($fieldName, $arguments); - - // Special field name Loop### to create a list - if (preg_match('/^Loop([0-9]+)$/', $fieldName ?? '', $matches)) { - $output = new ArrayList(); - for ($i = 0; $i < $matches[1]; $i++) { - $output->push(new TestFixture($childName)); - } - return $output; - } else { - if (preg_match('/NotSet/i', $fieldName ?? '')) { - return new ModelData(); - } else { - return new TestFixture($childName); - } - } - } - - public function XML_val(string $fieldName, array $arguments = [], bool $cache = false): string - { - if (preg_match('/NotSet/i', $fieldName ?? '')) { - return ''; - } else { - if (preg_match('/Raw/i', $fieldName ?? '')) { - return $fieldName; - } else { - return '[out:' . $this->argedName($fieldName, $arguments) . ']'; - } - } - } - - public function hasValue(string $fieldName, array $arguments = [], bool $cache = true): bool - { - return (bool)$this->XML_val($fieldName, $arguments); - } } diff --git a/tests/php/View/SSViewerTest/TestObject.php b/tests/php/View/SSViewerTest/TestObject.php index dc0d948b4..8f2ef0ffc 100644 --- a/tests/php/View/SSViewerTest/TestObject.php +++ b/tests/php/View/SSViewerTest/TestObject.php @@ -41,4 +41,12 @@ class TestObject extends DataObject implements TestOnly { return 'some/url.html'; } + + public function getMyProperty(mixed $someArg = null): string + { + if ($someArg) { + return "Was passed in: $someArg"; + } + return 'Nothing passed in'; + } } diff --git a/tests/php/i18n/i18nTestManifest.php b/tests/php/i18n/i18nTestManifest.php index d2fd62a13..2a8bcfb9e 100644 --- a/tests/php/i18n/i18nTestManifest.php +++ b/tests/php/i18n/i18nTestManifest.php @@ -17,10 +17,10 @@ use SilverStripe\i18n\Tests\i18nTest\MyObject; use SilverStripe\i18n\Tests\i18nTest\MySubObject; use SilverStripe\i18n\Tests\i18nTest\TestDataObject; use SilverStripe\View\SSViewer; -use SilverStripe\View\SSViewer_DataPresenter; use SilverStripe\View\ThemeResourceLoader; use SilverStripe\View\ThemeManifest; use SilverStripe\Model\ModelData; +use SilverStripe\View\SSViewer_Scope; use Symfony\Component\Translation\Loader\ArrayLoader; use Symfony\Component\Translation\Translator; @@ -71,9 +71,9 @@ trait i18nTestManifest public function setupManifest() { - // force SSViewer_DataPresenter to cache global template vars before we switch to the + // force SSViewer_Scope to cache global template vars before we switch to the // test-project class manifest (since it will lose visibility of core classes) - $presenter = new SSViewer_DataPresenter(new ModelData()); + $presenter = new SSViewer_Scope(new ModelData()); unset($presenter); // Switch to test manifest