Passed
Push — master ( 57cca1...ddf50c )
by Richard
03:09 queued 12s
created

Block::render()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 4
c 1
b 0
f 0
nc 2
nop 0
dl 0
loc 5
rs 10
1
<?php
2
3
4
namespace Riclep\Storyblok;
5
6
use Illuminate\Support\Collection;
7
use Illuminate\Support\Str;
8
use Illuminate\View\View;
9
use Riclep\Storyblok\Exceptions\MissingViewException;
10
use Riclep\Storyblok\Fields\Asset;
11
use Riclep\Storyblok\Fields\Image;
12
use Riclep\Storyblok\Fields\MultiAsset;
13
use Riclep\Storyblok\Fields\RichText;
14
use Riclep\Storyblok\Fields\Table;
15
use Riclep\Storyblok\Traits\CssClasses;
16
use Riclep\Storyblok\Traits\HasChildClasses;
17
use Riclep\Storyblok\Traits\HasMeta;
18
19
class Block implements \IteratorAggregate
20
{
21
	use CssClasses;
22
	use HasChildClasses;
23
	use HasMeta;
24
25
	/**
26
	 * @var bool resolve UUID relations automatically
27
	 */
28
	public $_autoResolveRelations = false;
29
30
	/**
31
	 * @var array list of field names containing relations to resolve
32
	 */
33
	public $_resolveRelations = [];
34
35
	/**
36
	 * @var array the path of nested components
37
	 */
38
	public $_componentPath = [];
39
40
	/**
41
	 * @var array the path of nested components
42
	 */
43
	protected $_casts = [];
44
45
	/**
46
	 * @var Collection all the fields for the Block
47
	 */
48
	private $_fields;
49
50
	/**
51
	 * @var Page|Block reference to the parent Block or Page
52
	 */
53
	private $_parent;
54
55
	/**
56
	 * Takes the Block’s content and a reference to the parent
57
	 * @param $content
58
	 * @param $parent
59
	 */
60
	public function __construct($content, $parent = null)
61
	{
62
		$this->_parent = $parent;
63
64
		$this->preprocess($content);
65
66
		if ($parent) {
67
			$this->_componentPath = array_merge($parent->_componentPath, [Str::lower($this->meta()['component'])]);
68
		}
69
70
		$this->processFields();
71
72
		// run automatic traits - methods matching initTraitClassName()
73
		foreach (class_uses_recursive($this) as $trait) {
74
			if (method_exists($this, $method = 'init' . class_basename($trait))) {
75
				$this->{$method}();
76
			}
77
		}
78
	}
79
80
	/**
81
	 * Returns the containing every field of content
82
	 *
83
	 * @return Collection
84
	 */
85
	public function content() {
86
		return $this->_fields;
87
	}
88
89
	/**
90
	 * Checks if the fields contain the specified key
91
	 *
92
	 * @param $key
93
	 * @return bool
94
	 */
95
	public function has($key) {
96
		return $this->_fields->has($key);
97
	}
98
99
	/**
100
	 * Returns the parent Block
101
	 *
102
	 * @return Block
103
	 */
104
	public function parent() {
105
		return $this->_parent;
106
	}
107
108
	/**
109
	 * Returns the page this Block belongs to
110
	 *
111
	 * @return Block
112
	 */
113
	public function page() {
114
		if ($this->parent() instanceof Page) {
0 ignored issues
show
introduced by
$this->parent() is never a sub-type of Riclep\Storyblok\Page.
Loading history...
115
			return $this->parent();
116
		}
117
118
		return $this->parent()->page();
119
	}
120
121
	/**
122
	 * Returns the first matching view, passing it the fields
123
	 *
124
	 * @return View
125
	 */
126
	public function render() {
127
		try {
128
			return view()->first($this->views(), ['block' => $this]);
129
		} catch (\Exception $exception) {
130
			throw new MissingViewException('None of the views in the given array exist: [' . implode(', ', $this->views()) . ']');
131
		}
132
	}
133
134
	/**
135
	 * Returns an array of possible views for the current Block based on
136
	 * it’s $componentPath match the component prefixed by each of it’s
137
	 * ancestors in turn, starting with the closest, for example:
138
	 *
139
	 * $componentPath = ['page', 'parent', 'child', 'this_block'];
140
	 *
141
	 * Becomes a list of possible views like so:
142
	 * ['child.this_block', 'parent.this_block', 'page.this_block'];
143
	 *
144
	 * Override this method with your custom implementation for
145
	 * ultimate control
146
	 *
147
	 * @return array
148
	 */
149
	public function views() {
150
		$compontentPath = $this->_componentPath;
151
		array_pop($compontentPath);
152
153
		$views = array_map(function($path) {
154
			return config('storyblok.view_path') . 'blocks.' . $path . '.' . $this->component();
155
		}, $compontentPath);
156
157
		$views = array_reverse($views);
158
159
		$views[] = config('storyblok.view_path') . 'blocks.' . $this->component();
160
161
		return $views;
162
	}
163
164
	/**
165
	 * Returns a component X generations previous
166
	 *
167
	 * @param $generation int
168
	 * @return mixed
169
	 */
170
	public function ancestorComponentName($generation)
171
	{
172
		return $this->_componentPath[count($this->_componentPath) - ($generation + 1)];
173
	}
174
175
	/**
176
	 * Checks if the current component is a child of another
177
	 *
178
	 * @param $parent string
179
	 * @return bool
180
	 */
181
	public function isChildOf($parent)
182
	{
183
		return $this->_componentPath[count($this->_componentPath) - 2] === $parent;
184
	}
185
186
	/**
187
	 * Checks if the component is an ancestor of another
188
	 *
189
	 * @param $parent string
190
	 * @return bool
191
	 */
192
	public function isAncestorOf($parent)
193
	{
194
		return in_array($parent, $this->parent()->_componentPath);
195
	}
196
197
	/**
198
	 * Returns the current Block’s component name from Storyblok
199
	 *
200
	 * @return string
201
	 */
202
	public function component() {
203
		return $this->_meta['component'];
204
	}
205
206
207
	/**
208
	 * Returns the HTML comment required for making this Block clickable in
209
	 * Storyblok’s visual editor. Don’t forget to set comments to true in
210
	 * your Vue.js app configuration.
211
	 *
212
	 * @return string
213
	 */
214
	public function editorLink() {
215
		if (array_key_exists('_editable', $this->_meta) && config('storyblok.edit_mode')) {
216
			return $this->_meta['_editable'];
217
		}
218
219
		return '';
220
	}
221
222
223
	/**
224
	 * Magic accessor to pull content from the _fields collection. Works just like
225
	 * Laravel’s model accessors. Matches public methods with the follow naming
226
	 * convention getSomeFieldAttribute() - called via $block->some_field
227
	 *
228
	 * @param $key
229
	 * @return null|string
230
	 */
231
	public function __get($key) {
232
		$accessor = 'get' . Str::studly($key) . 'Attribute';
233
234
		if (method_exists($this, $accessor)) {
235
			return $this->$accessor();
236
		}
237
238
		if ($this->has($key)) {
239
			return $this->_fields[$key];
240
		}
241
242
		return null;
243
	}
244
245
	/**
246
	 * Loops over every field to get te ball rolling
247
	 */
248
	private function processFields() {
249
		$this->_fields->transform(function ($field, $key) {
250
			return $this->getFieldType($field, $key);
251
		});
252
	}
253
254
	/**
255
	 * Converts fields into Field Classes based on various properties of their content
256
	 *
257
	 * @param $field
258
	 * @param $key
259
	 * @return array|Collection|mixed|Asset|Image|MultiAsset|RichText|Table
260
	 * @throws \Storyblok\ApiException
261
	 */
262
	private function getFieldType($field, $key) {
263
		// TODO process old asset fields
264
		// TODO option to convert all text fields to a class - single or multiline?
265
266
		// does the Block assign any $_casts? This is key (field) => value (class)
267
		if (property_exists($this, '_casts') && array_key_exists($key, $this->_casts)) {
268
			return new $this->_casts[$key]($field, $this);
269
		}
270
271
		// find Fields specific to this Block matching: BlockNameFieldName
272
		if ($class = $this->getChildClassName('Field', $this->component() . '_' . $key)) {
273
			return new $class($field, $this);
274
		}
275
276
		// auto-match Field classes
277
		if ($class = $this->getChildClassName('Field', $key)) {
278
			return new $class($field, $this);
279
		}
280
281
		// single item relations
282
		if (Str::isUuid($field) && ($this->_autoResolveRelations || in_array($key, $this->_resolveRelations))) {
283
			return $this->getRelation(new RequestStory(), $field);
284
		}
285
286
		// complex fields
287
		if (is_array($field) && !empty($field)) {
288
			return $this->arrayFieldTypes($field, $key);
289
		}
290
291
		// legacy image fields
292
		if (is_string($field) && Str::endsWith($field, ['.jpg', '.jpeg', '.png', '.gif', '.webp'])) {
293
			return new Image($field, $this);
294
		}
295
296
		// strings or anything else - do nothing
297
		return $field;
298
	}
299
300
301
	/**
302
	 * When the field is an array we need to do more processing
303
	 *
304
	 * @param $field
305
	 * @return Collection|mixed|Asset|Image|MultiAsset|RichText|Table
306
	 */
307
	private function arrayFieldTypes($field, $key) {
308
		// match link fields
309
		if (array_key_exists('linktype', $field)) {
310
			$class = 'Riclep\Storyblok\Fields\\' . Str::studly($field['linktype']) . 'Link';
311
312
			return new $class($field, $this);
313
		}
314
315
		// match rich-text fields
316
		if (array_key_exists('type', $field) && $field['type'] === 'doc') {
317
			return new RichText($field, $this);
318
		}
319
320
		// match asset fields - detecting raster images
321
		if (array_key_exists('fieldtype', $field) && $field['fieldtype'] === 'asset') {
322
			if (Str::endsWith($field['filename'], ['.jpg', '.jpeg', '.png', '.gif', '.webp'])) {
323
				return new Image($field, $this);
324
			}
325
326
			return new Asset($field, $this);
327
		}
328
329
		// match table fields
330
		if (array_key_exists('fieldtype', $field) && $field['fieldtype'] === 'table') {
331
			return new Table($field, $this);
332
		}
333
334
		if (array_key_exists(0, $field)) {
335
			// it’s an array of relations - request them if we’re auto or manual resolving
336
			if (Str::isUuid($field[0])) {
337
				if ($this->_autoResolveRelations || in_array($key, $this->_resolveRelations)) {
338
					return collect($field)->transform(function ($relation) {
339
						return $this->getRelation(new RequestStory(), $relation);
340
					});
341
				}
342
			}
343
344
			// has child items - single option, multi option and Blocks fields
345
			if (is_array($field[0])) {
346
				// resolved relationships - entire story is returned, we just want the content and a few meta items
347
				if (array_key_exists('content', $field[0])) {
348
					return collect($field)->transform(function ($relation) {
349
						$class = $this->getChildClassName('Block', $relation['content']['component']);
350
						$relationClass = new $class($relation['content'], $this);
351
352
						$relationClass->addMeta([
353
							'name' => $relation['name'],
354
							'published_at' => $relation['published_at'],
355
							'full_slug' => $relation['full_slug'],
356
						]);
357
358
						return $relationClass;
359
					});
360
				}
361
362
				// this field holds blocks!
363
				if (array_key_exists('component', $field[0])) {
364
					return collect($field)->transform(function ($block) {
365
						$class = $this->getChildClassName('Block', $block['component']);
366
367
						return new $class($block, $this);
368
					});
369
				}
370
371
				// multi assets
372
				if (array_key_exists('filename', $field[0])) {
373
					return new MultiAsset($field, $this);
374
				}
375
			}
376
		}
377
378
		// just return the array
379
		return $field;
380
	}
381
382
	/**
383
	 * Storyblok returns fields and other meta content at the same level so
384
	 * let’s do a little tidying up first
385
	 *
386
	 * @param $content
387
	 */
388
	private function preprocess($content) {
389
		$this->_fields = collect(array_diff_key($content, array_flip(['_editable', '_uid', 'component'])));
390
391
		// remove non-content keys
392
		$this->_meta = array_intersect_key($content, array_flip(['_editable', '_uid', 'component']));
393
	}
394
395
	/**
396
	 * Returns cotent of the field. In the visual editor it returns a VueJS template tag
397
	 *
398
	 * @param $field
399
	 * @return string
400
	 */
401
	public function liveField($field) {
402
		if (config('storyblok.edit_mode')) {
403
			return '{{ Object.keys(laravelStoryblokLive).length ? laravelStoryblokLive.uuid_' . str_replace('-', '_', $this->uuid()) . '.' . $field . ' : null }}';
404
		}
405
406
		return $this->{$field};
407
	}
408
409
	/**
410
	 * Flattens all the fields in an array keyed by their UUID to make linking the JS simple
411
	 */
412
	public function flatten() {
413
		$this->content()->each(function ($item, $key) {
414
415
			if ($item instanceof Collection) {
416
				$item->each(function ($item) {
417
					$item->flatten();
418
				});
419
			} elseif ($item instanceof Field) {
420
				$this->page()->liveContent['uuid_' . str_replace('-', '_', $this->uuid())][$key] = (string) $item;
0 ignored issues
show
Bug Best Practice introduced by
The property liveContent does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
421
			} else {
422
				$this->page()->liveContent['uuid_' . str_replace('-', '_', $this->uuid())][$key] = $item;
423
			}
424
		});
425
	}
426
427
	/**
428
	 * Let’s up loop over the fields in Blade without needing to
429
	 * delve deep into the content collection
430
	 *
431
	 * @return \Traversable
432
	 */
433
	public function getIterator() {
434
		return $this->_fields;
435
	}
436
437
	protected function getRelation(RequestStory $request, $relation) {
438
		$response = $request->get($relation);
439
440
		$class = $this->getChildClassName('Block', $response['content']['component']);
441
		$relationClass = new $class($response['content'], $this);
442
443
		$relationClass->addMeta([
444
			'name' => $response['name'],
445
			'published_at' => $response['published_at'],
446
			'full_slug' => $response['full_slug'],
447
		]);
448
449
		return $relationClass;
450
	}
451
}