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/ContentNegotiator.php b/src/Control/ContentNegotiator.php
index aeb3b9f54..4bb9b46c1 100644
--- a/src/Control/ContentNegotiator.php
+++ b/src/Control/ContentNegotiator.php
@@ -225,7 +225,7 @@ class ContentNegotiator
// Fix base tag
$content = preg_replace(
'//',
- '',
+ '',
$content ?? ''
);
diff --git a/src/Control/Controller.php b/src/Control/Controller.php
index d1abe672f..eb23078f5 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',
];
+ protected ?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/Email/Email.php b/src/Control/Email/Email.php
index aa8bddd5c..4a0a3309b 100644
--- a/src/Control/Email/Email.php
+++ b/src/Control/Email/Email.php
@@ -398,16 +398,13 @@ class Email extends SymfonyEmail
return $this;
}
- public function getHTMLTemplate(): string
+ public function getHTMLTemplate(): string|array
{
if ($this->HTMLTemplate) {
return $this->HTMLTemplate;
}
- return ThemeResourceLoader::inst()->findTemplate(
- SSViewer::get_templates_by_class(static::class, '', Email::class),
- SSViewer::get_themes()
- );
+ return SSViewer::get_templates_by_class(static::class, '', Email::class);
}
/**
diff --git a/src/Control/HTTPResponse.php b/src/Control/HTTPResponse.php
index 3cb4a498b..8cb6589aa 100644
--- a/src/Control/HTTPResponse.php
+++ b/src/Control/HTTPResponse.php
@@ -8,11 +8,12 @@ use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injectable;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\View\Requirements;
+use Stringable;
/**
* Represents a response returned by a controller.
*/
-class HTTPResponse
+class HTTPResponse implements Stringable
{
use Injectable;
@@ -444,10 +445,8 @@ EOT
/**
* The HTTP response represented as a raw string
- *
- * @return string
*/
- public function __toString()
+ public function __toString(): string
{
$headers = [];
foreach ($this->getHeaders() as $header => $values) {
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/Core/Manifest/ModuleResource.php b/src/Core/Manifest/ModuleResource.php
index e89b90ac5..8cb1c7770 100644
--- a/src/Core/Manifest/ModuleResource.php
+++ b/src/Core/Manifest/ModuleResource.php
@@ -5,12 +5,13 @@ namespace SilverStripe\Core\Manifest;
use InvalidArgumentException;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Core\Path;
+use Stringable;
/**
* This object represents a single resource file attached to a module, and can be used
* as a reference to this to be later turned into either a URL or file path.
*/
-class ModuleResource
+class ModuleResource implements Stringable
{
/**
* @var Module
@@ -114,10 +115,8 @@ class ModuleResource
/**
* Get relative path
- *
- * @return string
*/
- public function __toString()
+ public function __toString(): string
{
return $this->getRelativePath();
}
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..4b4271735 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.
@@ -273,6 +274,8 @@ class FormField extends RequestHandler
'Title' => 'Text',
'RightTitle' => 'Text',
'Description' => 'HTMLFragment',
+ // This is an associative arrays, but we cast to Text so we can get a JSON string representation
+ 'SchemaData' => 'Text',
];
/**
@@ -458,7 +461,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 +472,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) {
@@ -1027,7 +1032,7 @@ class FormField extends RequestHandler
*/
protected function _templates($customTemplate = null, $customTemplateSuffix = null)
{
- $templates = SSViewer::get_templates_by_class(static::class, $customTemplateSuffix, __CLASS__);
+ $templates = SSViewer::get_templates_by_class(static::class, $customTemplateSuffix ?? '', __CLASS__);
// Prefer any custom template
if ($customTemplate) {
// Prioritise direct template
@@ -1469,12 +1474,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..df39adba7 100644
--- a/src/Forms/GridField/GridFieldAddExistingAutocompleter.php
+++ b/src/Forms/GridField/GridFieldAddExistingAutocompleter.php
@@ -17,6 +17,9 @@ use SilverStripe\Model\ArrayData;
use SilverStripe\View\SSViewer;
use LogicException;
use SilverStripe\Control\HTTPResponse_Exception;
+use SilverStripe\Core\Injector\Injector;
+use SilverStripe\View\TemplateEngine;
+use SilverStripe\View\ViewLayerData;
/**
* This class is is responsible for adding objects to another object's has_many
@@ -283,12 +286,15 @@ class GridFieldAddExistingAutocompleter extends AbstractGridFieldComponent imple
$json = [];
Config::nest();
SSViewer::config()->set('source_file_comments', false);
- $viewer = SSViewer::fromString($this->resultsFormat);
+
+ $engine = Injector::inst()->create(TemplateEngine::class);
foreach ($results as $result) {
if (!$result->canView()) {
continue;
}
- $title = Convert::html2raw($viewer->process($result));
+ $title = Convert::html2raw(
+ $engine->renderString($this->resultsFormat, ViewLayerData::create($result), cache: false)
+ );
$json[] = [
'label' => $title,
'value' => $title,
diff --git a/src/Forms/GridField/GridFieldDataColumns.php b/src/Forms/GridField/GridFieldDataColumns.php
index c12ba899f..1a5f21cdc 100644
--- a/src/Forms/GridField/GridFieldDataColumns.php
+++ b/src/Forms/GridField/GridFieldDataColumns.php
@@ -222,29 +222,6 @@ class GridFieldDataColumns extends AbstractGridFieldComponent implements GridFie
];
}
- /**
- * Translate a Object.RelationName.ColumnName $columnName into the value that ColumnName returns
- *
- * @param ModelData $record
- * @param string $columnName
- * @return string|null - returns null if it could not found a value
- */
- protected function getValueFromRelation($record, $columnName)
- {
- $fieldNameParts = explode('.', $columnName ?? '');
- $tmpItem = clone($record);
- for ($idx = 0; $idx < sizeof($fieldNameParts ?? []); $idx++) {
- $methodName = $fieldNameParts[$idx];
- // Last mmethod call from $columnName return what that method is returning
- if ($idx == sizeof($fieldNameParts ?? []) - 1) {
- return $tmpItem->XML_val($methodName);
- }
- // else get the object from this $methodName
- $tmpItem = $tmpItem->$methodName();
- }
- return null;
- }
-
/**
* Casts a field to a string which is safe to insert into HTML
*
diff --git a/src/Forms/GridField/GridState.php b/src/Forms/GridField/GridState.php
index 1c0d9c640..42eafb37f 100644
--- a/src/Forms/GridField/GridState.php
+++ b/src/Forms/GridField/GridState.php
@@ -5,6 +5,7 @@ namespace SilverStripe\Forms\GridField;
use SilverStripe\Core\Convert;
use SilverStripe\Forms\HiddenField;
use SilverStripe\ORM\DataList;
+use Stringable;
/**
* This class is a snapshot of the current status of a {@link GridField}.
@@ -14,7 +15,7 @@ use SilverStripe\ORM\DataList;
*
* @see GridField
*/
-class GridState extends HiddenField
+class GridState extends HiddenField implements Stringable
{
/**
@@ -129,11 +130,7 @@ class GridState extends HiddenField
return Convert::raw2att($this->Value());
}
- /**
- *
- * @return string
- */
- public function __toString()
+ public function __toString(): string
{
return $this->Value();
}
diff --git a/src/Forms/GridField/GridState_Data.php b/src/Forms/GridField/GridState_Data.php
index f74c60a23..6f53e0334 100644
--- a/src/Forms/GridField/GridState_Data.php
+++ b/src/Forms/GridField/GridState_Data.php
@@ -2,13 +2,15 @@
namespace SilverStripe\Forms\GridField;
+use Stringable;
+
/**
* Simple set of data, similar to stdClass, but without the notice-level
* errors.
*
* @see GridState
*/
-class GridState_Data
+class GridState_Data implements Stringable
{
/**
@@ -94,7 +96,7 @@ class GridState_Data
unset($this->data[$name]);
}
- public function __toString()
+ public function __toString(): string
{
if (!$this->data) {
return "";
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/ArrayData.php b/src/Model/ArrayData.php
index 185eebf7b..21af9b8f1 100644
--- a/src/Model/ArrayData.php
+++ b/src/Model/ArrayData.php
@@ -18,7 +18,6 @@ use stdClass;
*/
class ArrayData extends ModelData
{
-
/**
* @var array
* @see ArrayData::_construct()
@@ -87,6 +86,7 @@ class ArrayData extends ModelData
*/
public function setField(string $fieldName, mixed $value): static
{
+ $this->objCacheClear();
$this->array[$fieldName] = $value;
return $this;
}
@@ -102,6 +102,11 @@ class ArrayData extends ModelData
return isset($this->array[$fieldName]);
}
+ public function exists(): bool
+ {
+ return !empty($this->array);
+ }
+
/**
* Converts an associative array to a simple object
*
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..45ca90ce1 100644
--- a/src/Model/ModelData.php
+++ b/src/Model/ModelData.php
@@ -2,7 +2,6 @@
namespace SilverStripe\Model;
-use Exception;
use InvalidArgumentException;
use LogicException;
use ReflectionMethod;
@@ -12,14 +11,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 +28,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 +37,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 +46,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
@@ -204,6 +205,7 @@ class ModelData
public function setDynamicData(string $field, mixed $value): static
{
+ $this->objCacheClear();
$this->dynamicData[$field] = $value;
return $this;
}
@@ -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,24 +404,19 @@ 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;
}
/**
* Clear object cache
- *
- * @return $this
*/
- protected function objCacheClear()
+ public function objCacheClear(): static
{
$this->objCache = [];
return $this;
@@ -497,82 +428,46 @@ 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 {
+ $getter = "get{$fieldName}";
+ $hasGetter = $this->hasMethod($getter) && $this->isAccessibleMethod($getter);
+ // Try fields and getters if there was no method with that name.
+ $hasObj = $this->hasField($fieldName) || $hasGetter;
+ if ($hasGetter && !empty($arguments)) {
+ $value = $this->$getter(...$arguments);
+ } else {
+ $value = $this->$fieldName;
+ }
+ }
- // 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);
}
/**
@@ -588,36 +483,6 @@ class ModelData
return (bool) $result;
}
- /**
- * Get the string value of a field on this object that has been suitable escaped to be inserted directly into a
- * template.
- */
- public function XML_val(string $field, array $arguments = [], bool $cache = false): string
- {
- $result = $this->obj($field, $arguments, $cache);
- if (!$result) {
- return '';
- }
- // Might contain additional formatting over ->XML(). E.g. parse shortcodes, nl2br()
- return $result->forTemplate();
- }
-
- /**
- * Get an array of XML-escaped values by field name
- *
- * @param array $fields an array of field names
- */
- public function getXMLValues(array $fields): array
- {
- $result = [];
-
- foreach ($fields as $field) {
- $result[$field] = $this->XML_val($field);
- }
-
- return $result;
- }
-
// UTILITY METHODS -------------------------------------------------------------------------------------------------
/**
@@ -677,4 +542,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 a65f5b161..dfb8781b3 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 e0d2b755d..ffd8f7afc 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
@@ -1945,6 +1942,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
string $eagerLoadRelation,
EagerLoadedList|DataObject $eagerLoadedData
): void {
+ $this->objCacheClear();
$this->eagerLoadedData[$eagerLoadRelation] = $eagerLoadedData;
}
@@ -3041,7 +3039,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) {
@@ -3059,7 +3057,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
}
}
- return parent::castingHelper($field, $useFallback);
+ return parent::castingHelper($field);
}
/**
@@ -3242,11 +3240,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();
@@ -3314,7 +3312,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)) {
@@ -4407,7 +4405,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/Queries/SQLExpression.php b/src/ORM/Queries/SQLExpression.php
index 168f943d8..2051e2769 100644
--- a/src/ORM/Queries/SQLExpression.php
+++ b/src/ORM/Queries/SQLExpression.php
@@ -6,12 +6,13 @@ use SilverStripe\Core\Convert;
use SilverStripe\ORM\Connect\Query;
use SilverStripe\ORM\DB;
use Exception;
+use Stringable;
/**
* Abstract base class for an object representing an SQL query.
* The various parts of the SQL query can be manipulated individually.
*/
-abstract class SQLExpression
+abstract class SQLExpression implements Stringable
{
/**
@@ -44,10 +45,8 @@ abstract class SQLExpression
/**
* Return the generated SQL string for this query
- *
- * @return string
*/
- public function __toString()
+ public function __toString(): string
{
try {
$sql = $this->sql($parameters);
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 94ea15ccf..7764a9112 100644
--- a/src/Security/Member.php
+++ b/src/Security/Member.php
@@ -345,7 +345,7 @@ class Member extends DataObject
{
/** @var DBDatetime $lockedOutUntilObj */
$lockedOutUntilObj = $this->dbObject('LockedOutUntil');
- if ($lockedOutUntilObj->InFuture()) {
+ if ($lockedOutUntilObj?->InFuture()) {
return true;
}
@@ -372,7 +372,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;
@@ -428,7 +428,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;
+ use Configurable;
+
+ /**
+ * Default prepended cache key for partial caching
+ */
+ private static string $global_key = '$CurrentReadingMode, $CurrentUser.ID';
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Triggered early in the request when someone requests a flush.
+ */
+ public static function flush(): void
+ {
+ SSTemplateEngine::flushTemplateCache(true);
+ SSTemplateEngine::flushCacheBlockCache(true);
+ }
+
+ /**
+ * Clears all parsed template files in the cache folder.
+ *
+ * @param bool $force Set this to true to force a re-flush. If left to false, flushing
+ * will only be performed once a request.
+ */
+ public static function flushTemplateCache(bool $force = false): void
+ {
+ 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.
+ *
+ * @param bool $force Set this to true to force a re-flush. If left to false, flushing
+ * will only be performed once a request.
+ */
+ public static function flushCacheBlockCache(bool $force = false): void
+ {
+ if (!SSTemplateEngine::$cacheblock_cache_flushed || $force) {
+ $cache = Injector::inst()->get(CacheInterface::class . '.cacheblock');
+ $cache->clear();
+ SSTemplateEngine::$cacheblock_cache_flushed = true;
+ }
+ }
+
+ public function hasTemplate(array|string $templateCandidates): bool
+ {
+ return (bool) $this->findTemplate($templateCandidates);
+ }
+
+ 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 $templateCandidates): static
+ {
+ $this->templateCandidates = $templateCandidates;
+ $this->chosen = $this->findTemplate($templateCandidates);
+ $this->subTemplates = [];
+ return $this;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * Set the cache object to use when storing / retrieving partial cache blocks.
+ */
+ public function setPartialCacheStore(CacheInterface $cache): static
+ {
+ $this->partialCacheStore = $cache;
+ return $this;
+ }
+
+ /**
+ * Get the cache object to use when storing / retrieving partial cache blocks.
+ */
+ public function getPartialCacheStore(): CacheInterface
+ {
+ if (!$this->partialCacheStore) {
+ $this->partialCacheStore = Injector::inst()->get(CacheInterface::class . '.cacheblock');
+ }
+ return $this->partialCacheStore;
+ }
+
+ /**
+ * 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|null $inheritedScope The current scope of a parent template including a sub-template
+ */
+ protected function includeGeneratedTemplate(
+ string $cacheFile,
+ ViewLayerData $model,
+ array $overlay,
+ array $underlay,
+ ?SSViewer_Scope $inheritedScope = null
+ ): string {
+ if (isset($_GET['showtemplate']) && $_GET['showtemplate'] && Permission::check('ADMIN')) {
+ $lines = file($cacheFile ?? '');
+ 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
+ */
+ protected function getSubtemplateFor(string $subtemplate): ?array
+ {
+ // 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;
+ }
+
+ /**
+ * Parse given template contents
+ *
+ * @param string $content The template contents
+ * @param string $template The template file name
+ */
+ protected function parseTemplateContent(string $content, string $template = ""): string
+ {
+ return $this->getParser()->compileString(
+ $content,
+ $template,
+ Director::isDev() && SSViewer::config()->uninherited('source_file_comments')
+ );
+ }
+
+ /**
+ * Attempts to find possible candidate templates from a set of template
+ * names from modules, current theme directory and finally the application
+ * folder.
+ *
+ * The template names can be passed in as plain strings, or be in the
+ * format "type/name", where type is the type of template to search for
+ * (e.g. Includes, Layout).
+ *
+ * The results of this method will be cached for future use.
+ *
+ * @param string|array $template Template name, or template spec in array format with the keys
+ * 'type' (type string) and 'templates' (template hierarchy in order of precedence).
+ * If 'templates' is omitted then any other item in the array will be treated as the template
+ * list, or list of templates each in the array spec given.
+ * Templates with an .ss extension will be treated as file paths, and will bypass
+ * theme-coupled resolution.
+ * @param array $themes List of themes to use to resolve themes. Defaults to {@see SSViewer::get_themes()}
+ * @return string Absolute path to resolved template file, or null if not resolved.
+ * File location will be in the format themes//templates///.ss
+ * Note that type (e.g. 'Layout') is not the root level directory under 'templates'.
+ * Returns null if no template was found.
+ */
+ private function findTemplate(string|array $template, array $themes = []): ?string
+ {
+ if (empty($themes)) {
+ $themes = SSViewer::get_themes();
+ }
+
+ $cacheAdapter = ThemeResourceLoader::inst()->getCache();
+ $cacheKey = 'findTemplate_' . md5(json_encode($template) . json_encode($themes));
+
+ // Look for a cached result for this data set
+ if ($cacheAdapter->has($cacheKey)) {
+ return $cacheAdapter->get($cacheKey);
+ }
+
+ $type = '';
+ if (is_array($template)) {
+ // Check if templates has type specified
+ if (array_key_exists('type', $template ?? [])) {
+ $type = $template['type'];
+ unset($template['type']);
+ }
+ // Templates are either nested in 'templates' or just the rest of the list
+ $templateList = array_key_exists('templates', $template ?? []) ? $template['templates'] : $template;
+ } else {
+ $templateList = [$template];
+ }
+
+ $themePaths = ThemeResourceLoader::inst()->getThemePaths($themes);
+ $baseDir = ThemeResourceLoader::inst()->getBase();
+ foreach ($templateList as $i => $template) {
+ // Check if passed list of templates in array format
+ if (is_array($template)) {
+ $path = $this->findTemplate($template, $themes);
+ if ($path) {
+ $cacheAdapter->set($cacheKey, $path);
+ return $path;
+ }
+ continue;
+ }
+
+ // If we have an .ss extension, this is a path, not a template name. We should
+ // pass in templates without extensions in order for template manifest to find
+ // files dynamically.
+ if (substr($template ?? '', -3) == '.ss' && file_exists($template ?? '')) {
+ $cacheAdapter->set($cacheKey, $template);
+ return $template;
+ }
+
+ // Check string template identifier
+ $template = str_replace('\\', '/', $template ?? '');
+ $parts = explode('/', $template ?? '');
+
+ $tail = array_pop($parts);
+ $head = implode('/', $parts);
+ foreach ($themePaths as $themePath) {
+ // Join path
+ $pathParts = [ $baseDir, $themePath, 'templates', $head, $type, $tail ];
+ try {
+ $path = Path::join($pathParts) . '.ss';
+ if (file_exists($path ?? '')) {
+ $cacheAdapter->set($cacheKey, $path);
+ return $path;
+ }
+ } catch (InvalidArgumentException $e) {
+ // No-op
+ }
+ }
+ }
+
+ // No template found
+ $cacheAdapter->set($cacheKey, null);
+ return null;
+ }
+}
diff --git a/src/View/SSTemplateParser.peg b/src/View/SSTemplateParser.peg
index b893ef4ae..c11a480a2 100644
--- a/src/View/SSTemplateParser.peg
+++ b/src/View/SSTemplateParser.peg
@@ -16,15 +16,6 @@ this is: framework/src/View):
See the php-peg docs for more information on the parser format, and how to convert this file into SSTemplateParser.php
-TODO:
- Template comments - <%-- --%>
- $Iteration
- Partial cache blocks
- i18n - we dont support then deprecated _t() or sprintf(_t()) methods; or the new <% t %> block yet
- Add with and loop blocks
- Add Up and Top
- More error detection?
-
This comment will not appear in the output
*/
@@ -247,7 +238,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 +265,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 +277,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 +350,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 +385,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 +528,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'] ?? '');
}
}
@@ -566,8 +559,6 @@ class SSTemplateParser extends Parser implements TemplateParser
$res['php'] .= '((bool)'.$sub['php'].')';
} 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
$res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? '');
}
}
@@ -697,7 +688,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'] ?? '');
}
/*!*
@@ -779,7 +770,7 @@ class SSTemplateParser extends Parser implements TemplateParser
// the passed cache key, the block index, and the sha hash of the template.
$res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . PHP_EOL;
$res['php'] .= '$val = \'\';' . PHP_EOL;
- if ($globalKey = SSViewer::config()->get('global_key')) {
+ if ($globalKey = SSTemplateEngine::config()->get('global_key')) {
// Embed the code necessary to evaluate the globalKey directly into the template,
// so that SSTemplateParser only needs to be called during template regeneration.
// Warning: If the global key is changed, it's necessary to flush the template cache.
@@ -827,7 +818,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 +906,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 +938,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 +1028,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')";
} else { //loop in the normal way
$arg = $res['Arguments'][0];
if ($arg['ArgumentMode'] == 'string') {
@@ -1045,13 +1037,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 +1063,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 .
@@ -1116,27 +1108,6 @@ class SSTemplateParser extends Parser implements TemplateParser
}
}
- /**
- * This is an open block handler, for the <% debug %> utility tag
- */
- function OpenBlock_Handle_Debug(&$res)
- {
- if ($res['ArgumentCount'] == 0) {
- return '$scope->debug();';
- } elseif ($res['ArgumentCount'] == 1) {
- $arg = $res['Arguments'][0];
-
- if ($arg['ArgumentMode'] == 'string') {
- return 'Debug::show('.$arg['php'].');';
- }
-
- $php = ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php'];
- return '$val .= Debug::show('.str_replace('FINALGET!', 'cachedCall', $php ?? '').');';
- } else {
- throw new SSTemplateParseException('Debug takes 0 or 1 argument only.', $this);
- }
- }
-
/**
* This is an open block handler, for the <% base_tag %> tag
*/
@@ -1145,7 +1116,9 @@ class SSTemplateParser extends Parser implements TemplateParser
if ($res['ArgumentCount'] != 0) {
throw new SSTemplateParseException('Base_tag takes no arguments', $this);
}
- return '$val .= \\SilverStripe\\View\\SSViewer::get_base_tag($val);';
+ $code = '$isXhtml = preg_match(\'/]+xhtml/i\', $val);';
+ $code .= PHP_EOL . '$val .= \\SilverStripe\\View\\SSViewer::getBaseTag($isXhtml);';
+ return $code;
}
/**
@@ -1297,9 +1270,9 @@ EOC;
* @param string $templateName The name of the template, normally the filename the template source was loaded from
* @param bool $includeDebuggingComments True is debugging comments should be included in the output
* @param bool $topTemplate True if this is a top template, false if it's just a template
- * @return mixed|string The php that, when executed (via include or exec) will behave as per the template source
+ * @return string The php that, when executed (via include or exec) will behave as per the template source
*/
- public function compileString($string, $templateName = "", $includeDebuggingComments = false, $topTemplate = true)
+ public function compileString(string $string, string $templateName = "", bool $includeDebuggingComments = false, bool $topTemplate = true): string
{
if (!trim($string ?? '')) {
$code = '';
@@ -1308,8 +1281,7 @@ EOC;
$this->includeDebuggingComments = $includeDebuggingComments;
- // Ignore UTF8 BOM at beginning of string. TODO: Confirm this is needed, make sure SSViewer handles UTF
- // (and other encodings) properly
+ // Ignore UTF8 BOM at beginning of string.
if (substr($string ?? '', 0, 3) == pack("CCC", 0xef, 0xbb, 0xbf)) {
$this->pos = 3;
}
@@ -1341,7 +1313,7 @@ EOC;
* @param string $templateName
* @return string $code
*/
- protected function includeDebuggingComments($code, $templateName)
+ protected function includeDebuggingComments(string $code, string $templateName): string
{
// If this template contains a doctype, put it right after it,
// if not, put it after the tag to avoid IE glitches
@@ -1375,11 +1347,10 @@ EOC;
* Compiles some file that contains template source code, and returns the php code that will execute as per that
* source
*
- * @static
- * @param $template - A file path that contains template source code
- * @return mixed|string - The php that, when executed (via include or exec) will behave as per the template source
+ * @param string $template - A file path that contains template source code
+ * @return string - The php that, when executed (via include or exec) will behave as per the template source
*/
- public function compileFile($template)
+ public function compileFile(string $template): string
{
return $this->compileString(file_get_contents($template ?? ''), $template);
}
diff --git a/src/View/SSTemplateParser.php b/src/View/SSTemplateParser.php
index 4e4842489..7833a9abf 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);
@@ -1886,8 +1888,6 @@ class SSTemplateParser extends Parser implements TemplateParser
$res['php'] .= '((bool)'.$sub['php'].')';
} 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
$res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? '');
}
}
@@ -2235,7 +2235,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 +2470,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 +2740,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 +3166,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 +3225,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;
@@ -3428,7 +3428,7 @@ class SSTemplateParser extends Parser implements TemplateParser
// the passed cache key, the block index, and the sha hash of the template.
$res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . PHP_EOL;
$res['php'] .= '$val = \'\';' . PHP_EOL;
- if ($globalKey = SSViewer::config()->get('global_key')) {
+ if ($globalKey = SSTemplateEngine::config()->get('global_key')) {
// Embed the code necessary to evaluate the globalKey directly into the template,
// so that SSTemplateParser only needs to be called during template regeneration.
// Warning: If the global key is changed, it's necessary to flush the template cache.
@@ -3587,7 +3587,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 +3792,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 +3896,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 +4165,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 +4265,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')";
} else { //loop in the normal way
$arg = $res['Arguments'][0];
if ($arg['ArgumentMode'] == 'string') {
@@ -4273,13 +4274,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 +4300,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 .
@@ -4401,27 +4402,6 @@ class SSTemplateParser extends Parser implements TemplateParser
}
}
- /**
- * This is an open block handler, for the <% debug %> utility tag
- */
- function OpenBlock_Handle_Debug(&$res)
- {
- if ($res['ArgumentCount'] == 0) {
- return '$scope->debug();';
- } elseif ($res['ArgumentCount'] == 1) {
- $arg = $res['Arguments'][0];
-
- if ($arg['ArgumentMode'] == 'string') {
- return 'Debug::show('.$arg['php'].');';
- }
-
- $php = ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php'];
- return '$val .= Debug::show('.str_replace('FINALGET!', 'cachedCall', $php ?? '').');';
- } else {
- throw new SSTemplateParseException('Debug takes 0 or 1 argument only.', $this);
- }
- }
-
/**
* This is an open block handler, for the <% base_tag %> tag
*/
@@ -4430,7 +4410,9 @@ class SSTemplateParser extends Parser implements TemplateParser
if ($res['ArgumentCount'] != 0) {
throw new SSTemplateParseException('Base_tag takes no arguments', $this);
}
- return '$val .= \\SilverStripe\\View\\SSViewer::get_base_tag($val);';
+ $code = '$isXhtml = preg_match(\'/]+xhtml/i\', $val);';
+ $code .= PHP_EOL . '$val .= \\SilverStripe\\View\\SSViewer::getBaseTag($isXhtml);';
+ return $code;
}
/**
@@ -4575,7 +4557,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; }
@@ -5321,9 +5303,9 @@ EOC;
* @param string $templateName The name of the template, normally the filename the template source was loaded from
* @param bool $includeDebuggingComments True is debugging comments should be included in the output
* @param bool $topTemplate True if this is a top template, false if it's just a template
- * @return mixed|string The php that, when executed (via include or exec) will behave as per the template source
+ * @return string The php that, when executed (via include or exec) will behave as per the template source
*/
- public function compileString($string, $templateName = "", $includeDebuggingComments = false, $topTemplate = true)
+ public function compileString(string $string, string $templateName = "", bool $includeDebuggingComments = false, bool $topTemplate = true): string
{
if (!trim($string ?? '')) {
$code = '';
@@ -5332,8 +5314,7 @@ EOC;
$this->includeDebuggingComments = $includeDebuggingComments;
- // Ignore UTF8 BOM at beginning of string. TODO: Confirm this is needed, make sure SSViewer handles UTF
- // (and other encodings) properly
+ // Ignore UTF8 BOM at beginning of string.
if (substr($string ?? '', 0, 3) == pack("CCC", 0xef, 0xbb, 0xbf)) {
$this->pos = 3;
}
@@ -5365,7 +5346,7 @@ EOC;
* @param string $templateName
* @return string $code
*/
- protected function includeDebuggingComments($code, $templateName)
+ protected function includeDebuggingComments(string $code, string $templateName): string
{
// If this template contains a doctype, put it right after it,
// if not, put it after the tag to avoid IE glitches
@@ -5399,11 +5380,10 @@ EOC;
* Compiles some file that contains template source code, and returns the php code that will execute as per that
* source
*
- * @static
- * @param $template - A file path that contains template source code
- * @return mixed|string - The php that, when executed (via include or exec) will behave as per the template source
+ * @param string $template - A file path that contains template source code
+ * @return string - The php that, when executed (via include or exec) will behave as per the template source
*/
- public function compileFile($template)
+ public function compileFile(string $template): string
{
return $this->compileString(file_get_contents($template ?? ''), $template);
}
diff --git a/src/View/SSViewer.php b/src/View/SSViewer.php
index 63f0edc34..e3397288d 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,103 +45,46 @@ 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
+ * If true, rendered templates will include comments indicating which template file was used.
+ * May not be supported for some rendering engines.
*/
- private static $global_key = '$CurrentReadingMode, $CurrentUser.ID';
-
- /**
- * @config
- * @var bool
- */
- 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
*
- * @var bool
+ * Can be set to "php" to rewrite hash links with PHP executable code.
*/
- protected static $current_rewrite_hash_links = null;
+ protected static null|bool|string $current_rewrite_hash_links = null;
/**
* Instance variable to disable rewrite_hash_links (overrides global default)
* Leave null to use global state.
*
- * @var bool|null
+ * Can be set to "php" to rewrite hash links with PHP executable code.
*/
- protected $rewriteHashlinks = null;
+ protected null|bool|string $rewriteHashlinks = null;
/**
- * @internal
- * @ignore
+ * Determines whether resources from the Requirements API are included in a processed result.
*/
- 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 +93,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 +137,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) {
@@ -282,23 +149,26 @@ 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
- * to determine the best candidate based on the current themes.
+ * which match each item in the class hierarchy.
+ *
+ * This method does NOT check the filesystem, so the resulting list of template candidates
+ * may or may not exist - but you can pass these template candidates into the SSViewer
+ * constructor or into a TemplateEngine.
+ *
+ * If you really need know if a template file exists, you can call hasTemplate() on a TemplateEngine.
*
* @param string|object $classOrObject Valid class name, or object
- * @param string $suffix
* @param string $baseClass Class to halt ancestry search at
- * @return array
*/
- public static function get_templates_by_class($classOrObject, $suffix = '', $baseClass = null)
- {
+ public static function get_templates_by_class(
+ string|object $classOrObject,
+ string $suffix = '',
+ ?string $baseClass = null
+ ): array {
// Figure out the class name from the supplied context.
- if (!is_object($classOrObject) && !(
- is_string($classOrObject) && class_exists($classOrObject ?? '')
- )) {
+ if (is_string($classOrObject) && !class_exists($classOrObject ?? '')) {
throw new InvalidArgumentException(
- 'SSViewer::get_templates_by_class() expects a valid class name as its first parameter.'
+ 'SSViewer::get_templates_by_class() expects a valid class name or instantiated object as its first parameter.'
);
}
@@ -314,7 +184,7 @@ class SSViewer implements Flushable
$templates[] = $matches['name'] . $suffix;
}
- if ($baseClass && $class == $baseClass) {
+ if ($baseClass && $class === $baseClass) {
break;
}
}
@@ -323,26 +193,66 @@ 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;
}
/**
* Check if rewrite hash links are enabled on this instance
- *
- * @return bool
*/
- public function getRewriteHashLinks()
+ public function getRewriteHashLinks(): null|bool|string
{
- if (isset($this->rewriteHashlinks)) {
+ if ($this->rewriteHashlinks !== null) {
return $this->rewriteHashlinks;
}
return static::getRewriteHashLinksDefault();
@@ -350,11 +260,8 @@ class SSViewer implements Flushable
/**
* Set if hash links are rewritten for this instance
- *
- * @param bool $rewrite
- * @return $this
*/
- public function setRewriteHashLinks($rewrite)
+ public function setRewriteHashLinks(null|bool|string $rewrite): static
{
$this->rewriteHashlinks = $rewrite;
return $this;
@@ -362,13 +269,11 @@ class SSViewer implements Flushable
/**
* Get default value for rewrite hash links for all modules
- *
- * @return bool
*/
- public static function getRewriteHashLinksDefault()
+ public static function getRewriteHashLinksDefault(): null|bool|string
{
// Check if config overridden
- if (isset(static::$current_rewrite_hash_links)) {
+ if (static::$current_rewrite_hash_links !== null) {
return static::$current_rewrite_hash_links;
}
return Config::inst()->get(static::class, 'rewrite_hash_links');
@@ -376,209 +281,29 @@ class SSViewer implements Flushable
/**
* Set default rewrite hash links
- *
- * @param bool $rewrite
*/
- public static function setRewriteHashLinksDefault($rewrite)
+ public static function setRewriteHashLinksDefault(null|bool|string $rewrite)
{
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"
- * @return string Full system path to a template file
- */
- public static function getTemplateFileByType($identifier, $type = null)
- {
- 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 "