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(
'/
* public static $casting = array (
@@ -47,16 +47,18 @@ class ModelData
*
*/
private static array $casting = [
- 'CSSClasses' => 'Varchar'
+ 'CSSClasses' => 'Varchar',
+ 'forTemplate' => 'HTMLText',
];
/**
- * The default object to cast scalar fields to if casting information is not specified, and casting to an object
+ * The default class to cast scalar fields to if casting information is not specified, and casting to an object
* is required.
+ * This can be any injectable service name but must resolve to a DBField subclass.
+ *
+ * If null, casting will be determined based on the type of value (e.g. integers will be cast to DBInt)
*/
- private static string $default_cast = 'Text';
-
- private static array $casting_cache = [];
+ private static ?string $default_cast = null;
/**
* Acts as a PHP 8.2+ compliant replacement for dynamic properties
@@ -251,8 +253,7 @@ class ModelData
// -----------------------------------------------------------------------------------------------------------------
/**
- * Add methods from the {@link ModelData::$failover} object, as well as wrapping any methods prefixed with an
- * underscore into a {@link ModelData::cachedCall()}.
+ * Add methods from the {@link ModelData::$failover} object
*
* @throws LogicException
*/
@@ -305,12 +306,18 @@ class ModelData
return true;
}
- /**
- * Return the class name (though subclasses may return something else)
- */
public function __toString(): string
{
- return static::class;
+ return $this->forTemplate();
+ }
+
+ /**
+ * Return the HTML markup that represents this model when it is directly injected into a template (e.g. using $Me).
+ * By default this attempts to render the model using templates based on the class hierarchy.
+ */
+ public function forTemplate(): string
+ {
+ return $this->renderWith($this->getViewerTemplates());
}
public function getCustomisedObj(): ?ModelData
@@ -326,14 +333,10 @@ class ModelData
// CASTING ---------------------------------------------------------------------------------------------------------
/**
- * Return the "casting helper" (a piece of PHP code that when evaluated creates a casted value object)
+ * Return the "casting helper" (an injectable service name)
* for a field on this object. This helper will be a subclass of DBField.
- *
- * @param bool $useFallback If true, fall back on the default casting helper if there isn't an explicit one.
- * @return string|null Casting helper As a constructor pattern, and may include arguments.
- * @throws Exception
*/
- public function castingHelper(string $field, bool $useFallback = true): ?string
+ public function castingHelper(string $field): ?string
{
// Get casting if it has been configured.
// DB fields and PHP methods are all case insensitive so we normalise casing before checking.
@@ -346,67 +349,15 @@ class ModelData
// If no specific cast is declared, fall back to failover.
$failover = $this->getFailover();
if ($failover) {
- $cast = $failover->castingHelper($field, $useFallback);
+ $cast = $failover->castingHelper($field);
if ($cast) {
return $cast;
}
}
- if ($useFallback) {
- return $this->defaultCastingHelper($field);
- }
-
return null;
}
- /**
- * Return the default "casting helper" for use when no explicit casting helper is defined.
- * This helper will be a subclass of DBField. See castingHelper()
- */
- protected function defaultCastingHelper(string $field): string
- {
- // If there is a failover, the default_cast will always
- // be drawn from this object instead of the top level object.
- $failover = $this->getFailover();
- if ($failover) {
- $cast = $failover->defaultCastingHelper($field);
- if ($cast) {
- return $cast;
- }
- }
-
- // Fall back to raw default_cast
- $default = $this->config()->get('default_cast');
- if (empty($default)) {
- throw new Exception('No default_cast');
- }
- return $default;
- }
-
- /**
- * Get the class name a field on this object will be casted to.
- */
- public function castingClass(string $field): string
- {
- // Strip arguments
- $spec = $this->castingHelper($field);
- return trim(strtok($spec ?? '', '(') ?? '');
- }
-
- /**
- * Return the string-format type for the given field.
- *
- * @return string 'xml'|'raw'
- */
- public function escapeTypeForField(string $field): string
- {
- $class = $this->castingClass($field) ?: $this->config()->get('default_cast');
-
- /** @var DBField $type */
- $type = Injector::inst()->get($class, true);
- return $type->config()->get('escape_type');
- }
-
// TEMPLATE ACCESS LAYER -------------------------------------------------------------------------------------------
/**
@@ -417,9 +368,9 @@ class ModelData
* - an SSViewer instance
*
* @param string|array|SSViewer $template the template to render into
- * @param ModelData|array|null $customFields fields to customise() the object with before rendering
+ * @param ModelData|array $customFields fields to customise() the object with before rendering
*/
- public function renderWith($template, ModelData|array|null $customFields = null): DBHTMLText
+ public function renderWith($template, ModelData|array $customFields = []): DBHTMLText
{
if (!is_object($template)) {
$template = SSViewer::create($template);
@@ -429,9 +380,10 @@ class ModelData
if ($customFields instanceof ModelData) {
$data = $data->customise($customFields);
+ $customFields = [];
}
if ($template instanceof SSViewer) {
- return $template->process($data, is_array($customFields) ? $customFields : null);
+ return $template->process($data, $customFields);
}
throw new UnexpectedValueException(
@@ -440,27 +392,11 @@ class ModelData
}
/**
- * Generate the cache name for a field
- *
- * @param string $fieldName Name of field
- * @param array $arguments List of optional arguments given
- * @return string
+ * Get a cached value from the field cache for a field
*/
- protected function objCacheName($fieldName, $arguments)
- {
- return $arguments
- ? $fieldName . ":" . var_export($arguments, true)
- : $fieldName;
- }
-
- /**
- * Get a cached value from the field cache
- *
- * @param string $key Cache key
- * @return mixed
- */
- protected function objCacheGet($key)
+ public function objCacheGet(string $fieldName, array $arguments = []): mixed
{
+ $key = $this->objCacheName($fieldName, $arguments);
if (isset($this->objCache[$key])) {
return $this->objCache[$key];
}
@@ -468,14 +404,11 @@ class ModelData
}
/**
- * Store a value in the field cache
- *
- * @param string $key Cache key
- * @param mixed $value
- * @return $this
+ * Store a value in the field cache for a field
*/
- protected function objCacheSet($key, $value)
+ public function objCacheSet(string $fieldName, array $arguments, mixed $value): static
{
+ $key = $this->objCacheName($fieldName, $arguments);
$this->objCache[$key] = $value;
return $this;
}
@@ -485,7 +418,7 @@ class ModelData
*
* @return $this
*/
- protected function objCacheClear()
+ public function objCacheClear()
{
$this->objCache = [];
return $this;
@@ -497,82 +430,40 @@ class ModelData
*
* @return object|DBField|null The specific object representing the field, or null if there is no
* property, method, or dynamic data available for that field.
- * Note that if there is a property or method that returns null, a relevant DBField instance will
- * be returned.
*/
public function obj(
string $fieldName,
array $arguments = [],
- bool $cache = false,
- ?string $cacheName = null
+ bool $cache = false
): ?object {
- $hasObj = false;
- if (!$cacheName && $cache) {
- $cacheName = $this->objCacheName($fieldName, $arguments);
- }
-
// Check pre-cached value
- $value = $cache ? $this->objCacheGet($cacheName) : null;
- if ($value !== null) {
- return $value;
- }
+ $value = $cache ? $this->objCacheGet($fieldName, $arguments) : null;
+ if ($value === null) {
+ $hasObj = false;
+ // Load value from record
+ if ($this->hasMethod($fieldName)) {
+ // Try methods first - there's a LOT of logic that assumes this will be checked first.
+ $hasObj = true;
+ $value = call_user_func_array([$this, $fieldName], $arguments ?: []);
+ } else {
+ // Try fields and getters if there was no method with that name.
+ $hasObj = $this->hasField($fieldName) || ($this->hasMethod("get{$fieldName}") && $this->isAccessibleMethod("get{$fieldName}"));
+ $value = $this->$fieldName;// @TODO may need _get() explicitly here
+ }
- // Load value from record
- if ($this->hasMethod($fieldName)) {
- $hasObj = true;
- $value = call_user_func_array([$this, $fieldName], $arguments ?: []);
- } else {
- $hasObj = $this->hasField($fieldName) || ($this->hasMethod("get{$fieldName}") && $this->isAccessibleMethod("get{$fieldName}"));
- $value = $this->$fieldName;
- }
+ // Record in cache
+ if ($value !== null && $cache) {
+ $this->objCacheSet($fieldName, $arguments, $value);
+ }
- // Return null early if there's no backing for this field
- // i.e. no poperty, no method, etc - it just doesn't exist on this model.
- if (!$hasObj && $value === null) {
- return null;
- }
-
- // Try to cast object if we have an explicit cast set
- if (!is_object($value)) {
- $castingHelper = $this->castingHelper($fieldName, false);
- if ($castingHelper !== null) {
- $valueObject = Injector::inst()->create($castingHelper, $fieldName);
- $valueObject->setValue($value, $this);
- $value = $valueObject;
+ // Return null early if there's no backing for this field
+ // i.e. no poperty, no method, etc - it just doesn't exist on this model.
+ if (!$hasObj && $value === null) {
+ return null;
}
}
- // Wrap list arrays in ModelData so templates can handle them
- if (is_array($value) && array_is_list($value)) {
- $value = ArrayList::create($value);
- }
-
- // Fallback on default casting
- if (!is_object($value)) {
- // Force cast
- $castingHelper = $this->defaultCastingHelper($fieldName);
- $valueObject = Injector::inst()->create($castingHelper, $fieldName);
- $valueObject->setValue($value, $this);
- $value = $valueObject;
- }
-
- // Record in cache
- if ($cache) {
- $this->objCacheSet($cacheName, $value);
- }
-
- return $value;
- }
-
- /**
- * A simple wrapper around {@link ModelData::obj()} that automatically caches the result so it can be used again
- * without re-running the method.
- *
- * @return Object|DBField
- */
- public function cachedCall(string $fieldName, array $arguments = [], ?string $cacheName = null): object
- {
- return $this->obj($fieldName, $arguments, true, $cacheName);
+ return CastingService::singleton()->cast($value, $this, $fieldName, true);
}
/**
@@ -677,4 +568,14 @@ class ModelData
{
return ModelDataDebugger::create($this);
}
+
+ /**
+ * Generate the cache name for a field
+ */
+ private function objCacheName(string $fieldName, array $arguments = []): string
+ {
+ return empty($arguments)
+ ? $fieldName
+ : $fieldName . ":" . var_export($arguments, true);
+ }
}
diff --git a/src/Model/ModelDataCustomised.php b/src/Model/ModelDataCustomised.php
index 6ae73be21..bc86d4a72 100644
--- a/src/Model/ModelDataCustomised.php
+++ b/src/Model/ModelDataCustomised.php
@@ -49,17 +49,22 @@ class ModelDataCustomised extends ModelData
return isset($this->customised->$property) || isset($this->original->$property) || parent::__isset($property);
}
+ public function forTemplate(): string
+ {
+ return $this->original->forTemplate();
+ }
+
public function hasMethod($method)
{
return $this->customised->hasMethod($method) || $this->original->hasMethod($method);
}
- public function cachedCall(string $fieldName, array $arguments = [], ?string $cacheName = null): object
+ public function castingHelper(string $field): ?string
{
- if ($this->customisedHas($fieldName)) {
- return $this->customised->cachedCall($fieldName, $arguments, $cacheName);
+ if ($this->customisedHas($field)) {
+ return $this->customised->castingHelper($field);
}
- return $this->original->cachedCall($fieldName, $arguments, $cacheName);
+ return $this->original->castingHelper($field);
}
public function obj(
@@ -74,10 +79,15 @@ class ModelDataCustomised extends ModelData
return $this->original->obj($fieldName, $arguments, $cache, $cacheName);
}
- private function customisedHas(string $fieldName): bool
+ public function customisedHas(string $fieldName): bool
{
return property_exists($this->customised, $fieldName) ||
$this->customised->hasField($fieldName) ||
$this->customised->hasMethod($fieldName);
}
+
+ public function getCustomisedModelData(): ?ModelData
+ {
+ return $this->customised;
+ }
}
diff --git a/src/ORM/DataList.php b/src/ORM/DataList.php
index d703d3b90..e8d69f27f 100644
--- a/src/ORM/DataList.php
+++ b/src/ORM/DataList.php
@@ -19,6 +19,7 @@ use SilverStripe\Model\List\Limitable;
use SilverStripe\Model\List\Map;
use SilverStripe\Model\List\Sortable;
use SilverStripe\Model\List\SS_List;
+use SilverStripe\ORM\FieldType\DBField;
use SilverStripe\ORM\Filters\SearchFilterable;
/**
@@ -1852,7 +1853,7 @@ class DataList extends ModelData implements SS_List, Filterable, Sortable, Limit
return $relation;
}
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
return singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php
index 2b6bed1da..913250645 100644
--- a/src/ORM/DataObject.php
+++ b/src/ORM/DataObject.php
@@ -104,9 +104,6 @@ use stdClass;
* }
*
*
- * If any public method on this class is prefixed with an underscore,
- * the results are cached in memory through {@link cachedCall()}.
- *
* @property int $ID ID of the DataObject, 0 if the DataObject doesn't exist in database.
* @property int $OldID ID of object, if deleted
* @property string $Title
@@ -3033,7 +3030,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
/**
* {@inheritdoc}
*/
- public function castingHelper(string $field, bool $useFallback = true): ?string
+ public function castingHelper(string $field): ?string
{
$fieldSpec = static::getSchema()->fieldSpec(static::class, $field);
if ($fieldSpec) {
@@ -3051,7 +3048,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
}
}
- return parent::castingHelper($field, $useFallback);
+ return parent::castingHelper($field);
}
/**
@@ -3234,11 +3231,11 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
* - it still returns an object even when the field has no value.
* - it only matches fields and not methods
* - it matches foreign keys generated by has_one relationships, eg, "ParentID"
+ * - if the field exists, the return value is ALWAYS a DBField instance
*
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
+ * Returns null if the field doesn't exist
*/
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
// Check for field in DB
$schema = static::getSchema();
@@ -3306,7 +3303,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
} elseif ($component instanceof Relation || $component instanceof DataList) {
// $relation could either be a field (aggregate), or another relation
$singleton = DataObject::singleton($component->dataClass());
- $component = $singleton->dbObject($relation) ?: $component->relation($relation);
+ $component = $singleton->dbObject($relation) ?? $component->relation($relation);
} elseif ($component instanceof DataObject && ($dbObject = $component->dbObject($relation))) {
$component = $dbObject;
} elseif ($component instanceof ModelData && $component->hasField($relation)) {
@@ -4399,7 +4396,7 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro
// has_one fields should not use dbObject to check if a value is given
$hasOne = static::getSchema()->hasOneComponent(static::class, $field);
if (!$hasOne && ($obj = $this->dbObject($field))) {
- return $obj->exists();
+ return $obj && $obj->exists();
} else {
return parent::hasValue($field, $arguments, $cache);
}
diff --git a/src/ORM/EagerLoadedList.php b/src/ORM/EagerLoadedList.php
index d65a49d37..ad53ad42e 100644
--- a/src/ORM/EagerLoadedList.php
+++ b/src/ORM/EagerLoadedList.php
@@ -171,7 +171,7 @@ class EagerLoadedList extends ModelData implements Relation, SS_List, Filterable
return $this->dataClass;
}
- public function dbObject($fieldName): ?DBField
+ public function dbObject(string $fieldName): ?DBField
{
return singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/ORM/FieldType/DBComposite.php b/src/ORM/FieldType/DBComposite.php
index 7060417ea..6c9ea2a05 100644
--- a/src/ORM/FieldType/DBComposite.php
+++ b/src/ORM/FieldType/DBComposite.php
@@ -73,7 +73,7 @@ abstract class DBComposite extends DBField
foreach ($this->compositeDatabaseFields() as $field => $spec) {
// Write sub-manipulation
$fieldObject = $this->dbObject($field);
- $fieldObject->writeToManipulation($manipulation);
+ $fieldObject?->writeToManipulation($manipulation);
}
}
@@ -137,7 +137,7 @@ abstract class DBComposite extends DBField
// By default all fields
foreach ($this->compositeDatabaseFields() as $field => $spec) {
$fieldObject = $this->dbObject($field);
- if (!$fieldObject->exists()) {
+ if (!$fieldObject?->exists()) {
return false;
}
}
diff --git a/src/ORM/FieldType/DBField.php b/src/ORM/FieldType/DBField.php
index 38efb5758..8d7254aa3 100644
--- a/src/ORM/FieldType/DBField.php
+++ b/src/ORM/FieldType/DBField.php
@@ -520,11 +520,6 @@ abstract class DBField extends ModelData implements DBIndexable
DBG;
}
- public function __toString(): string
- {
- return (string)$this->forTemplate();
- }
-
public function getArrayValue()
{
return $this->arrayValue;
diff --git a/src/ORM/FieldType/DBVarchar.php b/src/ORM/FieldType/DBVarchar.php
index 3081ad34b..86608a197 100644
--- a/src/ORM/FieldType/DBVarchar.php
+++ b/src/ORM/FieldType/DBVarchar.php
@@ -47,7 +47,7 @@ class DBVarchar extends DBString
* can be useful if you want to have text fields with a length limit that
* is dictated by the DB field.
*
- * TextField::create('Title')->setMaxLength(singleton('SiteTree')->dbObject('Title')->getSize())
+ * TextField::create('Title')->setMaxLength(singleton('SiteTree')->dbObject('Title')?->getSize())
*
* @return int The size of the field
*/
diff --git a/src/ORM/Filters/SearchFilter.php b/src/ORM/Filters/SearchFilter.php
index f622252fb..bc70ec5d4 100644
--- a/src/ORM/Filters/SearchFilter.php
+++ b/src/ORM/Filters/SearchFilter.php
@@ -339,7 +339,7 @@ abstract class SearchFilter
/** @var DBField $dbField */
$dbField = singleton($this->model)->dbObject($this->name);
- $dbField->setValue($this->value);
+ $dbField?->setValue($this->value);
return $dbField->RAW();
}
diff --git a/src/ORM/Relation.php b/src/ORM/Relation.php
index 62b2b266c..93c63e961 100644
--- a/src/ORM/Relation.php
+++ b/src/ORM/Relation.php
@@ -45,9 +45,6 @@ interface Relation extends SS_List, Filterable, Sortable, Limitable
/**
* Return the DBField object that represents the given field on the related class.
- *
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
*/
- public function dbObject($fieldName);
+ public function dbObject(string $fieldName): ?DBField;
}
diff --git a/src/ORM/UnsavedRelationList.php b/src/ORM/UnsavedRelationList.php
index e01ff241e..ab2780288 100644
--- a/src/ORM/UnsavedRelationList.php
+++ b/src/ORM/UnsavedRelationList.php
@@ -307,11 +307,8 @@ class UnsavedRelationList extends ArrayList implements Relation
/**
* Return the DBField object that represents the given field on the related class.
- *
- * @param string $fieldName Name of the field
- * @return DBField The field as a DBField object
*/
- public function dbObject($fieldName)
+ public function dbObject(string $fieldName): ?DBField
{
return DataObject::singleton($this->dataClass)->dbObject($fieldName);
}
diff --git a/src/PolyExecution/PolyOutput.php b/src/PolyExecution/PolyOutput.php
index a10d4646e..35b52af39 100644
--- a/src/PolyExecution/PolyOutput.php
+++ b/src/PolyExecution/PolyOutput.php
@@ -226,9 +226,6 @@ class PolyOutput extends Output
{
$listInfo = $this->listTypeStack[array_key_last($this->listTypeStack)];
$listType = $listInfo['type'];
- if ($listType === PolyOutput::LIST_ORDERED) {
- echo '';
- }
if ($options === null) {
$options = $listInfo['options'];
}
diff --git a/src/Security/Member.php b/src/Security/Member.php
index e5d198650..a4a7b3467 100644
--- a/src/Security/Member.php
+++ b/src/Security/Member.php
@@ -343,7 +343,7 @@ class Member extends DataObject
{
/** @var DBDatetime $lockedOutUntilObj */
$lockedOutUntilObj = $this->dbObject('LockedOutUntil');
- if ($lockedOutUntilObj->InFuture()) {
+ if ($lockedOutUntilObj?->InFuture()) {
return true;
}
@@ -370,7 +370,7 @@ class Member extends DataObject
/** @var DBDatetime $firstFailureDate */
$firstFailureDate = $attempts->first()->dbObject('Created');
$maxAgeSeconds = $this->config()->get('lock_out_delay_mins') * 60;
- $lockedOutUntil = $firstFailureDate->getTimestamp() + $maxAgeSeconds;
+ $lockedOutUntil = $firstFailureDate?->getTimestamp() + $maxAgeSeconds;
$now = DBDatetime::now()->getTimestamp();
if ($now < $lockedOutUntil) {
return true;
@@ -426,7 +426,7 @@ class Member extends DataObject
$currentValue = $this->PasswordExpiry;
$currentDate = $this->dbObject('PasswordExpiry');
- if ($dataValue && (!$currentValue || $currentDate->inFuture())) {
+ if ($dataValue && (!$currentValue || $currentDate?->inFuture())) {
// Only alter future expiries - this way an admin could see how long ago a password expired still
$this->PasswordExpiry = DBDatetime::now()->Rfc2822();
} elseif (!$dataValue && $this->isPasswordExpired()) {
diff --git a/src/Security/PermissionCheckboxSetField.php b/src/Security/PermissionCheckboxSetField.php
index bad09fa4f..7592dc681 100644
--- a/src/Security/PermissionCheckboxSetField.php
+++ b/src/Security/PermissionCheckboxSetField.php
@@ -117,7 +117,7 @@ class PermissionCheckboxSetField extends FormField
$uninheritedCodes[$permission->Code][] = _t(
'SilverStripe\\Security\\PermissionCheckboxSetField.AssignedTo',
'assigned to "{title}"',
- ['title' => $record->dbObject('Title')->forTemplate()]
+ ['title' => $record->dbObject('Title')?->forTemplate()]
);
}
@@ -135,7 +135,7 @@ class PermissionCheckboxSetField extends FormField
'SilverStripe\\Security\\PermissionCheckboxSetField.FromRole',
'inherited from role "{title}"',
'A permission inherited from a certain permission role',
- ['title' => $role->dbObject('Title')->forTemplate()]
+ ['title' => $role->dbObject('Title')?->forTemplate()]
);
}
}
@@ -159,8 +159,8 @@ class PermissionCheckboxSetField extends FormField
'inherited from role "{roletitle}" on group "{grouptitle}"',
'A permission inherited from a role on a certain group',
[
- 'roletitle' => $role->dbObject('Title')->forTemplate(),
- 'grouptitle' => $parent->dbObject('Title')->forTemplate()
+ 'roletitle' => $role->dbObject('Title')?->forTemplate(),
+ 'grouptitle' => $parent->dbObject('Title')?->forTemplate()
]
);
}
@@ -176,7 +176,7 @@ class PermissionCheckboxSetField extends FormField
'SilverStripe\\Security\\PermissionCheckboxSetField.FromGroup',
'inherited from group "{title}"',
'A permission inherited from a certain group',
- ['title' => $parent->dbObject('Title')->forTemplate()]
+ ['title' => $parent->dbObject('Title')?->forTemplate()]
);
}
}
diff --git a/src/View/CastingService.php b/src/View/CastingService.php
new file mode 100644
index 000000000..0d6f7510b
--- /dev/null
+++ b/src/View/CastingService.php
@@ -0,0 +1,100 @@
+castingHelper($fieldName);
+ }
+
+ // Cast to object if there's an explicit casting for this field
+ // Explicit casts take precedence over array casting
+ if ($service) {
+ $castObject = Injector::inst()->create($service, $fieldName);
+ if (!ClassInfo::hasMethod($castObject, 'setValue')) {
+ throw new LogicException('Explicit casting service must have a setValue method.');
+ }
+ $castObject->setValue($data, $source);
+ return $castObject;
+ }
+
+ // Wrap arrays in ModelData so templates can handle them
+ if (is_array($data)) {
+ return array_is_list($data) ? ArrayList::create($data) : ArrayData::create($data);
+ }
+
+ // Fall back to default casting
+ $service = $this->defaultService($data, $source, $fieldName);
+ $castObject = Injector::inst()->create($service, $fieldName);
+ if (!ClassInfo::hasMethod($castObject, 'setValue')) {
+ throw new LogicException('Default service must have a setValue method.');
+ }
+ $castObject->setValue($data, $source);
+ return $castObject;
+ }
+
+ /**
+ * Get the default service to use if no explicit service is declared for this field on the source model.
+ */
+ private function defaultService(mixed $data, mixed $source = null, string $fieldName = ''): ?string
+ {
+ $default = null;
+ if ($source instanceof ModelData) {
+ $default = $source::config()->get('default_cast');
+ if ($default === null) {
+ $failover = $source->getFailover();
+ if ($failover) {
+ $default = $this->defaultService($data, $failover, $fieldName);
+ }
+ }
+ }
+ if ($default !== null) {
+ return $default;
+ }
+
+ return match (gettype($data)) {
+ 'boolean' => DBBoolean::class,
+ 'string' => DBText::class,
+ 'double' => DBFloat::class,
+ 'integer' => DBInt::class,
+ default => DBText::class,
+ };
+ }
+}
diff --git a/src/View/Dev/SSViewerTestState.php b/src/View/Dev/SSViewerTestState.php
index 56f946e46..bb4b8e5f7 100644
--- a/src/View/Dev/SSViewerTestState.php
+++ b/src/View/Dev/SSViewerTestState.php
@@ -11,7 +11,7 @@ class SSViewerTestState implements TestState
{
public function setUp(SapphireTest $test)
{
- SSViewer::set_themes(null);
+ SSViewer::set_themes([]);
SSViewer::setRewriteHashLinksDefault(null);
ContentNegotiator::setEnabled(null);
}
diff --git a/src/View/Exception/MissingTemplateException.php b/src/View/Exception/MissingTemplateException.php
new file mode 100644
index 000000000..7864290d7
--- /dev/null
+++ b/src/View/Exception/MissingTemplateException.php
@@ -0,0 +1,11 @@
+` template commands.
+ *
+ * Caching
+ *
+ * Compiled templates are cached, usually on the filesystem.
+ * If you put ?flush=1 on your URL, it will force the template to be recompiled.
+ *
+ */
+class SSTemplateEngine implements TemplateEngine, Flushable
+{
+ use Injectable;
+
+ /**
+ * List of models being processed
+ */
+ protected static array $topLevel = [];
+
+ /**
+ * @internal
+ */
+ private static bool $template_cache_flushed = false;
+
+ /**
+ * @internal
+ */
+ private static bool $cacheblock_cache_flushed = false;
+
+ /**
+ */
+ private ?CacheInterface $partialCacheStore = null;
+
+ /**
+ */
+ private ?TemplateParser $parser = null;
+
+ /**
+ * A template or pool of candidate templates to choose from.
+ */
+ private string|array $templateCandidates = [];
+
+ /**
+ * Absolute path to chosen template file which will be used in the call to render()
+ */
+ private ?string $chosen = null;
+
+ /**
+ * Templates to use when looking up 'Layout' or 'Content'
+ */
+ private array $subTemplates = [];
+
+ public function __construct(string|array $templateCandidates = [])
+ {
+ if (!empty($templateCandidates)) {
+ $this->setTemplate($templateCandidates);
+ }
+ }
+
+ /**
+ * Execute the given template, passing it the given data.
+ * Used by the <% include %> template tag to process included templates.
+ *
+ * @param array $overlay Associative array of fields (e.g. args into an include template) to inject into the
+ * template as properties. These override properties and methods with the same name from $data and from global
+ * template providers.
+ */
+ public static function execute_template(array|string $template, ViewLayerData $data, array $overlay = [], ?SSViewer_Scope $scope = null): string
+ {
+ $engine = static::create($template);
+ return $engine->render($data, $overlay, $scope);
+ }
+
+ /**
+ * Get the current model being processed
+ */
+ public static function topLevel(): ?ViewLayerData
+ {
+ if (SSTemplateEngine::$topLevel) {
+ return SSTemplateEngine::$topLevel[sizeof(SSTemplateEngine::$topLevel)-1];
+ }
+ return null;
+ }
+
+ /**
+ * Triggered early in the request when someone requests a flush.
+ */
+ public static function flush()
+ {
+ SSTemplateEngine::flush_template_cache(true);
+ SSTemplateEngine::flush_cacheblock_cache(true);
+ }
+
+ public function hasTemplate(array|string $templateCandidates): bool
+ {
+ return (bool) ThemeResourceLoader::inst()->findTemplate($templateCandidates, SSViewer::get_themes());
+ }
+
+ public function renderString(string $template, ViewLayerData $model, array $overlay = [], bool $cache = true): string
+ {
+ $hash = sha1($template);
+ $cacheFile = TEMP_PATH . DIRECTORY_SEPARATOR . ".cache.$hash";
+
+ // Generate a file whether we're caching or not.
+ // This is an inefficiency that's required due to the way rendered templates get processed.
+ if (!file_exists($cacheFile ?? '') || isset($_GET['flush'])) {
+ $content = $this->parseTemplateContent($template, "string sha1=$hash");
+ $fh = fopen($cacheFile ?? '', 'w');
+ fwrite($fh, $content ?? '');
+ fclose($fh);
+ }
+
+ $output = $this->includeGeneratedTemplate($cacheFile, $model, $overlay, []);
+
+ if (!$cache) {
+ unlink($cacheFile ?? '');
+ }
+
+ return $output;
+ }
+
+ public function render(ViewLayerData $model, array $overlay = [], ?SSViewer_Scope $scope = null): string
+ {
+ SSTemplateEngine::$topLevel[] = $model;
+ $template = $this->chosen;
+
+ // If there's no template, throw an exception
+ if (!$template) {
+ if (empty($this->templateCandidates)) {
+ throw new MissingTemplateException(
+ 'No template to render. '
+ . 'Try calling setTemplate() or passing template candidates into the constructor.'
+ );
+ }
+ $message = 'None of the following templates could be found: ';
+ $message .= print_r($this->templateCandidates, true);
+ $themes = SSViewer::get_themes();
+ if (!$themes) {
+ $message .= ' (no theme in use)';
+ } else {
+ $message .= ' in themes "' . print_r($themes, true) . '"';
+ }
+ throw new MissingTemplateException($message);
+ }
+
+ $cacheFile = TEMP_PATH . DIRECTORY_SEPARATOR . '.cache'
+ . str_replace(['\\','/',':'], '.', Director::makeRelative(realpath($template ?? '')) ?? '');
+ $lastEdited = filemtime($template ?? '');
+
+ if (!file_exists($cacheFile ?? '') || filemtime($cacheFile ?? '') < $lastEdited) {
+ $content = file_get_contents($template ?? '');
+ $content = $this->parseTemplateContent($content, $template);
+
+ $fh = fopen($cacheFile ?? '', 'w');
+ fwrite($fh, $content ?? '');
+ fclose($fh);
+ }
+
+ $underlay = ['I18NNamespace' => basename($template ?? '')];
+
+ // Makes the rendered sub-templates available on the parent model,
+ // through $Content and $Layout placeholders.
+ foreach (['Content', 'Layout'] as $subtemplate) {
+ // Detect sub-template to use
+ $sub = $this->getSubtemplateFor($subtemplate);
+ if (!$sub) {
+ continue;
+ }
+
+ // Create lazy-evaluated underlay for this subtemplate
+ $underlay[$subtemplate] = function () use ($model, $overlay, $sub) {
+ $subtemplateViewer = clone $this;
+ // Select the right template and render if the template exists
+ $subtemplateViewer->setTemplate($sub);
+ // If there's no template for that underlay, just don't render anything.
+ // This mirrors how SSViewer_Scope handles null values.
+ if (!$subtemplateViewer->chosen) {
+ return null;
+ }
+ // Render and wrap in DBHTMLText so it doesn't get escaped
+ return DBHTMLText::create()->setValue($subtemplateViewer->render($model, $overlay));
+ };
+ }
+
+ $output = $this->includeGeneratedTemplate($cacheFile, $model, $overlay, $underlay, $scope);
+
+ array_pop(SSTemplateEngine::$topLevel);
+
+ return $output;
+ }
+
+ /**
+ */
+ public function setTemplate(string|array $templates): static
+ {
+ $this->templateCandidates = $templates;
+ $this->chosen = $this->chooseTemplate($templates);
+ $this->subTemplates = [];
+ return $this;
+ }
+
+ /**
+ * Find the template to use for a given list
+ *
+ * @param array|string $templates
+ * @return string
+ */
+ public function chooseTemplate($templates)
+ {
+ return ThemeResourceLoader::inst()->findTemplate($templates, SSViewer::get_themes());
+ }
+
+ /**
+ * Returns the filenames of the template that will be rendered. It is a map that may contain
+ * 'Content' & 'Layout', and will have to contain 'main'
+ *
+ * @return array
+ */
+ public function templates()
+ {
+ return array_merge(['main' => $this->chosen], $this->subTemplates);
+ }
+
+ /**
+ * @param string $type "Layout" or "main"
+ * @param string $file Full system path to the template file
+ */
+ public function setTemplateFile($type, $file)
+ {
+ if (!$type || $type == 'main') {
+ $this->chosen = $file;
+ } else {
+ $this->subTemplates[$type] = $file;
+ }
+ }
+
+ /**
+ * Set the template parser that will be used in template generation
+ */
+ public function setParser(TemplateParser $parser): static
+ {
+ $this->parser = $parser;
+ return $this;
+ }
+
+ /**
+ * Returns the parser that is set for template generation
+ */
+ public function getParser(): TemplateParser
+ {
+ if (!$this->parser) {
+ $this->setParser(Injector::inst()->get(SSTemplateParser::class));
+ }
+ return $this->parser;
+ }
+
+ /**
+ * Parse given template contents
+ *
+ * @param string $content The template contents
+ * @param string $template The template file name
+ * @return string
+ */
+ public function parseTemplateContent($content, $template = "")
+ {
+ return $this->getParser()->compileString(
+ $content,
+ $template,
+ Director::isDev() && SSViewer::config()->uninherited('source_file_comments')
+ );
+ }
+
+ /**
+ * An internal utility function to set up variables in preparation for including a compiled
+ * template, then do the include
+ *
+ * @param string $cacheFile The path to the file that contains the template compiled to PHP
+ * @param ViewLayerData $model The model to use as the root scope for the template
+ * @param array $overlay Any variables to layer on top of the scope
+ * @param array $underlay Any variables to layer underneath the scope
+ * @param SSViewer_Scope $inheritedScope The current scope of a parent template including a sub-template
+ * @return string The result of executing the template
+ */
+ protected function includeGeneratedTemplate($cacheFile, $model, $overlay, $underlay, $inheritedScope = null)
+ {
+ if (isset($_GET['showtemplate']) && $_GET['showtemplate'] && Permission::check('ADMIN')) {
+ $lines = file($cacheFile ?? '');
+ echo "
"; + foreach ($lines as $num => $line) { + echo str_pad($num+1, 5) . htmlentities($line, ENT_COMPAT, 'UTF-8'); + } + echo ""; + } + + $cache = $this->getPartialCacheStore(); + $scope = new SSViewer_Scope($model, $overlay, $underlay, $inheritedScope); + $val = ''; + + // Placeholder for values exposed to $cacheFile + [$cache, $scope, $val]; + include($cacheFile); + + return $val; + } + + /** + * Get the appropriate template to use for the named sub-template, or null if none are appropriate + * + * @param string $subtemplate Sub-template to use + * + * @return array|null + */ + protected function getSubtemplateFor($subtemplate) + { + // Get explicit subtemplate name + if (isset($this->subTemplates[$subtemplate])) { + return $this->subTemplates[$subtemplate]; + } + + // Don't apply sub-templates if type is already specified (e.g. 'Includes') + if (isset($this->templateCandidates['type'])) { + return null; + } + + // Filter out any other typed templates as we can only add, not change type + $templates = array_filter( + (array) $this->templateCandidates, + function ($template) { + return !isset($template['type']); + } + ); + if (empty($templates)) { + return null; + } + + // Set type to subtemplate + $templates['type'] = $subtemplate; + return $templates; + } + + /** + * Clears all parsed template files in the cache folder. + * + * Can only be called once per request (there may be multiple SSViewer instances). + * + * @param bool $force Set this to true to force a re-flush. If left to false, flushing + * may only be performed once a request. + */ + public static function flush_template_cache($force = false) + { + if (!SSTemplateEngine::$template_cache_flushed || $force) { + $dir = dir(TEMP_PATH); + while (false !== ($file = $dir->read())) { + if (strstr($file ?? '', '.cache')) { + unlink(TEMP_PATH . DIRECTORY_SEPARATOR . $file); + } + } + SSTemplateEngine::$template_cache_flushed = true; + } + } + + /** + * Clears all partial cache blocks. + * + * Can only be called once per request (there may be multiple SSViewer instances). + * + * @param bool $force Set this to true to force a re-flush. If left to false, flushing + * may only be performed once a request. + */ + public static function flush_cacheblock_cache($force = false) + { + if (!SSTemplateEngine::$cacheblock_cache_flushed || $force) { + $cache = Injector::inst()->get(CacheInterface::class . '.cacheblock'); + $cache->clear(); + + + SSTemplateEngine::$cacheblock_cache_flushed = true; + } + } + + /** + * Set the cache object to use when storing / retrieving partial cache blocks. + * + * @param CacheInterface $cache + */ + public function setPartialCacheStore($cache) + { + $this->partialCacheStore = $cache; + } + + /** + * Get the cache object to use when storing / retrieving partial cache blocks. + * + * @return CacheInterface + */ + public function getPartialCacheStore() + { + if ($this->partialCacheStore) { + return $this->partialCacheStore; + } + + return Injector::inst()->get(CacheInterface::class . '.cacheblock'); + } +} diff --git a/src/View/SSTemplateParser.peg b/src/View/SSTemplateParser.peg index b893ef4ae..84ae8b2eb 100644 --- a/src/View/SSTemplateParser.peg +++ b/src/View/SSTemplateParser.peg @@ -247,7 +247,7 @@ class SSTemplateParser extends Parser implements TemplateParser } $res['php'] .= ($sub['ArgumentMode'] == 'default') ? $sub['string_php'] : - str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + str_replace('$$FINAL', 'getValueAsArgument', $sub['php'] ?? ''); } /*!* @@ -274,8 +274,8 @@ class SSTemplateParser extends Parser implements TemplateParser } /** - * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'obj' to - * get the next ModelData in the sequence, and LastLookupStep calls different methods (XML_val, hasValue, obj) + * The basic generated PHP of LookupStep and LastLookupStep is the same, except that LookupStep calls 'scopeToIntermediateValue' to + * get the next ModelData in the sequence, and LastLookupStep calls different methods (getOutputValue, hasValue, scopeToIntermediateValue) * depending on the context the lookup is used in. */ function Lookup_AddLookupStep(&$res, $sub, $method) @@ -286,15 +286,17 @@ class SSTemplateParser extends Parser implements TemplateParser if (isset($sub['Call']['CallArguments']) && isset($sub['Call']['CallArguments']['php'])) { $arguments = $sub['Call']['CallArguments']['php']; - $res['php'] .= "->$method('$property', [$arguments], true)"; + $type = ViewLayerData::TYPE_METHOD; + $res['php'] .= "->$method('$property', [$arguments], '$type')"; } else { - $res['php'] .= "->$method('$property', [], true)"; + $type = ViewLayerData::TYPE_PROPERTY; + $res['php'] .= "->$method('$property', [], '$type')"; } } function Lookup_LookupStep(&$res, $sub) { - $this->Lookup_AddLookupStep($res, $sub, 'obj'); + $this->Lookup_AddLookupStep($res, $sub, 'scopeToIntermediateValue'); } function Lookup_LastLookupStep(&$res, $sub) @@ -357,7 +359,7 @@ class SSTemplateParser extends Parser implements TemplateParser function InjectionVariables_Argument(&$res, $sub) { - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? '') . ','; + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? '') . ','; } function InjectionVariables__finalise(&$res) @@ -392,7 +394,7 @@ class SSTemplateParser extends Parser implements TemplateParser */ function Injection_STR(&$res, $sub) { - $res['php'] = '$val .= '. str_replace('$$FINAL', 'XML_val', $sub['Lookup']['php'] ?? '') . ';'; + $res['php'] = '$val .= '. str_replace('$$FINAL', 'getOutputValue', $sub['Lookup']['php'] ?? '') . ';'; } /*!* @@ -535,10 +537,10 @@ class SSTemplateParser extends Parser implements TemplateParser if (!empty($res['php'])) { $res['php'] .= $sub['string_php']; } else { - $res['php'] = str_replace('$$FINAL', 'XML_val', $sub['lookup_php'] ?? ''); + $res['php'] = str_replace('$$FINAL', 'getOutputValue', $sub['lookup_php'] ?? ''); } } else { - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? ''); } } @@ -567,7 +569,7 @@ class SSTemplateParser extends Parser implements TemplateParser } else { $php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']); // TODO: kinda hacky - maybe we need a way to pass state down the parse chain so - // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val + // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of getOutputValue $res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? ''); } } @@ -697,7 +699,7 @@ class SSTemplateParser extends Parser implements TemplateParser $res['php'] = ''; } - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? ''); } /*!* @@ -827,7 +829,7 @@ class SSTemplateParser extends Parser implements TemplateParser { $entity = $sub['String']['text']; if (strpos($entity ?? '', '.') === false) { - $res['php'] .= "\$scope->XML_val('I18NNamespace').'.$entity'"; + $res['php'] .= "\$scope->getOutputValue('I18NNamespace').'.$entity'"; } else { $res['php'] .= "'$entity'"; } @@ -915,7 +917,7 @@ class SSTemplateParser extends Parser implements TemplateParser break; default: - $res['php'] .= str_replace('$$FINAL', 'obj', $sub['php'] ?? '') . '->self()'; + $res['php'] .= str_replace('$$FINAL', 'scopeToIntermediateValue', $sub['php'] ?? '') . '->self()'; break; } } @@ -947,8 +949,8 @@ class SSTemplateParser extends Parser implements TemplateParser $template = $res['template']; $arguments = $res['arguments']; - // Note: 'type' here is important to disable subTemplates in SSViewer::getSubtemplateFor() - $res['php'] = '$val .= \\SilverStripe\\View\\SSViewer::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getItem(), [' . + // Note: 'type' here is important to disable subTemplates in SSTemplateEngine::getSubtemplateFor() + $res['php'] = '$val .= \\SilverStripe\\View\\SSTemplateEngine::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getCurrentItem(), [' . implode(',', $arguments)."], \$scope, true);\n"; if ($this->includeDebuggingComments) { // Add include filename comments on dev sites @@ -1037,7 +1039,8 @@ class SSTemplateParser extends Parser implements TemplateParser //loop without arguments loops on the current scope if ($res['ArgumentCount'] == 0) { - $on = '$scope->locally()->obj(\'Me\', [], true)'; + $type = ViewLayerData::TYPE_METHOD; + $on = "\$scope->locally()->scopeToIntermediateValue('Me', [], '$type')"; // @TODO use self instead or move $Me to scope explicitly } else { //loop in the normal way $arg = $res['Arguments'][0]; if ($arg['ArgumentMode'] == 'string') { @@ -1045,13 +1048,13 @@ class SSTemplateParser extends Parser implements TemplateParser } $on = str_replace( '$$FINAL', - 'obj', + 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php'] ); } return - $on . '; $scope->pushScope(); while (($key = $scope->next()) !== false) {' . PHP_EOL . + $on . '; $scope->pushScope(); while ($scope->next() !== false) {' . PHP_EOL . $res['Template']['php'] . PHP_EOL . '}; $scope->popScope(); '; } @@ -1071,7 +1074,7 @@ class SSTemplateParser extends Parser implements TemplateParser throw new SSTemplateParseException('Control block cant take string as argument.', $this); } - $on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); + $on = str_replace('$$FINAL', 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); return $on . '; $scope->pushScope();' . PHP_EOL . $res['Template']['php'] . PHP_EOL . @@ -1118,6 +1121,7 @@ class SSTemplateParser extends Parser implements TemplateParser /** * This is an open block handler, for the <% debug %> utility tag + * @TODO find out if this even works in CMS 5, and if so make sure it keeps working */ function OpenBlock_Handle_Debug(&$res) { @@ -1145,7 +1149,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; } /** diff --git a/src/View/SSTemplateParser.php b/src/View/SSTemplateParser.php index 4e4842489..3ccc7ecbd 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 */ @@ -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'] ?? ''); } } @@ -1887,7 +1889,7 @@ class SSTemplateParser extends Parser implements TemplateParser } else { $php = ($sub['ArgumentMode'] == 'default' ? $sub['lookup_php'] : $sub['php']); // TODO: kinda hacky - maybe we need a way to pass state down the parse chain so - // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of XML_val + // Lookup_LastLookupStep and Argument_BareWord can produce hasValue instead of getOutputValue $res['php'] .= str_replace('$$FINAL', 'hasValue', $php ?? ''); } } @@ -2470,7 +2472,7 @@ class SSTemplateParser extends Parser implements TemplateParser $res['php'] = ''; } - $res['php'] .= str_replace('$$FINAL', 'XML_val', $sub['php'] ?? ''); + $res['php'] .= str_replace('$$FINAL', 'getOutputValue', $sub['php'] ?? ''); } /* CacheBlockTemplate: (Comment | Translate | If | Require | OldI18NTag | Include | ClosedBlock | @@ -3587,7 +3589,7 @@ class SSTemplateParser extends Parser implements TemplateParser { $entity = $sub['String']['text']; if (strpos($entity ?? '', '.') === false) { - $res['php'] .= "\$scope->XML_val('I18NNamespace').'.$entity'"; + $res['php'] .= "\$scope->getOutputValue('I18NNamespace').'.$entity'"; } else { $res['php'] .= "'$entity'"; } @@ -3792,7 +3794,7 @@ class SSTemplateParser extends Parser implements TemplateParser break; default: - $res['php'] .= str_replace('$$FINAL', 'obj', $sub['php'] ?? '') . '->self()'; + $res['php'] .= str_replace('$$FINAL', 'scopeToIntermediateValue', $sub['php'] ?? '') . '->self()'; break; } } @@ -3896,8 +3898,8 @@ class SSTemplateParser extends Parser implements TemplateParser $template = $res['template']; $arguments = $res['arguments']; - // Note: 'type' here is important to disable subTemplates in SSViewer::getSubtemplateFor() - $res['php'] = '$val .= \\SilverStripe\\View\\SSViewer::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getItem(), [' . + // Note: 'type' here is important to disable subTemplates in SSTemplateEngine::getSubtemplateFor() + $res['php'] = '$val .= \\SilverStripe\\View\\SSTemplateEngine::execute_template([["type" => "Includes", '.$template.'], '.$template.'], $scope->getCurrentItem(), [' . implode(',', $arguments)."], \$scope, true);\n"; if ($this->includeDebuggingComments) { // Add include filename comments on dev sites @@ -4265,7 +4267,8 @@ class SSTemplateParser extends Parser implements TemplateParser //loop without arguments loops on the current scope if ($res['ArgumentCount'] == 0) { - $on = '$scope->locally()->obj(\'Me\', [], true)'; + $type = ViewLayerData::TYPE_METHOD; + $on = "\$scope->locally()->scopeToIntermediateValue('Me', [], '$type')"; // @TODO use self instead or move $Me to scope explicitly } else { //loop in the normal way $arg = $res['Arguments'][0]; if ($arg['ArgumentMode'] == 'string') { @@ -4273,13 +4276,13 @@ class SSTemplateParser extends Parser implements TemplateParser } $on = str_replace( '$$FINAL', - 'obj', + 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php'] ); } return - $on . '; $scope->pushScope(); while (($key = $scope->next()) !== false) {' . PHP_EOL . + $on . '; $scope->pushScope(); while ($scope->next() !== false) {' . PHP_EOL . $res['Template']['php'] . PHP_EOL . '}; $scope->popScope(); '; } @@ -4299,7 +4302,7 @@ class SSTemplateParser extends Parser implements TemplateParser throw new SSTemplateParseException('Control block cant take string as argument.', $this); } - $on = str_replace('$$FINAL', 'obj', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); + $on = str_replace('$$FINAL', 'scopeToIntermediateValue', ($arg['ArgumentMode'] == 'default') ? $arg['lookup_php'] : $arg['php']); return $on . '; $scope->pushScope();' . PHP_EOL . $res['Template']['php'] . PHP_EOL . @@ -4403,6 +4406,7 @@ class SSTemplateParser extends Parser implements TemplateParser /** * This is an open block handler, for the <% debug %> utility tag + * @TODO find out if this even works in CMS 5, and if so make sure it keeps working */ function OpenBlock_Handle_Debug(&$res) { @@ -4430,7 +4434,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; } /** diff --git a/src/View/SSViewer.php b/src/View/SSViewer.php index 63f0edc34..cac4d75f8 100644 --- a/src/View/SSViewer.php +++ b/src/View/SSViewer.php @@ -5,41 +5,20 @@ namespace SilverStripe\View; use SilverStripe\Core\Config\Config; use SilverStripe\Core\Config\Configurable; use SilverStripe\Core\ClassInfo; -use Psr\SimpleCache\CacheInterface; use SilverStripe\Core\Convert; -use SilverStripe\Core\Flushable; -use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Injector\Injectable; use SilverStripe\Control\Director; use SilverStripe\ORM\FieldType\DBField; use SilverStripe\ORM\FieldType\DBHTMLText; -use SilverStripe\Security\Permission; use InvalidArgumentException; -use SilverStripe\Model\ModelData; +use SilverStripe\Core\Injector\Injector; /** - * Parses a template file with an *.ss file extension. + * Class that manages themes and interacts with TemplateEngine classes to render templates. * - * In addition to a full template in the templates/ folder, a template in - * templates/Content or templates/Layout will be rendered into $Content and - * $Layout, respectively. - * - * A single template can be parsed by multiple nested {@link SSViewer} instances - * through $Layout/$Content placeholders, as well as <% include MyTemplateFile %> template commands. - * - * Themes - * - * See http://doc.silverstripe.org/themes and http://doc.silverstripe.org/themes:developing - * - * Caching - * - * Compiled templates are cached via {@link Cache}, usually on the filesystem. - * If you put ?flush=1 on your URL, it will force the template to be recompiled. - * - * @see http://doc.silverstripe.org/themes - * @see http://doc.silverstripe.org/themes:developing + * Ensures rendered templates are normalised, e.g have appropriate resources from the Requirements API. */ -class SSViewer implements Flushable +class SSViewer { use Configurable; use Injectable; @@ -57,18 +36,8 @@ class SSViewer implements Flushable /** * A list (highest priority first) of themes to use * Only used when {@link $theme_enabled} is set to TRUE. - * - * @config - * @var string */ - private static $themes = []; - - /** - * Overridden value of $themes config - * - * @var array - */ - protected static $current_themes = null; + private static array $themes = []; /** * Use the theme. Set to FALSE in order to disable themes, @@ -76,33 +45,29 @@ class SSViewer implements Flushable * such as an administrative interface separate from the website theme. * It retains the theme settings to be re-enabled, for example when a website content * needs to be rendered from within this administrative interface. - * - * @config - * @var bool */ - private static $theme_enabled = true; + private static bool $theme_enabled = true; /** * Default prepended cache key for partial caching - * - * @config - * @var string */ - private static $global_key = '$CurrentReadingMode, $CurrentUser.ID'; + private static string $global_key = '$CurrentReadingMode, $CurrentUser.ID'; /** - * @config - * @var bool + * If true, rendered templates will include comments indicating which template file was used. + * May not be supported for some rendering engines. */ - private static $source_file_comments = false; + private static bool $source_file_comments = false; /** * Set if hash links should be rewritten - * - * @config - * @var bool */ - private static $rewrite_hash_links = true; + private static bool $rewrite_hash_links = true; + + /** + * Overridden value of $themes config + */ + protected static array $current_themes = []; /** * Overridden value of rewrite_hash_links config @@ -120,59 +85,10 @@ class SSViewer implements Flushable protected $rewriteHashlinks = null; /** - * @internal - * @ignore */ - private static $template_cache_flushed = false; + protected bool $includeRequirements = true; - /** - * @internal - * @ignore - */ - private static $cacheblock_cache_flushed = false; - - /** - * List of items being processed - * - * @var array - */ - protected static $topLevel = []; - - /** - * List of templates to select from - * - * @var array - */ - protected $templates = null; - - /** - * Absolute path to chosen template file - * - * @var string - */ - protected $chosen = null; - - /** - * Templates to use when looking up 'Layout' or 'Content' - * - * @var array - */ - protected $subTemplates = []; - - /** - * @var bool - */ - protected $includeRequirements = true; - - /** - * @var TemplateParser - */ - protected $parser; - - /** - * @var CacheInterface - */ - protected $partialCacheStore = null; + private TemplateEngine $templateEngine; /** * @param string|array $templates If passed as a string with .ss extension, used as the "main" template. @@ -181,86 +97,41 @@ class SSViewer implements Flushable *
* array('MySpecificPage', 'MyPage', 'Page')
*
- * @param TemplateParser $parser
*/
- public function __construct($templates, TemplateParser $parser = null)
+ public function __construct(string|array $templates, ?TemplateEngine $templateEngine = null)
{
- if ($parser) {
- $this->setParser($parser);
+ if ($templateEngine) {
+ $templateEngine->setTemplate($templates);
+ } else {
+ $templateEngine = Injector::inst()->create(TemplateEngine::class, $templates);
}
-
- $this->setTemplate($templates);
-
- if (!$this->chosen) {
- $message = 'None of the following templates could be found: ';
- $message .= print_r($templates, true);
-
- $themes = SSViewer::get_themes();
- if (!$themes) {
- $message .= ' (no theme in use)';
- } else {
- $message .= ' in themes "' . print_r($themes, true) . '"';
- }
-
- user_error($message ?? '', E_USER_WARNING);
- }
- }
-
- /**
- * Triggered early in the request when someone requests a flush.
- */
- public static function flush()
- {
- SSViewer::flush_template_cache(true);
- SSViewer::flush_cacheblock_cache(true);
- }
-
- /**
- * Create a template from a string instead of a .ss file
- *
- * @param string $content The template content
- * @param bool|void $cacheTemplate Whether or not to cache the template from string
- * @return SSViewer
- */
- public static function fromString($content, $cacheTemplate = null)
- {
- $viewer = SSViewer_FromString::create($content);
- if ($cacheTemplate !== null) {
- $viewer->setCacheTemplate($cacheTemplate);
- }
- return $viewer;
+ $this->setTemplateEngine($templateEngine);
}
/**
* Assign the list of active themes to apply.
* If default themes should be included add $default as the last entry.
- *
- * @param array $themes
*/
- public static function set_themes($themes = [])
+ public static function set_themes(array $themes): void
{
static::$current_themes = $themes;
}
/**
* Add to the list of active themes to apply
- *
- * @param array $themes
*/
- public static function add_themes($themes = [])
+ public static function add_themes(array $themes)
{
$currentThemes = SSViewer::get_themes();
$finalThemes = array_merge($themes, $currentThemes);
// array_values is used to ensure sequential array keys as array_unique can leave gaps
- static::set_themes(array_values(array_unique($finalThemes ?? [])));
+ static::set_themes(array_values(array_unique($finalThemes)));
}
/**
* Get the list of active themes
- *
- * @return array
*/
- public static function get_themes()
+ public static function get_themes(): array
{
$default = [SSViewer::PUBLIC_THEME, SSViewer::DEFAULT_THEME];
@@ -270,7 +141,7 @@ class SSViewer implements Flushable
// Explicit list is assigned
$themes = static::$current_themes;
- if (!isset($themes)) {
+ if (empty($themes)) {
$themes = SSViewer::config()->uninherited('themes');
}
if ($themes) {
@@ -283,7 +154,7 @@ class SSViewer implements Flushable
/**
* Traverses the given the given class context looking for candidate template names
* which match each item in the class hierarchy. The resulting list of template candidates
- * may or may not exist, but you can invoke {@see SSViewer::chooseTemplate} on any list
+ * may or may not exist, but you can call hasTemplate() on a TemplateEngine
* to determine the best candidate based on the current themes.
*
* @param string|object $classOrObject Valid class name, or object
@@ -323,16 +194,58 @@ class SSViewer implements Flushable
}
/**
- * Get the current item being processed
+ * Get an associative array of names to information about callable template provider methods.
*
- * @return ModelData
+ * @var boolean $createObject If true, methods will be called on instantiated objects rather than statically on the class.
*/
- public static function topLevel()
+ public static function getMethodsFromProvider(string $providerInterface, $methodName, bool $createObject = false): array
{
- if (SSViewer::$topLevel) {
- return SSViewer::$topLevel[sizeof(SSViewer::$topLevel)-1];
+ $implementors = ClassInfo::implementorsOf($providerInterface);
+ if ($implementors) {
+ foreach ($implementors as $implementor) {
+ // Create a new instance of the object for method calls
+ if ($createObject) {
+ $implementor = new $implementor();
+ $exposedVariables = $implementor->$methodName();
+ } else {
+ $exposedVariables = $implementor::$methodName();
+ }
+
+ foreach ($exposedVariables as $varName => $details) {
+ if (!is_array($details)) {
+ $details = ['method' => $details];
+ }
+
+ // If just a value (and not a key => value pair), use method name for both key and value
+ if (is_numeric($varName)) {
+ $varName = $details['method'];
+ }
+
+ // Add in a reference to the implementing class (might be a string class name or an instance)
+ $details['implementor'] = $implementor;
+
+ // And a callable array
+ if (isset($details['method'])) {
+ $details['callable'] = [$implementor, $details['method']];
+ }
+
+ // Save with both uppercase & lowercase first letter, so either works
+ $lcFirst = strtolower($varName[0] ?? '') . substr($varName ?? '', 1);
+ $result[$lcFirst] = $details;
+ $result[ucfirst($varName)] = $details;
+ }
+ }
}
- return null;
+
+ return $result;
+ }
+
+ /**
+ * Get the template engine used to render templates for this viewer
+ */
+ public function getTemplateEngine(): TemplateEngine
+ {
+ return $this->templateEngine;
}
/**
@@ -384,81 +297,15 @@ class SSViewer implements Flushable
static::$current_rewrite_hash_links = $rewrite;
}
- /**
- * @param string|array $templates
- */
- public function setTemplate($templates)
- {
- $this->templates = $templates;
- $this->chosen = $this->chooseTemplate($templates);
- $this->subTemplates = [];
- }
-
- /**
- * Find the template to use for a given list
- *
- * @param array|string $templates
- * @return string
- */
- public static function chooseTemplate($templates)
- {
- return ThemeResourceLoader::inst()->findTemplate($templates, SSViewer::get_themes());
- }
-
- /**
- * Set the template parser that will be used in template generation
- *
- * @param TemplateParser $parser
- */
- public function setParser(TemplateParser $parser)
- {
- $this->parser = $parser;
- }
-
- /**
- * Returns the parser that is set for template generation
- *
- * @return TemplateParser
- */
- public function getParser()
- {
- if (!$this->parser) {
- $this->setParser(Injector::inst()->get('SilverStripe\\View\\SSTemplateParser'));
- }
- return $this->parser;
- }
-
- /**
- * Returns true if at least one of the listed templates exists.
- *
- * @param array|string $templates
- *
- * @return bool
- */
- public static function hasTemplate($templates)
- {
- return (bool)ThemeResourceLoader::inst()->findTemplate($templates, SSViewer::get_themes());
- }
-
/**
* Call this to disable rewriting of links. This is useful in Ajax applications.
* It returns the SSViewer objects, so that you can call new SSViewer("X")->dontRewriteHashlinks()->process();
- *
- * @return $this
*/
- public function dontRewriteHashlinks()
+ public function dontRewriteHashlinks(): static
{
return $this->setRewriteHashLinks(false);
}
- /**
- * @return string
- */
- public function exists()
- {
- return $this->chosen;
- }
-
/**
* @param string $identifier A template name without '.ss' extension or path
* @param string $type The template type, either "main", "Includes" or "Layout"
@@ -469,116 +316,14 @@ class SSViewer implements Flushable
return ThemeResourceLoader::inst()->findTemplate(['type' => $type, $identifier], SSViewer::get_themes());
}
- /**
- * Clears all parsed template files in the cache folder.
- *
- * Can only be called once per request (there may be multiple SSViewer instances).
- *
- * @param bool $force Set this to true to force a re-flush. If left to false, flushing
- * may only be performed once a request.
- */
- public static function flush_template_cache($force = false)
- {
- if (!SSViewer::$template_cache_flushed || $force) {
- $dir = dir(TEMP_PATH);
- while (false !== ($file = $dir->read())) {
- if (strstr($file ?? '', '.cache')) {
- unlink(TEMP_PATH . DIRECTORY_SEPARATOR . $file);
- }
- }
- SSViewer::$template_cache_flushed = true;
- }
- }
-
- /**
- * Clears all partial cache blocks.
- *
- * Can only be called once per request (there may be multiple SSViewer instances).
- *
- * @param bool $force Set this to true to force a re-flush. If left to false, flushing
- * may only be performed once a request.
- */
- public static function flush_cacheblock_cache($force = false)
- {
- if (!SSViewer::$cacheblock_cache_flushed || $force) {
- $cache = Injector::inst()->get(CacheInterface::class . '.cacheblock');
- $cache->clear();
-
-
- SSViewer::$cacheblock_cache_flushed = true;
- }
- }
-
- /**
- * Set the cache object to use when storing / retrieving partial cache blocks.
- *
- * @param CacheInterface $cache
- */
- public function setPartialCacheStore($cache)
- {
- $this->partialCacheStore = $cache;
- }
-
- /**
- * Get the cache object to use when storing / retrieving partial cache blocks.
- *
- * @return CacheInterface
- */
- public function getPartialCacheStore()
- {
- if ($this->partialCacheStore) {
- return $this->partialCacheStore;
- }
-
- return Injector::inst()->get(CacheInterface::class . '.cacheblock');
- }
-
/**
* Flag whether to include the requirements in this response.
- *
- * @param bool $incl
*/
- public function includeRequirements($incl = true)
+ public function includeRequirements(bool $incl = true)
{
$this->includeRequirements = $incl;
}
- /**
- * An internal utility function to set up variables in preparation for including a compiled
- * template, then do the include
- *
- * Effectively this is the common code that both SSViewer#process and SSViewer_FromString#process call
- *
- * @param string $cacheFile The path to the file that contains the template compiled to PHP
- * @param ModelData $item The item to use as the root scope for the template
- * @param array $overlay Any variables to layer on top of the scope
- * @param array $underlay Any variables to layer underneath the scope
- * @param ModelData $inheritedScope The current scope of a parent template including a sub-template
- * @return string The result of executing the template
- */
- protected function includeGeneratedTemplate($cacheFile, $item, $overlay, $underlay, $inheritedScope = null)
- {
- if (isset($_GET['showtemplate']) && $_GET['showtemplate'] && Permission::check('ADMIN')) {
- $lines = file($cacheFile ?? '');
- echo ""; - foreach ($lines as $num => $line) { - echo str_pad($num+1, 5) . htmlentities($line, ENT_COMPAT, 'UTF-8'); - } - echo ""; - } - - $cache = $this->getPartialCacheStore(); - $scope = new SSViewer_DataPresenter($item, $overlay, $underlay, $inheritedScope); - $val = ''; - - // Placeholder for values exposed to $cacheFile - [$cache, $scope, $val]; - include($cacheFile); - - return $val; - } - /** * The process() method handles the "meat" of the template processing. * @@ -590,70 +335,24 @@ class SSViewer implements Flushable * * Note: You can call this method indirectly by {@link ModelData->renderWith()}. * - * @param ModelData $item - * @param array|null $arguments Arguments to an included template - * @param ModelData $inheritedScope The current scope of a parent template including a sub-template - * @return DBHTMLText Parsed template output. + * @param array $overlay Associative array of fields for use in the template. + * These will override properties and methods with the same name from $data and from global + * template providers. */ - public function process($item, $arguments = null, $inheritedScope = null) + public function process(mixed $item, array $overlay = []): DBHTMLText { + $item = ViewLayerData::create($item); // Set hashlinks and temporarily modify global state $rewrite = $this->getRewriteHashLinks(); $origRewriteDefault = static::getRewriteHashLinksDefault(); static::setRewriteHashLinksDefault($rewrite); - SSViewer::$topLevel[] = $item; - - $template = $this->chosen; - - $cacheFile = TEMP_PATH . DIRECTORY_SEPARATOR . '.cache' - . str_replace(['\\','/',':'], '.', Director::makeRelative(realpath($template ?? '')) ?? ''); - $lastEdited = filemtime($template ?? ''); - - if (!file_exists($cacheFile ?? '') || filemtime($cacheFile ?? '') < $lastEdited) { - $content = file_get_contents($template ?? ''); - $content = $this->parseTemplateContent($content, $template); - - $fh = fopen($cacheFile ?? '', 'w'); - fwrite($fh, $content ?? ''); - fclose($fh); - } - - $underlay = ['I18NNamespace' => basename($template ?? '')]; - - // Makes the rendered sub-templates available on the parent item, - // through $Content and $Layout placeholders. - foreach (['Content', 'Layout'] as $subtemplate) { - // Detect sub-template to use - $sub = $this->getSubtemplateFor($subtemplate); - if (!$sub) { - continue; - } - - // Create lazy-evaluated underlay for this subtemplate - $underlay[$subtemplate] = function () use ($item, $arguments, $sub) { - $subtemplateViewer = clone $this; - // Disable requirements - this will be handled by the parent template - $subtemplateViewer->includeRequirements(false); - // Select the right template - $subtemplateViewer->setTemplate($sub); - - // Render if available - if ($subtemplateViewer->exists()) { - return $subtemplateViewer->process($item, $arguments); - } - return null; - }; - } - - $output = $this->includeGeneratedTemplate($cacheFile, $item, $arguments, $underlay, $inheritedScope); + $output = $this->getTemplateEngine()->render($item, $overlay);// this is where we tell the engine to render the template if ($this->includeRequirements) { $output = Requirements::includeInHTML($output); } - array_pop(SSViewer::$topLevel); - // If we have our crazy base tag, then fix # links referencing the current page. if ($rewrite) { if (strpos($output ?? '', '