Rewrite pages and fields

This commit is contained in:
Giuseppe Criscione 2022-11-27 19:40:38 +01:00
parent b61bdfeb6d
commit 23e8d19d74
106 changed files with 2897 additions and 2831 deletions

View File

@ -1,120 +1,57 @@
title: User title: User
layout:
type: sections
sections:
user:
label: '{{admin.users.user}}'
fields: [fullname, email, password, language, role, color-scheme, avatar]
fields: fields:
rows1: fullname:
type: rows type: text
fields: label: '{{admin.user.fullname}}'
row1: required: true
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.user.fullname}}'
column2:
type: column
width: 2-3
fields:
fullname:
type: text
required: true
row2:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.user.email}}'
column2:
type: column
width: 2-3
fields:
email:
type: email
required: true
row3: email:
type: row type: email
fields: label: '{{admin.user.email}}'
column1: required: true
type: column
width: 1-3
label: '{{admin.user.password}}'
column2:
type: column
width: 2-3
fields:
password:
type: password
placeholder: '{{admin.user.password.type-new-password}}'
disabled: true
pattern: '^.{8,}$'
autocomplete: new-password
row4: password:
type: row type: password
fields: label: '{{admin.user.password}}'
column1: placeholder: '{{admin.user.password.type-new-password}}'
type: column disabled: true
width: 1-3 pattern: '^.{8,}$'
label: '{{admin.user.language}}' autocomplete: new-password
column2:
type: column
width: 2-3
fields:
language:
type: select
required: true
translate: false
import:
options: 'Formwork\Admin\Admin::availableTranslations'
row5: language:
type: row type: select
fields: label: '{{admin.user.language}}'
column1: required: true
type: column translate: [label]
width: 1-3 import:
label: '{{admin.user.role}}' options: 'Formwork\Admin\Admin::availableTranslations'
column2:
type: column
width: 2-3
fields:
role:
type: select
disabled: true
import:
options: 'Formwork\Admin\Users\Users::availableRoles'
row6: role:
type: row type: select
fields: label: '{{admin.user.role}}'
column1: disabled: true
type: column import:
width: 1-3 options: 'Formwork\Admin\Users\Users::availableRoles'
label: '{{admin.user.color-scheme}}'
column2:
type: column
width: 2-3
fields:
color-scheme:
type: togglegroup
options:
light: '{{admin.user.color-scheme.light}}'
dark: '{{admin.user.color-scheme.dark}}'
auto: '{{admin.user.color-scheme.auto}}'
row7:
type: row color-scheme:
fields: type: togglegroup
column1: label: '{{admin.user.color-scheme}}'
type: column options:
width: 1-3 light: '{{admin.user.color-scheme.light}}'
label: '{{admin.user.avatar}}' dark: '{{admin.user.color-scheme.dark}}'
column2: auto: '{{admin.user.color-scheme.auto}}'
type: column
width: 2-3 avatar:
fields: type: file
avatar: label: '{{admin.user.avatar}}'
type: file accept: .jpg, .jpeg, .png, .gif
accept: .jpg, .jpeg, .png, .gif

View File

@ -176,7 +176,7 @@ admin.pages.page.status: Status
admin.pages.page.tags: Tags admin.pages.page.tags: Tags
admin.pages.page.title: Title admin.pages.page.title: Title
admin.pages.page.unpublish-date: Unpublish Date admin.pages.page.unpublish-date: Unpublish Date
admin.pages.page.visible: Visible in the menu admin.pages.page.listed: Visible in the menu
admin.pages.pages: Pages admin.pages.pages: Pages
admin.pages.pages.collapse-all: Collapse All admin.pages.pages.collapse-all: Collapse All
admin.pages.pages.expand-all: Expand All admin.pages.pages.expand-all: Expand All
@ -190,7 +190,9 @@ admin.pages.status.not-published: Not Published
admin.pages.status.not-routable: Not Routable admin.pages.status.not-routable: Not Routable
admin.pages.status.published: Published admin.pages.status.published: Published
admin.pages.status.routable: Routable admin.pages.status.routable: Routable
admin.pages.summary: Summary
admin.pages.template: Template admin.pages.template: Template
admin.pages.text: Text
admin.pages.toggle-children: Toggle Children Pages admin.pages.toggle-children: Toggle Children Pages
admin.panel: Administration Panel admin.panel: Administration Panel
admin.register.create-user: Formwork Admin is installed but no users were found. Please register a user now. admin.register.create-user: Formwork Admin is installed but no users were found. Please register a user now.

View File

@ -1,5 +1 @@
<?php foreach ($fields as $field): ?> <?php $this->insert('fields.layout.' . $fields->layout()->type(), ['sections' => $fields->layout()->sections()]) ?>
<?php if ($field->isVisible()): ?>
<?php $this->insert('fields.' . $field->type(), ['field' => $field]) ?>
<?php endif; ?>
<?php endforeach; ?>

View File

@ -1,7 +1,8 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<div <?= $this->attr([ <div <?= $this->attr([
'class' => ['input-array', $field->get('associative') ? 'input-array-associative' : ''], 'class' => ['input-array', $field->get('associative') ? 'input-array-associative' : ''],
'id' => $field->name(), 'id' => $field->name(),
'hidden' => $field->isHidden(),
'data-name' => $field->formName() 'data-name' => $field->formName()
]) ?>> ]) ?>>
<?php foreach ($field->value() ?: ['' => ''] as $key => $value): ?> <?php foreach ($field->value() ?: ['' => ''] as $key => $value): ?>

View File

@ -1,14 +1,16 @@
<div> <div>
<label class="input-checkbox-label"> <label class="input-checkbox-label">
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'checkbox', 'type' => 'checkbox',
'class' => 'input-checkbox', 'class' => 'input-checkbox',
'id' => $field->name(), 'id' => $field->name(),
'name' => $field->formName(), 'name' => $field->formName(),
'checked' => $field->value() == true, 'checked' => $field->value() == true,
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled() 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>
<span class="input-checkbox-text"><?= $field->label() ?></span> <span class="input-checkbox-text"><?= $field->label() ?></span>
</label> </label>
</div> </div>
<?= $this->insert('fields.partials.description') ?>

View File

@ -1,7 +0,0 @@
<div class="col-m-<?= $field->get('width') ?>">
<?php if ($field->has('label')): ?>
<?= $field->label() ?>
<?php else: ?>
<?php $this->insert('fields', ['fields' => $field->get('fields')]) ?>
<?php endif; ?>
</div>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<div class="input-wrap"> <div class="input-wrap">
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'text', 'type' => 'text',
@ -8,7 +8,8 @@
'value' => $field->value(), 'value' => $field->value(),
'placeholder' => $field->placeholder(), 'placeholder' => $field->placeholder(),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled() 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>
<span class="input-reset" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span> <span class="input-reset" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span>
</div> </div>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'number', 'type' => 'number',
'id' => $field->name(), 'id' => $field->name(),
@ -9,6 +9,7 @@
'value' => $field->value(), 'value' => $field->value(),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled(), 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden(),
'data-field' => 'duration', 'data-field' => 'duration',
'data-unit' => $field->get('unit', 'seconds'), 'data-unit' => $field->get('unit', 'seconds'),
'data-intervals' => $field->has('intervals') ? implode(', ', $field->get('intervals')) : null 'data-intervals' => $field->has('intervals') ? implode(', ', $field->get('intervals')) : null

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'email', 'type' => 'email',
'id' => $field->name(), 'id' => $field->name(),
@ -9,5 +9,7 @@
'maxlength' => $field->get('max'), 'maxlength' => $field->get('max'),
'pattern' => $field->get('pattern'), 'pattern' => $field->get('pattern'),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled() 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'file', 'type' => 'file',
'class' => 'input-file', 'class' => 'input-file',
@ -6,7 +6,10 @@
'name' => $field->formName() . '[]', 'name' => $field->formName() . '[]',
'accept' => $field->get('accept', implode(', ', $formwork->config()->get('files.allowed_extensions'))), 'accept' => $field->get('accept', implode(', ', $formwork->config()->get('files.allowed_extensions'))),
'data-auto-upload' => $field->get('auto-upload') ? 'true' : 'false', 'data-auto-upload' => $field->get('auto-upload') ? 'true' : 'false',
'multiple' => $field->get('multiple') ? true : false 'multiple' => $field->get('multiple'),
'required' => $field->isRequired(),
'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>
<label for="<?= $field->name() ?>" class="input-file-label"> <label for="<?= $field->name() ?>" class="input-file-label">
<span><?= $this->translate('fields.file.upload-label') ?></span> <span><?= $this->translate('fields.file.upload-label') ?></span>

View File

@ -1 +0,0 @@
<div class="section-header"><?= $field->label() ?></div>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<div class="input-wrap"> <div class="input-wrap">
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'text', 'type' => 'text',
@ -7,7 +7,10 @@
'name' => $field->formName(), 'name' => $field->formName(),
'value' => basename($field->value() ?? ''), 'value' => basename($field->value() ?? ''),
'placeholder' => $field->placeholder(), 'placeholder' => $field->placeholder(),
'readonly' => true 'readonly' => true,
'required' => $field->isRequired(),
'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>
<span class="input-reset" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span> <span class="input-reset" data-reset="<?= $field->name() ?>"><?= $this->icon('times-circle') ?></span>
</div> </div>

View File

@ -0,0 +1,5 @@
<?php foreach ($fields as $field): ?>
<?php if ($field->isVisible()): ?>
<?php $this->insert('fields.' . $field->type(), ['field' => $field]) ?>
<?php endif; ?>
<?php endforeach; ?>

View File

@ -1,23 +1,32 @@
<div class="sections"> <div class="sections">
<?php <?php
foreach ($field->get('fields') as $section): foreach ($sections as $section):
?> ?>
<div <?= $this->attr(['class' => ['section', $section->is('collapsible') ? 'collapsible' : '', $section->is('collapsed') ? 'collapsed' : '']]) ?>> <div <?= $this->attr(['class' => ['section', $section->is('collapsible') ? 'collapsible' : '', $section->is('collapsed') ? 'collapsed' : '']]) ?>>
<div class="section-header"> <div class="section-header">
<?php <?php
if ($section->is('collapsible')): if ($section->is('collapsible')):
?> ?>
<span class="section-toggle"><?= $this->icon('chevron-up') ?></span> <span class="section-toggle"><?= $this->icon('chevron-up') ?></span>
<?php <?php
endif; endif;
?> ?>
<?= $section->label() ?> <?= $section->label() ?>
</div> </div>
<div class="section-content" style="padding: 0 .5rem;"> <div class="section-content" style="padding: 0 .5rem;">
<?php $this->insert('fields', ['fields' => $section->get('fields')]) ?> <?php
foreach ($fields->getMultiple($section->get('fields', [])) as $field):
?>
<?php if ($field->isVisible()): ?>
<?php $this->insert('fields.' . $field->type(), ['field' => $field]) ?>
<?php endif; ?>
<?php
endforeach;
?>
</div> </div>
</div> </div>
<?php <?php
endforeach; endforeach;
?> ?>
</div> </div>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<div class="editor-wrap"> <div class="editor-wrap">
<div class="editor-toolbar" data-for="<?= $field->name() ?>"> <div class="editor-toolbar" data-for="<?= $field->name() ?>">
<button type="button" class="toolbar-button" data-command="bold" title="<?= $this->translate('admin.pages.editor.bold') ?>"><?= $this->icon('bold') ?></button> <button type="button" class="toolbar-button" data-command="bold" title="<?= $this->translate('admin.pages.editor.bold') ?>"><?= $this->icon('bold') ?></button>
@ -18,9 +18,15 @@
'class' => 'editor-textarea', 'class' => 'editor-textarea',
'id' => $field->name(), 'id' => $field->name(),
'name' => $field->formName(), 'name' => $field->formName(),
'value' => $field->value(),
'placeholder' => $field->placeholder(), 'placeholder' => $field->placeholder(),
'minlength' => $field->get('min'),
'maxlength' => $field->get('max'),
'autocomplete' => $field->get('autocomplete', 'off'),
'rows' => $field->get('rows'),
'cols' => $field->get('cols'),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled(), 'disabled' => $field->isDisabled(),
'autocomplete' => 'off' 'hidden' => $field->isHidden()
]) ?>><?= $this->escape($field->value() ?? '') ?></textarea> ]) ?>><?= $this->escape($field->value() ?? '') ?></textarea>
</div> </div>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'number', 'type' => 'number',
'id' => $field->name(), 'id' => $field->name(),
@ -9,5 +9,6 @@
'value' => $field->value(), 'value' => $field->value(),
'placeholder' => $field->placeholder(), 'placeholder' => $field->placeholder(),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled() 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>

View File

@ -1,21 +1,21 @@
<ul class="files-list"> <ul class="files-list">
<?php <?php
foreach ($page->files() as $file): foreach ($page->files() as $file):
?> ?>
<li> <li>
<div class="files-item"> <div class="files-item">
<?= $this->icon(is_null($file->type()) ? 'file' : 'file-' . $file->type()) ?> <div class="files-item-cell file-name" data-overflow-tooltip="true"><?= $file->name() ?> <span class="file-size">(<?= $file->size() ?>)</span></div> <?= $this->icon(is_null($file->type()) ? 'file' : 'file-' . $file->type()) ?> <div class="files-item-cell file-name" data-overflow-tooltip="true"><?= $file->name() ?> <span class="file-size">(<?= $file->size() ?>)</span></div>
<div class="files-item-cell file-actions"> <div class="files-item-cell file-actions">
<a class="button button-link" role="button" href="<?= $admin->pageUri($page) . $file->name() ?>" target="formwork-preview-file-<?= $file->hash() ?>" title="<?= $this->translate('admin.pages.preview-file') ?>" aria-label="title="<?= $this->translate('admin.pages.preview-file') ?>""><?= $this->icon('eye') ?></a> <a class="button button-link" role="button" href="<?= $admin->pageUri($page) . $file->name() ?>" target="formwork-preview-file-<?= $file->hash() ?>" title="<?= $this->translate('admin.pages.preview-file') ?>" aria-label="title="<?= $this->translate('admin.pages.preview-file') ?>""><?= $this->icon('eye') ?></a>
<?php <?php
if ($admin->user()->permissions()->has('pages.delete_files')): if ($admin->user()->permissions()->has('pages.delete_files')):
?> ?>
<button type="button" class="button-link" data-modal="deleteFileModal" data-modal-action="<?= $admin->uri('/pages/' . trim($page->route(), '/') . '/file/' . $file->name() . '/delete/') ?>" title="<?= $this->translate('admin.pages.delete-file') ?>" aria-label="<?= $this->translate('admin.pages.delete-file') ?>"> <button type="button" class="button-link" data-modal="deleteFileModal" data-modal-action="<?= $admin->uri('/pages/' . trim($page->route(), '/') . '/file/' . $file->name() . '/delete/') ?>" title="<?= $this->translate('admin.pages.delete-file') ?>" aria-label="<?= $this->translate('admin.pages.delete-file') ?>">
<?= $this->icon('trash') ?> <?= $this->icon('trash') ?>
</button> </button>
<?php <?php
endif; endif;
?> ?>
</div> </div>
</div> </div>
</li> </li>

View File

@ -1,12 +1,16 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<select id="page-parent" name="parent"> <select id="page-parent" name="parent">
<option value="." <?php if ($page->parent()->isSite()): ?> selected<?php endif; ?>><?= $this->translate('admin.pages.new-page.site') ?> (/)</option> <option value="." <?php if ($page->parent()->isSite()): ?> selected<?php endif; ?>><?= $this->translate('admin.pages.new-page.site') ?> (/)</option>
<?php <?php
foreach ($parents as $parent): foreach ($parents as $parent):
$scheme = $formwork->schemes()->get('pages', $parent->template()->name()); $scheme = $formwork->schemes()->get('pages', $parent->template()->name());
if (!$scheme->get('pages', true)) continue; if (!$scheme->get('pages', true)) {
if ($parent === $page) continue; continue;
?> }
if ($parent === $page) {
continue;
}
?>
<option value="<?= $parent->route() ?>"<?php if ($page->parent() === $parent): ?> selected<?php endif; ?>><?= str_repeat('— ', $parent->level() - 1) . $parent->title() ?></option> <option value="<?= $parent->route() ?>"<?php if ($page->parent() === $parent): ?> selected<?php endif; ?>><?= str_repeat('— ', $parent->level() - 1) . $parent->title() ?></option>
<?php <?php
endforeach; endforeach;

View File

@ -1,9 +1,9 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<select id="page-template" name="template"> <select id="page-template" name="template">
<?php <?php
foreach ($templates as $template): foreach ($templates as $template):
$scheme = $formwork->schemes()->get('pages', $template); $scheme = $formwork->schemes()->get('pages', $template);
?> ?>
<option value="<?= $template ?>"<?php if ($page->template()->name() === $template): ?> selected<?php endif; ?>><?= $scheme->title() ?></option> <option value="<?= $template ?>"<?php if ($page->template()->name() === $template): ?> selected<?php endif; ?>><?= $scheme->title() ?></option>
<?php <?php
endforeach; endforeach;

View File

@ -0,0 +1,3 @@
<?php if ($field->has('description')): ?>
<div style="font-size: 0.875rem; color: #7d7d7d; margin-bottom: 0.75rem;"><?= $this->markdown($field->get('description')); ?></div>
<?php endif; ?>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'password', 'type' => 'password',
'id' => $field->name(), 'id' => $field->name(),
@ -10,5 +10,6 @@
'pattern' => $field->get('pattern'), 'pattern' => $field->get('pattern'),
'autocomplete' => $field->get('autocomplete'), 'autocomplete' => $field->get('autocomplete'),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled() 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>

View File

@ -1,13 +1,16 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<input <?= $this->attr([ <div>
'type' => 'range', <input <?= $this->attr([
'id' => $field->name(), 'type' => 'range',
'name' => $field->formName(), 'id' => $field->name(),
'min' => $field->get('min'), 'name' => $field->formName(),
'max' => $field->get('max'), 'min' => $field->get('min'),
'step' => $field->get('step'), 'max' => $field->get('max'),
'value' => $field->value(), 'step' => $field->get('step'),
'required' => $field->isRequired(), 'value' => $field->value(),
'disabled' => $field->isDisabled() 'required' => $field->isRequired(),
]) ?>> 'disabled' => $field->isDisabled(),
<output class="input-range-value" for="<?= $field->name() ?>"><?= $field->value() ?></output> 'hidden' => $field->isHidden()
]) ?>>
<output class="input-range-value" for="<?= $field->name() ?>"><?= $field->value() ?></output>
</div>

View File

@ -1,3 +0,0 @@
<div class="row">
<?php $this->insert('fields', ['fields' => $field->get('fields')]) ?>
</div>

View File

@ -1,3 +0,0 @@
<div class="container-full">
<?php $this->insert('fields', ['fields' => $field->get('fields')]) ?>
</div>

View File

@ -1,9 +1,10 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<select <?= $this->attr([ <select <?= $this->attr([
'id' => $field->name(), 'id' => $field->name(),
'name' => $field->formName(), 'name' => $field->formName(),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled() 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>
<?php foreach ((array) $field->get('options') as $value => $label): ?> <?php foreach ((array) $field->get('options') as $value => $label): ?>
<option <?= $this->attr(['value' => $value, 'selected' => $value == $field->value()]) ?>><?= $label ?></option> <option <?= $this->attr(['value' => $value, 'selected' => $value == $field->value()]) ?>><?= $label ?></option>

View File

@ -1,13 +1,9 @@
<div class="tabs"> <div class="tabs">
<?php <?php foreach ($field->get('fields') as $tab): ?>
foreach ($field->get('fields') as $tab):
?>
<a <?= $this->attr([ <a <?= $this->attr([
'class' => ['tabs-tab', $tab->get('active') ? 'active' : ''], 'class' => ['tabs-tab', $tab->get('active') ? 'active' : ''],
'data-tab' => $tab->name() 'data-tab' => $tab->name()
]) ?>><?= $tab->label() ?></a> ]) ?>><?= $tab->label() ?></a>
<?php <?php endforeach; ?>
endforeach;
?>
</div> </div>
<?php $this->insert('fields', ['fields' => $field->get('fields')]) ?> <?php $this->insert('fields', ['fields' => $field->get('fields')]) ?>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<input <?= $this->attr([ <input <?= $this->attr([
'type' => 'text', 'type' => 'text',
'id' => $field->name(), 'id' => $field->name(),
@ -7,6 +7,7 @@
'placeholder' => $field->placeholder(), 'placeholder' => $field->placeholder(),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled(), 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden(),
'data-field' => 'tags', 'data-field' => 'tags',
'data-options' => $field->has('options') ? Formwork\Parsers\JSON::encode((array) $field->get('options')) : null 'data-options' => $field->has('options') ? Formwork\Parsers\JSON::encode((array) $field->get('options')) : null
]) ?>> ]) ?>>

View File

@ -1,4 +1,4 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<input <?= $this->attr([ <input <?= $this->attr([
'class' => $field->get('class'), 'class' => $field->get('class'),
'type' => 'text', 'type' => 'text',
@ -10,5 +10,6 @@
'maxlength' => $field->get('max'), 'maxlength' => $field->get('max'),
'pattern' => $field->get('pattern'), 'pattern' => $field->get('pattern'),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled() 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>> ]) ?>>

View File

@ -1,8 +1,9 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<textarea <?= $this->attr([ <textarea <?= $this->attr([
'id' => $field->name(), 'id' => $field->name(),
'name' => $field->formName(), 'name' => $field->formName(),
'placeholder' => $field->placeholder(), 'placeholder' => $field->placeholder(),
'required' => $field->isRequired(), 'required' => $field->isRequired(),
'disabled' => $field->isDisabled() 'disabled' => $field->isDisabled(),
'hidden' => $field->isHidden()
]) ?>><?= $field->value() ?></textarea> ]) ?>><?= $field->value() ?></textarea>

View File

@ -1,18 +1,21 @@
<?= $this->insert('fields.label', ['field' => $field]) ?> <?= $this->layout('fields.field') ?>
<fieldset <?= $this->attr([ <div>
'id' => $field->name(), <fieldset <?= $this->attr([
'class' => 'input-togglegroup', 'id' => $field->name(),
'disabled' => $field->isDisabled() 'class' => 'input-togglegroup',
]) ?>> 'disabled' => $field->isDisabled(),
<?php foreach ((array) $field->get('options') as $value => $label): ?> 'hidden' => $field->isHidden()
<label> ]) ?>>
<input <?= $this->attr([ <?php foreach ((array) $field->get('options') as $value => $label): ?>
'type' => 'radio', <label>
'name' => $field->formName(), <input <?= $this->attr([
'value' => $value, 'type' => 'radio',
'checked' => $value == $field->value() 'name' => $field->formName(),
]) ?>> 'value' => $value,
<span><?= $label ?></span> 'checked' => $value == $field->value()
</label> ]) ?>>
<?php endforeach; ?> <span><?= $label ?></span>
</fieldset> </label>
<?php endforeach; ?>
</fieldset>
</div>

View File

@ -0,0 +1,3 @@
<?= $this->insert('fields.partials.label') ?>
<?= $this->content() ?>
<?= $this->insert('fields.partials.description') ?>

View File

@ -5,7 +5,7 @@
<div> <div>
<?php if (!$page->isIndexPage() && !$page->isErrorPage()): ?> <?php if (!$page->isIndexPage() && !$page->isErrorPage()): ?>
<div class="page-route page-route-changeable"> <div class="page-route page-route-changeable">
<button type="button" class="page-slug-change" data-command="change-slug" title="<?= $this->translate('admin.pages.change-slug') ?>"><?= $page->route() ?></button><?= $this->icon('pencil') ?> <button type="button" class="page-slug-change" data-command="change-slug" title="<?= $this->translate('admin.pages.change-slug') ?>"><?= $page->route() ?><?= $this->icon('pencil') ?></button>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="page-route"><span><?= $page->route() ?></span></div> <div class="page-route"><span><?= $page->route() ?></span></div>

View File

@ -10,18 +10,18 @@
<?php <?php
endif; endif;
?> ?>
<ul class="pages-list <?= $class ?>" data-sortable-children="<?= $sortable ? 'true' : 'false' ?>"<?php if ($parent): ?> data-parent="<?= $parent ?>"<?php endif; ?>> <ul class="pages-list <?= $class ?>" data-sortable-children="<?= $orderable ? 'true' : 'false' ?>"<?php if ($parent): ?> data-parent="<?= $parent ?>"<?php endif; ?>>
<?php <?php
foreach ($pages as $page): foreach ($pages as $page):
$routable = $page->published() && $page->routable(); $routable = $page->published() && $page->routable();
$date = $this->datetime($page->lastModifiedTime()); $date = $this->datetime($page->lastModifiedTime());
?> ?>
<li class="<?php if ($subpages): ?>pages-level-<?= $page->level() ?><?php endif; ?>" <?php if (!$page->sortable()): ?>data-sortable="false"<?php endif; ?>> <li class="<?php if ($subpages): ?>pages-level-<?= $page->level() ?><?php endif; ?>" <?php if (!$page->orderable()): ?>data-sortable="false"<?php endif; ?>>
<div class="pages-item"> <div class="pages-item">
<div class="pages-item-cell page-details"> <div class="pages-item-cell page-details">
<div class="page-title"> <div class="page-title">
<?php <?php
if ($sortable && $page->sortable()): if ($orderable && $page->orderable()):
?> ?>
<span class="sort-handle" title="<?= $this->translate('admin.drag-to-reorder') ?>"><?= $this->icon('grabber') ?></span> <span class="sort-handle" title="<?= $this->translate('admin.drag-to-reorder') ?>"><?= $this->icon('grabber') ?></span>
<?php <?php
@ -70,14 +70,14 @@
if ($subpages && $page->hasChildren()): if ($subpages && $page->hasChildren()):
$scheme = $page->scheme(); $scheme = $page->scheme();
$reverseChildren = $scheme->get('children.reverse', false); $reverseChildren = $scheme->get('children.reverse', false);
$sortableChildren = $scheme->get('children.sortable', true); $orderableChildren = $scheme->get('children.sortable', true);
$this->insert('pages.list', [ $this->insert('pages.list', [
'pages' => $reverseChildren ? $page->children()->reverse() : $page->children(), 'pages' => $reverseChildren ? $page->children()->reverse() : $page->children(),
'subpages' => true, 'subpages' => true,
'class' => 'pages-children', 'class' => 'pages-children',
'parent' => $sortableChildren ? $page->route() : null, 'parent' => $orderableChildren ? $page->route() : null,
'sortable' => $sortable && $sortableChildren, 'sortable' => $orderable && $orderableChildren,
'headers' => false 'headers' => false
]); ]);

View File

@ -39,6 +39,9 @@ return [
'errors' => [ 'errors' => [
'set_handlers' => true 'set_handlers' => true
], ],
'fields' => [
'path' => FORMWORK_PATH . 'fields' . DS
],
'files' => [ 'files' => [
'allowed_extensions' => [ 'allowed_extensions' => [
'.jpg', '.jpg',

32
formwork/fields/array.php Normal file
View File

@ -0,0 +1,32 @@
<?php
use Formwork\Data\Contracts\Arrayable;
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'validate' => function (Field $field, $value) {
if (Constraint::isEmpty($value)) {
return [];
}
if ($value instanceof Arrayable) {
$value = $value->toArray();
}
if (!is_array($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->get('associative')) {
foreach (array_keys($value) as $key) {
if (is_int($key)) {
unset($value[$key]);
}
}
}
return array_filter($value);
}
];

View File

@ -0,0 +1,23 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'validate' => function (Field $field, $value) {
if (Constraint::isTruthy($value)) {
return true;
}
if (Constraint::isFalsy($value)) {
return false;
}
if ($value === null) {
return false;
}
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
];

42
formwork/fields/date.php Normal file
View File

@ -0,0 +1,42 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Formwork;
use Formwork\Utils\Constraint;
use Formwork\Utils\Date;
use Formwork\Utils\Str;
return [
'format' => function (Field $field, string $format = null): string {
return Date::formatTimestamp($field->toTimestamp(), $format);
},
'toTimestamp' => function (Field $field): string {
return Date::toTimestamp($field->value());
},
'toDuration' => function (Field $field): string {
return Date::formatTimestampAsDistance($field->toTimestamp(), Formwork::instance()->languages()->current());
},
'toString' => function (Field $field): string {
return $field->format();
},
'return' => function (Field $field): Field {
return $field;
},
'validate' => function (Field $field, $value): ?string {
if (Constraint::isEmpty($value)) {
return null;
}
try {
return date('Y-m-d H:i:s', Date::toTimestamp($value));
} catch (InvalidArgumentException $e) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s":%s', $field->name(), $field->type(), Str::after($e->getMessage(), ':')));
}
}
];

View File

@ -0,0 +1,29 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
return [
'validate' => function (Field $field, $value): int {
if (!is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
// This reliably casts numeric values to int or float
$value += 0;
if ($field->has('min') && $value < $field->get('min')) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be greater than or equal to %d', $field->name(), $field->type(), $field->get('min')));
}
if ($field->has('max') && $value > $field->get('max')) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be less than or equal to %d', $field->name(), $field->type(), $field->get('max')));
}
if ($field->has('step') && ($value - $field->get('min', 0)) % $field->get('step') !== 0) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not conform to the step value %d', $field->name(), $field->value(), $field->get('step')));
}
return $value;
}
];

35
formwork/fields/email.php Normal file
View File

@ -0,0 +1,35 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'validate' => function (Field $field, $value): string {
if (Constraint::isEmpty($value)) {
return '';
}
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" is not a valid e-mail address', $field->name(), $field->value()));
}
if (!is_string($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('min') && strlen($value) < $field->get('min')) {
throw new ValidationException(sprintf('The minimum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('min')));
}
if ($field->has('max') && strlen($value) > $field->get('max')) {
throw new ValidationException(sprintf('The maximum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('max')));
}
if ($field->has('pattern') && !Constraint::matchesRegex($value, $field->get('pattern'))) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not match the required pattern', $field->name(), $field->value()));
}
return (string) $value;
}
];

23
formwork/fields/image.php Normal file
View File

@ -0,0 +1,23 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'return' => function (Field $field): Field {
return $field;
},
'validate' => function (Field $field, $value): ?string {
if (Constraint::isEmpty($value)) {
return null;
}
if (!is_string($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
return $value;
}
];

View File

@ -0,0 +1,44 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Formwork;
use Formwork\Parsers\Markdown;
use Formwork\Utils\Constraint;
return [
'toHTML' => function (Field $field): string {
$currentPage = Formwork::instance()->site()->currentPage();
return Markdown::parse($field->value(), ['baseRoute' => $currentPage ? $currentPage->route() : '/']);
},
'toString' => function (Field $field): string {
return $field->toHTML();
},
'return' => function (Field $field): Field {
return $field;
},
'validate' => function (Field $field, $value): string {
if (Constraint::isEmpty($value)) {
return '';
}
if (!is_string($value) && !is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('min') && strlen($value) < $field->get('min')) {
throw new ValidationException(sprintf('The minimum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('min')));
}
if ($field->has('max') && strlen($value) > $field->get('max')) {
throw new ValidationException(sprintf('The maximum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('max')));
}
$value = str_replace("\r\n", "\n", $value);
return $value;
}
];

View File

@ -0,0 +1,29 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
return [
'validate' => function (Field $field, $value): int | float {
if (!is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
// This reliably casts numeric values to int or float
$value += 0;
if ($field->has('min') && $value < $field->get('min')) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be greater than or equal to %d', $field->name(), $field->type(), $field->get('min')));
}
if ($field->has('max') && $value > $field->get('max')) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be less than or equal to %d', $field->name(), $field->type(), $field->get('max')));
}
if ($field->has('step') && ($value - $field->get('min', 0)) % $field->get('step') !== 0) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not conform to the step value %d', $field->name(), $field->value(), $field->get('step')));
}
return $value;
}
];

View File

@ -0,0 +1,31 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'validate' => function (Field $field, $value): ?string {
if (Constraint::isEmpty($value)) {
return '';
}
if (!is_string($value) && !is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('min') && strlen($value) < $field->get('min')) {
throw new ValidationException(sprintf('The minimum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('min')));
}
if ($field->has('max') && strlen($value) > $field->get('max')) {
throw new ValidationException(sprintf('The maximum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('max')));
}
if ($field->has('pattern') && !Constraint::matchesRegex($value, $field->get('pattern'))) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not match the required pattern', $field->name(), $field->value()));
}
return (string) $value;
}
];

29
formwork/fields/range.php Normal file
View File

@ -0,0 +1,29 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
return [
'validate' => function (Field $field, $value): int {
if (!is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
// This reliably casts numeric values to int or float
$value += 0;
if ($field->has('min') && $value < $field->get('min')) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be greater than or equal to %d', $field->name(), $field->type(), $field->get('min')));
}
if ($field->has('max') && $value > $field->get('max')) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be less than or equal to %d', $field->name(), $field->type(), $field->get('max')));
}
if ($field->has('step') && ($value - $field->get('min', 0)) % $field->get('step') !== 0) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not conform to the step value %d', $field->name(), $field->value(), $field->get('step')));
}
return $value;
}
];

View File

@ -0,0 +1,14 @@
<?php
use Formwork\Fields\Field;
return [
'validate' => function (Field $field, $value) {
if (is_numeric($value)) {
// This reliably casts numeric values to int or float
return $value + 0;
}
return $value;
}
];

36
formwork/fields/tags.php Normal file
View File

@ -0,0 +1,36 @@
<?php
use Formwork\Data\Collection;
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'toString' => function ($field) {
return implode(', ', $field->value());
},
'return' => function (Field $field): Collection {
return Collection::from($field->value());
},
'validate' => function (Field $field, $value): array {
if (Constraint::isEmpty($value)) {
return [];
}
if (is_string($value)) {
$value = array_map('trim', explode(',', $value));
}
if (!is_array($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('pattern')) {
$value = array_filter($value, static fn ($item): bool => Constraint::matchesRegex($item, $field->get('pattern')));
}
return array_values(array_filter($value));
}
];

31
formwork/fields/text.php Normal file
View File

@ -0,0 +1,31 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'validate' => function (Field $field, $value): string {
if (Constraint::isEmpty($value)) {
return '';
}
if (!is_string($value) && !is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('min') && strlen($value) < $field->get('min')) {
throw new ValidationException(sprintf('The minimum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('min')));
}
if ($field->has('max') && strlen($value) > $field->get('max')) {
throw new ValidationException(sprintf('The maximum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('max')));
}
if ($field->has('pattern') && !Constraint::matchesRegex($value, $field->get('pattern'))) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not match the required pattern', $field->name(), $field->value()));
}
return (string) $value;
}
];

View File

@ -0,0 +1,29 @@
<?php
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'validate' => function (Field $field, $value): string {
if (Constraint::isEmpty($value)) {
return '';
}
if (!is_string($value) && !is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('min') && strlen($value) < $field->get('min')) {
throw new ValidationException(sprintf('The minimum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('min')));
}
if ($field->has('max') && strlen($value) > $field->get('max')) {
throw new ValidationException(sprintf('The maximum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('max')));
}
$value = str_replace("\r\n", "\n", (string) $value);
return $value;
}
];

View File

@ -0,0 +1,23 @@
<?php
use Formwork\Fields\Field;
use Formwork\Utils\Constraint;
return [
'validate' => function (Field $field, $value) {
if (Constraint::isTruthy($value)) {
return true;
}
if (Constraint::isFalsy($value)) {
return false;
}
if (is_numeric($value)) {
// This reliably casts numeric values to int or float
return $value + 0;
}
return $value;
}
];

View File

@ -1,319 +0,0 @@
<?php
namespace Formwork;
use Formwork\Metadata\Metadata;
use Formwork\Utils\Arr;
use Formwork\Utils\FileSystem;
use Formwork\Utils\HTTPRequest;
use Formwork\Utils\Uri;
use BadMethodCallException;
abstract class AbstractPage
{
/**
* Page path
*/
protected string $path;
/**
* Page path relative to content path
*/
protected string $relativePath;
/**
* Page unique identifier
*/
protected string $uid;
/**
* Page route
*/
protected string $route;
/**
* Page data
*/
protected array $data = [];
/**
* Page uri
*/
protected string $uri;
/**
* Page absolute uri
*/
protected string $absoluteUri;
/**
* Page last modified time
*/
protected int $lastModifiedTime;
/**
* Page modified date
*/
protected string $date;
/**
* Page metadata
*/
protected Metadata $metadata;
/**
* Page parent
*
* @var Page|Site
*/
protected $parent;
/**
* PageCollection containing page parents
*/
protected PageCollection $parents;
/**
* Page level
*/
protected int $level;
/**
* PageCollection containing page children
*/
protected PageCollection $children;
/**
* PageCollection containing page descendants
*/
protected PageCollection $descendants;
/**
* Return a URI relative to page
*
* @param bool|string $includeLanguage
*/
public function uri(string $path = '', $includeLanguage = true): string
{
$base = HTTPRequest::root();
if ($includeLanguage) {
$language = is_string($includeLanguage) ? $includeLanguage : Formwork::instance()->site()->languages()->current();
$default = Formwork::instance()->site()->languages()->default();
$preferred = Formwork::instance()->site()->languages()->preferred();
if (($language !== null && $language !== $default) || ($preferred !== null && $preferred !== $default)) {
$base .= $language . '/';
}
}
return $base . ltrim($this->route, '/') . ltrim($path, '/');
}
/**
* Get the page unique identifier
*/
public function uid(): string
{
if (isset($this->uid)) {
return $this->uid;
}
return $this->uid = substr(hash('sha256', $this->relativePath), 0, 32);
}
/**
* Get page absolute URI
*/
public function absoluteUri(): string
{
if (isset($this->absoluteUri)) {
return $this->absoluteUri;
}
return $this->absoluteUri = Uri::resolveRelative($this->uri());
}
/**
* Get page last modified time
*/
public function lastModifiedTime(): int
{
if (isset($this->lastModifiedTime)) {
return $this->lastModifiedTime;
}
return $this->lastModifiedTime = FileSystem::lastModifiedTime($this->path);
}
/**
* Return page date optionally in a given format
*
* @param string $format
*/
public function date(string $format = null): string
{
$format ??= Formwork::instance()->config()->get('date.format');
return date($format, $this->lastModifiedTime());
}
/**
* Get parent page
*
* @return Page|Site|null
*/
public function parent()
{
if (isset($this->parent)) {
return $this->parent;
}
$parentPath = dirname($this->path) . DS;
if (FileSystem::isDirectory($parentPath) && $parentPath !== Formwork::instance()->config()->get('content.path')) {
return $this->parent = Formwork::instance()->site()->retrievePage($parentPath);
}
// If no parent was found returns the site as first level pages' parent
return $this->parent = Formwork::instance()->site();
}
/**
* Return a PageCollection containing page parents
*/
public function parents(): PageCollection
{
if (isset($this->parents)) {
return $this->parents;
}
$parentPages = [];
$page = $this;
while (($parent = $page->parent()) !== null) {
$parentPages[] = $parent;
$page = $parent;
}
return $this->parents = new PageCollection(array_reverse($parentPages));
}
/**
* Return whether page has parents
*/
public function hasParents(): bool
{
return !$this->parents()->isEmpty();
}
/**
* Return a PageCollection containing page children
*/
public function children(): PageCollection
{
if (isset($this->children)) {
return $this->children;
}
return $this->children = PageCollection::fromPath($this->path);
}
/**
* Return whether page has children
*/
public function hasChildren(): bool
{
return !$this->children()->isEmpty();
}
/**
* Return a PageCollection containing page descendants
*/
public function descendants(): PageCollection
{
if (isset($this->descendants)) {
return $this->descendants;
}
return $this->descendants = PageCollection::fromPath($this->path, true);
}
/**
* Return whether page has descendants
*/
public function hasDescendants(): bool
{
foreach ($this->children() as $child) {
if ($child->hasChildren()) {
return true;
}
}
return false;
}
/**
* Return page level
*/
public function level(): int
{
if (isset($this->level)) {
return $this->level;
}
return $this->level = $this->parents()->count();
}
/**
* Return whether current page is Site
*/
abstract public function isSite(): bool;
/**
* Return whether current page is index page
*/
abstract public function isIndexPage(): bool;
/**
* Return whether current page is error page
*/
abstract public function isErrorPage(): bool;
/**
* Return whether current page is deletable
*/
abstract public function isDeletable(): bool;
/**
* Return page metadata
*/
abstract public function metadata(): Metadata;
/**
* Get page data by key
*/
public function get(string $key, $default = null)
{
if (property_exists($this, $key)) {
// Call getter method if exists and property is null
if (!isset($this->$key) && method_exists($this, $key)) {
return $this->$key();
}
return $this->$key;
}
if (Arr::has($this->data, $key)) {
return Arr::get($this->data, $key, $default);
}
return $default;
}
/**
* Return whether page data has a key
*/
public function has(string $key): bool
{
return property_exists($this, $key) || Arr::has($this->data, $key);
}
/**
* Set page data
*/
public function set(string $key, $value): void
{
Arr::set($this->data, $key, $value);
}
public function __call(string $name, array $arguments)
{
if ($this->has($name)) {
return $this->get($name);
}
throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name));
}
}

View File

@ -9,7 +9,7 @@ use Formwork\Controllers\AbstractController as BaseAbstractController;
use Formwork\Formwork; use Formwork\Formwork;
use Formwork\Parsers\JSON; use Formwork\Parsers\JSON;
use Formwork\Parsers\PHP; use Formwork\Parsers\PHP;
use Formwork\Site; use Formwork\Pages\Site;
use Formwork\Utils\Date; use Formwork\Utils\Date;
use Formwork\Utils\HTTPRequest; use Formwork\Utils\HTTPRequest;
use Formwork\View\View; use Formwork\View\View;

View File

@ -18,8 +18,8 @@ class DashboardController extends AbstractController
$statistics = new Statistics(); $statistics = new Statistics();
$this->modal('newPage', [ $this->modal('newPage', [
'templates' => $this->site()->templates(), 'templates' => $this->site()->templates()->keys(),
'pages' => $this->site()->descendants()->sortBy('path') 'pages' => $this->site()->descendants()->sortBy('relativePath')
]); ]);
$this->modal('deletePage'); $this->modal('deletePage');
@ -27,12 +27,12 @@ class DashboardController extends AbstractController
return new Response($this->view('dashboard.index', [ return new Response($this->view('dashboard.index', [
'title' => $this->admin()->translate('admin.dashboard.dashboard'), 'title' => $this->admin()->translate('admin.dashboard.dashboard'),
'lastModifiedPages' => $this->view('pages.list', [ 'lastModifiedPages' => $this->view('pages.list', [
'pages' => $this->site()->descendants()->sortBy('lastModifiedTime', direction: SORT_DESC)->slice(0, 5), 'pages' => $this->site()->descendants()->sortBy('lastModifiedTime', direction: SORT_DESC)->slice(0, 5),
'subpages' => false, 'subpages' => false,
'class' => 'pages-list-top', 'class' => 'pages-list-top',
'parent' => null, 'parent' => null,
'sortable' => false, 'orderable' => false,
'headers' => true 'headers' => true
], true), ], true),
'statistics' => JSON::encode($statistics->getChartData()) 'statistics' => JSON::encode($statistics->getChartData())
], true)); ], true));

View File

@ -2,9 +2,7 @@
namespace Formwork\Admin\Controllers; namespace Formwork\Admin\Controllers;
use Formwork\Data\DataGetter; use Formwork\Fields\FieldCollection;
use Formwork\Fields\Fields;
use Formwork\Fields\Validator;
use Formwork\Formwork; use Formwork\Formwork;
use Formwork\Parsers\JSON; use Formwork\Parsers\JSON;
use Formwork\Parsers\YAML; use Formwork\Parsers\YAML;
@ -38,13 +36,16 @@ class OptionsController extends AbstractController
{ {
$this->ensurePermission('options.system'); $this->ensurePermission('options.system');
$fields = new Fields(Formwork::instance()->schemes()->get('config', 'system')->get('fields')); $scheme = Formwork::instance()->schemes()->get('config', 'system');
$fields = $scheme->fields();
if (HTTPRequest::method() === 'POST') { if (HTTPRequest::method() === 'POST') {
$data = HTTPRequest::postData(); $data = HTTPRequest::postData();
$options = Formwork::instance()->config(); $options = Formwork::instance()->config();
$defaults = Formwork::instance()->defaults(); $defaults = Formwork::instance()->defaults();
$differ = $this->updateOptions('system', $fields->validate($data), $options->toArray(), $defaults); $fields->validate($data);
$differ = $this->updateOptions('system', $fields, $options->toArray(), $defaults);
// Touch content folder to invalidate cache // Touch content folder to invalidate cache
if ($differ) { if ($differ) {
@ -55,7 +56,7 @@ class OptionsController extends AbstractController
return $this->admin()->redirect('/options/system/'); return $this->admin()->redirect('/options/system/');
} }
$fields->validate(new DataGetter(Formwork::instance()->config()->toArray())); $fields->validate(Formwork::instance()->config());
$this->modal('changes'); $this->modal('changes');
@ -76,13 +77,15 @@ class OptionsController extends AbstractController
{ {
$this->ensurePermission('options.site'); $this->ensurePermission('options.site');
$fields = new Fields(Formwork::instance()->schemes()->get('config', 'site')->get('fields')); $scheme = Formwork::instance()->schemes()->get('config', 'site');
$fields = $scheme->fields();
if (HTTPRequest::method() === 'POST') { if (HTTPRequest::method() === 'POST') {
$data = HTTPRequest::postData(); $data = HTTPRequest::postData();
$options = $this->site()->data(); $options = $this->site()->data();
$defaults = Formwork::instance()->site()->defaults(); $defaults = Formwork::instance()->site()->defaults();
$differ = $this->updateOptions('site', $fields->validate($data), $options, $defaults); $fields->validate($data);
$differ = $this->updateOptions('site', $fields, $options, $defaults);
// Touch content folder to invalidate cache // Touch content folder to invalidate cache
if ($differ) { if ($differ) {
@ -93,7 +96,7 @@ class OptionsController extends AbstractController
return $this->admin()->redirect('/options/site/'); return $this->admin()->redirect('/options/site/');
} }
$fields->validate(new DataGetter($this->site()->data())); $fields->validate($this->site()->data());
$this->modal('changes'); $this->modal('changes');
@ -237,26 +240,20 @@ class OptionsController extends AbstractController
/** /**
* Update options of a given type with given data * Update options of a given type with given data
* *
* @param string $type Options type ('system' or 'site') * @param string $type Options type ('system' or 'site')
* @param Fields $fields Fields object * @param FieldCollection $fields FieldCollection object
* @param array $options Current options * @param array $options Current options
* @param array $defaults Default values * @param array $defaults Default values
* *
* @return bool Whether new values were applied or not * @return bool Whether new values were applied or not
*/ */
protected function updateOptions(string $type, Fields $fields, array $options, array $defaults): bool protected function updateOptions(string $type, FieldCollection $fields, array $options, array $defaults): bool
{ {
// Flatten fields
$fields = $fields->toArray(true);
$old = $options; $old = $options;
$options = []; $options = [];
// Update options with new values // Update options with new values
foreach ($fields as $field) { foreach ($fields as $field) {
if (in_array($field->type(), Validator::IGNORED_FIELDS, true)) {
continue;
}
if ($field->isRequired() && $field->isEmpty()) { if ($field->isRequired() && $field->isEmpty()) {
continue; continue;
} }

View File

@ -5,20 +5,19 @@ namespace Formwork\Admin\Controllers;
use Formwork\Admin\Uploader; use Formwork\Admin\Uploader;
use Formwork\Data\DataGetter; use Formwork\Data\DataGetter;
use Formwork\Exceptions\TranslatedException; use Formwork\Exceptions\TranslatedException;
use Formwork\Fields\Fields; use Formwork\Fields\FieldCollection;
use Formwork\Files\Image; use Formwork\Files\Image;
use Formwork\Formwork; use Formwork\Formwork;
use Formwork\Languages\LanguageCodes; use Formwork\Languages\LanguageCodes;
use Formwork\Page; use Formwork\Pages\Page;
use Formwork\Parsers\YAML; use Formwork\Parsers\YAML;
use Formwork\Response\JSONResponse; use Formwork\Response\JSONResponse;
use Formwork\Response\RedirectResponse; use Formwork\Response\RedirectResponse;
use Formwork\Response\Response; use Formwork\Response\Response;
use Formwork\Router\RouteParams; use Formwork\Router\RouteParams;
use Formwork\Site; use Formwork\Pages\Site;
use Formwork\Utils\FileSystem; use Formwork\Utils\FileSystem;
use Formwork\Utils\HTTPRequest; use Formwork\Utils\HTTPRequest;
use Formwork\Utils\Session;
use Formwork\Utils\Str; use Formwork\Utils\Str;
use Formwork\Utils\Uri; use Formwork\Utils\Uri;
use InvalidArgumentException; use InvalidArgumentException;
@ -46,8 +45,8 @@ class PagesController extends AbstractController
$this->ensurePermission('pages.index'); $this->ensurePermission('pages.index');
$this->modal('newPage', [ $this->modal('newPage', [
'templates' => $this->site()->templates(), 'templates' => $this->site()->templates()->keys(),
'pages' => $this->site()->descendants()->sortBy('path') 'pages' => $this->site()->descendants()->sortBy('relativePath')
]); ]);
$this->modal('deletePage'); $this->modal('deletePage');
@ -55,12 +54,12 @@ class PagesController extends AbstractController
return new Response($this->view('pages.index', [ return new Response($this->view('pages.index', [
'title' => $this->admin()->translate('admin.pages.pages'), 'title' => $this->admin()->translate('admin.pages.pages'),
'pagesList' => $this->view('pages.list', [ 'pagesList' => $this->view('pages.list', [
'pages' => $this->site()->pages(), 'pages' => $this->site()->pages(),
'subpages' => true, 'subpages' => true,
'class' => 'pages-list-top', 'class' => 'pages-list-top',
'parent' => '.', 'parent' => '.',
'sortable' => $this->user()->permissions()->has('pages.reorder'), 'orderable' => $this->user()->permissions()->has('pages.reorder'),
'headers' => true 'headers' => true
], true) ], true)
], true)); ], true));
} }
@ -77,7 +76,6 @@ class PagesController extends AbstractController
// Let's create the page // Let's create the page
try { try {
$page = $this->createPage($data); $page = $this->createPage($data);
Session::set('FORMWORK_PAGE_TO_PUBLISH', $page->route());
$this->admin()->notify($this->admin()->translate('admin.pages.page.created'), 'success'); $this->admin()->notify($this->admin()->translate('admin.pages.page.created'), 'success');
} catch (TranslatedException $e) { } catch (TranslatedException $e) {
$this->admin()->notify($e->getTranslatedMessage(), 'error'); $this->admin()->notify($e->getTranslatedMessage(), 'error');
@ -121,21 +119,13 @@ class PagesController extends AbstractController
return $this->admin()->redirect('/pages/' . trim($page->route(), '/') . '/edit/language/' . $page->language() . '/'); return $this->admin()->redirect('/pages/' . trim($page->route(), '/') . '/edit/language/' . $page->language() . '/');
} }
// Check if page has to be published on next save
if (Session::has('FORMWORK_PAGE_TO_PUBLISH')) {
if ($page->route() === Session::get('FORMWORK_PAGE_TO_PUBLISH')) {
$page->set('published', true);
}
Session::remove('FORMWORK_PAGE_TO_PUBLISH');
}
// Load page fields // Load page fields
$fields = new Fields($page->scheme()->get('fields')); $fields = $page->scheme()->fields();
switch (HTTPRequest::method()) { switch (HTTPRequest::method()) {
case 'GET': case 'GET':
// Load data from the page itself // Load data from the page itself
$data = new DataGetter(array_merge($page->data(), ['content' => $page->rawContent()])); $data = $page->data();
// Validate fields against data // Validate fields against data
$fields->validate($data); $fields->validate($data);
@ -189,8 +179,8 @@ class PagesController extends AbstractController
'title' => $this->admin()->translate('admin.pages.edit-page', $page->title()), 'title' => $this->admin()->translate('admin.pages.edit-page', $page->title()),
'page' => $page, 'page' => $page,
'fields' => $fields, 'fields' => $fields,
'templates' => $this->site()->templates(), 'templates' => $this->site()->templates()->keys(),
'parents' => $this->site()->descendants()->sortBy('path'), 'parents' => $this->site()->descendants()->sortBy('relativePath'),
'currentLanguage' => $params->get('language', $page->language()), 'currentLanguage' => $params->get('language', $page->language()),
'availableLanguages' => $this->availableSiteLanguages() 'availableLanguages' => $this->availableSiteLanguages()
], true)); ], true));
@ -368,7 +358,7 @@ class PagesController extends AbstractController
} }
// Validate page template // Validate page template
if (!$this->site()->hasTemplate($data->get('template'))) { if (!$this->site()->templates()->has($data->get('template'))) {
throw new TranslatedException('Invalid page template', 'admin.pages.page.cannot-create.invalid-template'); throw new TranslatedException('Invalid page template', 'admin.pages.page.cannot-create.invalid-template');
} }
@ -401,7 +391,7 @@ class PagesController extends AbstractController
/** /**
* Update a page * Update a page
*/ */
protected function updatePage(Page $page, DataGetter $data, Fields $fields): Page protected function updatePage(Page $page, DataGetter $data, FieldCollection $fields): Page
{ {
// Ensure no required data is missing // Ensure no required data is missing
if (!$data->hasMultiple(['title', 'content'])) { if (!$data->hasMultiple(['title', 'content'])) {
@ -420,7 +410,7 @@ class PagesController extends AbstractController
$defaults = $page->defaults(); $defaults = $page->defaults();
// Handle data from fields // Handle data from fields
foreach ($fields->toArray(true) as $field) { foreach ($fields as $field) {
$default = array_key_exists($field->name(), $defaults) && $field->value() === $defaults[$field->name()]; $default = array_key_exists($field->name(), $defaults) && $field->value() === $defaults[$field->name()];
// Remove empty and default values // Remove empty and default values
@ -442,7 +432,7 @@ class PagesController extends AbstractController
throw new TranslatedException('Invalid page language', 'admin.pages.page.cannot-edit.invalid-language'); throw new TranslatedException('Invalid page language', 'admin.pages.page.cannot-edit.invalid-language');
} }
$differ = $frontmatter !== $page->frontmatter() || $content !== $page->rawContent() || $language !== $page->language(); $differ = $frontmatter !== $page->frontmatter() || $content !== $page->data()['content'] || $language !== $page->language();
if ($differ) { if ($differ) {
$filename = $data->get('template'); $filename = $data->get('template');
@ -463,8 +453,8 @@ class PagesController extends AbstractController
} }
// Check if page number has to change // Check if page number has to change
if (!empty($page->date()) && $page->scheme()->get('num') === 'date' && $page->num() !== (int) $page->date(self::DATE_NUM_FORMAT)) { if ($page->scheme()->get('num') === 'date' && $page->num() !== ($num = (int) date(self::DATE_NUM_FORMAT, $page->timestamp()))) {
$name = preg_replace(Page::NUM_REGEX, $page->date(self::DATE_NUM_FORMAT) . '-', $page->name()); $name = preg_replace(Page::NUM_REGEX, $num . '-', $page->name());
try { try {
$page = $this->changePageName($page, $name); $page = $this->changePageName($page, $name);
} catch (RuntimeException $e) { } catch (RuntimeException $e) {
@ -483,7 +473,7 @@ class PagesController extends AbstractController
// Check if page template has to change // Check if page template has to change
if ($page->template()->name() !== ($template = $data->get('template'))) { if ($page->template()->name() !== ($template = $data->get('template'))) {
if (!$this->site()->hasTemplate($template)) { if (!$this->site()->templates()->has($template)) {
throw new TranslatedException('Invalid page template', 'admin.pages.page.cannot-edit.invalid-template'); throw new TranslatedException('Invalid page template', 'admin.pages.page.cannot-edit.invalid-template');
} }
$page = $this->changePageTemplate($page, $template); $page = $this->changePageTemplate($page, $template);
@ -531,10 +521,9 @@ class PagesController extends AbstractController
/** /**
* Make a page num according to 'date' or default mode * Make a page num according to 'date' or default mode
* *
* @param Page|Site $parent * @param string $mode 'date' for pages with a publish date
* @param string $mode 'date' for pages with a publish date
*/ */
protected function makePageNum($parent, ?string $mode): string protected function makePageNum(Page|Site $parent, ?string $mode): string
{ {
if (!$parent instanceof Page && !$parent instanceof Site) { if (!$parent instanceof Page && !$parent instanceof Site) {
throw new InvalidArgumentException(sprintf('%s() accepts only instances of %s or %s as $parent argument', __METHOD__, Page::class, Site::class)); throw new InvalidArgumentException(sprintf('%s() accepts only instances of %s or %s as $parent argument', __METHOD__, Page::class, Site::class));
@ -567,14 +556,9 @@ class PagesController extends AbstractController
/** /**
* Change the parent of a page * Change the parent of a page
*
* @param Page|Site $parent
*/ */
protected function changePageParent(Page $page, $parent): Page protected function changePageParent(Page $page, Page|Site $parent): Page
{ {
if (!$parent instanceof Page && !$parent instanceof Site) {
throw new InvalidArgumentException(sprintf('%s() accepts only instances of %s or %s as $parent argument', __METHOD__, Page::class, Site::class));
}
$destination = FileSystem::joinPaths($parent->path(), $page->name(), DS); $destination = FileSystem::joinPaths($parent->path(), $page->name(), DS);
FileSystem::moveDirectory($page->path(), $destination); FileSystem::moveDirectory($page->path(), $destination);
return new Page($destination); return new Page($destination);
@ -595,10 +579,8 @@ class PagesController extends AbstractController
* Resolve parent page helper * Resolve parent page helper
* *
* @param string $parent Page URI or '.' for site * @param string $parent Page URI or '.' for site
*
* @return Page|Site|null
*/ */
protected function resolveParent(string $parent) protected function resolveParent(string $parent): Page|Site
{ {
if ($parent === '.') { if ($parent === '.') {
return $this->site(); return $this->site();

View File

@ -5,10 +5,7 @@ namespace Formwork\Admin\Controllers;
use Formwork\Admin\Security\Password; use Formwork\Admin\Security\Password;
use Formwork\Admin\Uploader; use Formwork\Admin\Uploader;
use Formwork\Admin\Users\User; use Formwork\Admin\Users\User;
use Formwork\Data\DataGetter;
use Formwork\Data\DataSetter;
use Formwork\Exceptions\TranslatedException; use Formwork\Exceptions\TranslatedException;
use Formwork\Fields\Fields;
use Formwork\Files\Image; use Formwork\Files\Image;
use Formwork\Formwork; use Formwork\Formwork;
use Formwork\Parsers\YAML; use Formwork\Parsers\YAML;
@ -113,7 +110,9 @@ class UsersController extends AbstractController
*/ */
public function profile(RouteParams $params): Response public function profile(RouteParams $params): Response
{ {
$fields = new Fields(Formwork::instance()->schemes()->get('admin', 'user')->get('fields')); $scheme = Formwork::instance()->schemes()->get('admin', 'user');
$fields = $scheme->fields();
$user = $this->admin()->users()->get($params->get('user')); $user = $this->admin()->users()->get($params->get('user'));
@ -123,13 +122,13 @@ class UsersController extends AbstractController
} }
// Disable password and/or role fields if they cannot be changed // Disable password and/or role fields if they cannot be changed
$fields->find('password')->set('disabled', !$this->user()->canChangePasswordOf($user)); $fields->get('password')->set('disabled', !$this->user()->canChangePasswordOf($user));
$fields->find('role')->set('disabled', !$this->user()->canChangeRoleOf($user)); $fields->get('role')->set('disabled', !$this->user()->canChangeRoleOf($user));
if (HTTPRequest::method() === 'POST') { if (HTTPRequest::method() === 'POST') {
// Ensure that options can be changed // Ensure that options can be changed
if ($this->user()->canChangeOptionsOf($user)) { if ($this->user()->canChangeOptionsOf($user)) {
$data = DataSetter::fromGetter(HTTPRequest::postData()); $data = HTTPRequest::postData()->toArray();
$fields->validate($data); $fields->validate($data);
try { try {
$this->updateUser($user, $data); $this->updateUser($user, $data);
@ -144,7 +143,7 @@ class UsersController extends AbstractController
return $this->admin()->redirect('/users/' . $user->username() . '/profile/'); return $this->admin()->redirect('/users/' . $user->username() . '/profile/');
} }
$fields->validate(new DataGetter($user->toArray())); $fields = $fields->validate($user);
$this->modal('changes'); $this->modal('changes');
@ -160,36 +159,36 @@ class UsersController extends AbstractController
/** /**
* Update user data from POST request * Update user data from POST request
*/ */
protected function updateUser(User $user, DataSetter $data): void protected function updateUser(User $user, array $data): void
{ {
// Remove CSRF token from $data // Remove CSRF token from $data
$data->remove('csrf-token'); unset($data['csrf-token']);
if (!empty($data->get('password'))) { if (!empty($data['password'])) {
// Ensure that password can be changed // Ensure that password can be changed
if (!$this->user()->canChangePasswordOf($user)) { if (!$this->user()->canChangePasswordOf($user)) {
throw new TranslatedException(sprintf('Cannot change the password of %s', $user->username()), 'admin.users.user.cannot-change-password'); throw new TranslatedException(sprintf('Cannot change the password of %s', $user->username()), 'admin.users.user.cannot-change-password');
} }
// Hash the new password // Hash the new password
$data->set('hash', Password::hash($data->get('password'))); $data['hash'] = Password::hash($data['password']);
} }
// Remove password from $data // Remove password from $data
$data->remove('password'); unset($data['password']);
// Ensure that user role can be changed // Ensure that user role can be changed
if ($data->get('role', $user->role()) !== $user->role() && !$this->user()->canChangeRoleOf($user)) { if (($data['role'] ?? $user->role()) !== $user->role() && !$this->user()->canChangeRoleOf($user)) {
throw new TranslatedException(sprintf('Cannot change the role of %s', $user->username()), 'admin.users.user.cannot-change-role'); throw new TranslatedException(sprintf('Cannot change the role of %s', $user->username()), 'admin.users.user.cannot-change-role');
} }
// Handle incoming files // Handle incoming files
if (HTTPRequest::hasFiles() && ($avatar = $this->uploadAvatar($user)) !== null) { if (HTTPRequest::hasFiles() && ($avatar = $this->uploadAvatar($user)) !== null) {
$data->set('avatar', $avatar); $data['avatar'] = $avatar;
} }
// Filter empty elements from $data and merge them with $user ones // Filter empty elements from $data and merge them with $user ones
$userData = array_merge($user->toArray(), $data->toArray()); $userData = array_merge($user->toArray(), $data);
YAML::encodeToFile($userData, Formwork::instance()->config()->get('admin.paths.accounts') . $user->username() . '.yml'); YAML::encodeToFile($userData, Formwork::instance()->config()->get('admin.paths.accounts') . $user->username() . '.yml');
} }

View File

@ -3,12 +3,11 @@
namespace Formwork\Controllers; namespace Formwork\Controllers;
use Formwork\Formwork; use Formwork\Formwork;
use Formwork\Page; use Formwork\Pages\Page;
use Formwork\Response\FileResponse; use Formwork\Response\FileResponse;
use Formwork\Response\RedirectResponse; use Formwork\Response\RedirectResponse;
use Formwork\Response\Response; use Formwork\Response\Response;
use Formwork\Router\RouteParams; use Formwork\Router\RouteParams;
use Formwork\Utils\Date;
use Formwork\Utils\FileSystem; use Formwork\Utils\FileSystem;
class PageController extends AbstractController class PageController extends AbstractController
@ -19,15 +18,16 @@ class PageController extends AbstractController
$route = $params->get('page', $formwork->config()->get('pages.index')); $route = $params->get('page', $formwork->config()->get('pages.index'));
if ($formwork->site()->has('aliases') && $alias = $formwork->site()->alias($route)) { if ($resolvedAlias = $formwork->site()->resolveAlias($route)) {
$route = trim($alias, '/'); $route = $resolvedAlias;
} }
if ($page = $formwork->site()->findPage($route)) { if ($page = $formwork->site()->findPage($route)) {
if ($page->has('canonical')) { if ($page->canonical() !== null) {
$canonical = trim($page->canonical(), '/'); $canonical = $page->canonical();
if ($params->get('page', '') !== $canonical) {
$route = empty($canonical) ? '' : $formwork->router()->rewrite(['page' => $canonical]); if ($params->get('page', '/') !== $canonical) {
$route = $formwork->router()->rewrite(['page' => $canonical]);
return new RedirectResponse($formwork->site()->uri($route), 301); return new RedirectResponse($formwork->site()->uri($route), 301);
} }
} }
@ -37,15 +37,15 @@ class PageController extends AbstractController
} }
if ($formwork->config()->get('cache.enabled') && ($page->has('publish-date') || $page->has('unpublish-date'))) { if ($formwork->config()->get('cache.enabled') && ($page->has('publish-date') || $page->has('unpublish-date'))) {
if (($page->published() && !$formwork->site()->modifiedSince(Date::toTimestamp($page->get('publish-date')))) if (($page->isPublished() && !$formwork->site()->modifiedSince($page->publishDate()->toTimestamp()))
|| (!$page->published() && !$formwork->site()->modifiedSince(Date::toTimestamp($page->get('unpublish-date'))))) { || (!$page->isPublished() && !$formwork->site()->modifiedSince($page->unpublishDate()->toTimestamp()))) {
// Clear cache if the site was not modified since the page has been published or unpublished // Clear cache if the site was not modified since the page has been published or unpublished
$formwork->cache()->clear(); $formwork->cache()->clear();
FileSystem::touch($formwork->config()->get('content.path')); FileSystem::touch($formwork->config()->get('content.path'));
} }
} }
if ($page->routable() && $page->published()) { if ($page->isPublished() && $page->routable()) {
return $this->getPageResponse($page); return $this->getPageResponse($page);
} }
} else { } else {
@ -91,7 +91,7 @@ class PageController extends AbstractController
$cache->delete($cacheKey); $cache->delete($cacheKey);
} }
$response = new Response($page->renderToString(), (int) $page->get('response_status', 200), $page->headers()); $response = new Response($page->render(), $page->responseStatus(), $page->headers());
if ($config->get('cache.enabled') && $page->cacheable()) { if ($config->get('cache.enabled') && $page->cacheable()) {
$cache->save($cacheKey, $response); $cache->save($cacheKey, $response);

View File

@ -167,6 +167,18 @@ abstract class AbstractCollection implements Arrayable, Countable, Iterator
return Arr::keyOf($this->data, $value); return Arr::keyOf($this->data, $value);
} }
/**
* Get the keys of all items
*/
public function keys(): array
{
if (!$this->isAssociative()) {
throw new LogicException('Only associative collections support keys');
}
return array_keys($this->data);
}
/** /**
* Return whether the collection contains the given value * Return whether the collection contains the given value
*/ */
@ -274,7 +286,7 @@ abstract class AbstractCollection implements Arrayable, Countable, Iterator
public function map(callable $callback): static public function map(callable $callback): static
{ {
$collection = $this->clone(); $collection = $this->clone();
$collection->data = Arr::map($callback, $collection->data); $collection->data = Arr::map($collection->data, $callback);
return $collection; return $collection;
} }
@ -328,6 +340,62 @@ abstract class AbstractCollection implements Arrayable, Countable, Iterator
return $collection; return $collection;
} }
/**
* Group collection items using a callback
*/
public function group(callable $callback): array
{
return Arr::group($this->data, $callback);
}
/**
* Get the value corresponding to the specified key from each item in the collection
*
* Typed collection should implement their own version of this method, optimised for their data type
*/
public function pluck(string $key, $default = null): array
{
return Arr::pluck($this->data, $key, $default);
}
/**
* Filter the collection using the key from each item
*/
public function filterBy(string $key, $value = true, $default = null, bool $strict = null): static
{
$values = $this->pluck($key, $default);
if (is_callable($value)) {
$values = Arr::map($values, $value);
$comparison = true;
$strict ??= true;
}
return $this->filter(fn ($v, $k) => Constraint::isEqualTo($values[$k], $comparison ??= $value, $strict ??= false));
}
/**
* Sort the collection using the given key from each item
*/
public function sortBy(
string $key,
int $direction = SORT_ASC,
int $type = SORT_NATURAL,
bool $caseSensitive = false,
bool $preserveKeys = true
): static {
return $this->sort($direction, $type, $this->pluck($key), $caseSensitive, $preserveKeys);
}
/**
* Group the collection using the given key from each item
*/
public function groupBy(string $key, $default = null): array
{
$values = $this->pluck($key, $default);
return $this->group(fn ($v, $k) => $values[$k]);
}
/** /**
* Return a copy of the collection with the given values * Return a copy of the collection with the given values

View File

@ -0,0 +1,13 @@
<?php
namespace Formwork\Data\Contracts;
use Formwork\Data\AbstractCollection;
use Formwork\Data\Pagination;
interface Paginable
{
public function pagination(): Pagination;
public function paginate(int $length, int $currentPage): AbstractCollection;
}

View File

@ -0,0 +1,152 @@
<?php
namespace Formwork\Data;
class Pagination
{
/**
* Number of all items to paginate
*/
protected int $count = 0;
/**
* Number of items in each pagination page
*/
protected int $length = 0;
/**
* Number of pagination pages
*/
protected int $pages = 0;
/**
* Current pagination page
*/
protected int $currentPage = 1;
/**
* Create a new Pagination instance
*/
public function __construct(AbstractCollection $collection, int $length)
{
$this->count = $collection->count();
$this->length = $length;
$this->pages = $this->count > 0 ? ceil($this->count / $this->length) : 1;
}
/**
* Get current page
*/
public function currentPage(): int
{
return $this->currentPage;
}
public function setCurrentPage(int $currentPage): void
{
$this->currentPage = $currentPage;
}
/**
* Get pagination length
*/
public function length(): int
{
return $this->length;
}
/**
* Get current pagination offset
*/
public function offset(): int
{
return ($this->currentPage - 1) * $this->length;
}
public function pages(): int
{
return $this->pages;
}
/**
* Return whether pagination has more than one page
*/
public function hasPages(): bool
{
return $this->pages > 1;
}
/**
* Return whether a given page number exists
*/
public function has(int $pageNumber): bool
{
return $pageNumber >= 1 && $pageNumber <= $this->pages;
}
/**
* Return whether current page is the first
*/
public function isFirstPage(): bool
{
return $this->currentPage === 1;
}
public function firstPage(): int
{
return 1;
}
/**
* Return whether current page is the last
*/
public function isLastPage(): bool
{
return $this->currentPage === $this->pages;
}
public function lastPage(): int
{
return $this->pages;
}
/**
* Return whether a previous page exists
*/
public function hasPreviousPage(): bool
{
return !$this->isFirstPage();
}
/**
* Get previous pagination page number
*
* @return bool|int
*/
public function previousPage(): int
{
return ($previous = $this->currentPage - 1) > 0
? $previous
: $this->firstPage();
}
/**
* Return whether a next page exists
*/
public function hasNextPage(): bool
{
return !$this->isLastPage();
}
/**
* Get next pagination page number
*/
public function nextPage(): int
{
return ($next = $this->currentPage + 1) <= $this->pages
? $next
: $this->lastPage();
}
}

View File

@ -6,14 +6,24 @@ use Formwork\Data\Contracts\Arrayable;
use Formwork\Data\Traits\DataArrayable; use Formwork\Data\Traits\DataArrayable;
use Formwork\Data\Traits\DataMultipleGetter; use Formwork\Data\Traits\DataMultipleGetter;
use Formwork\Data\Traits\DataMultipleSetter; use Formwork\Data\Traits\DataMultipleSetter;
use Formwork\Formwork;
use Formwork\Traits\Methods;
use Formwork\Utils\Arr;
use Formwork\Utils\Constraint; use Formwork\Utils\Constraint;
use Formwork\Utils\FileSystem;
use Formwork\Utils\Str;
use UnexpectedValueException; use UnexpectedValueException;
class Field implements Arrayable class Field implements Arrayable
{ {
use DataArrayable; use DataArrayable;
use DataMultipleGetter; use DataMultipleGetter {
get as protected baseGet;
}
use DataMultipleSetter; use DataMultipleSetter;
use Methods;
protected const UNTRANSLATABLE_KEYS = ['name', 'type', 'value', 'default', 'translate', 'import'];
/** /**
* Field name * Field name
@ -27,13 +37,16 @@ class Field implements Arrayable
{ {
$this->name = $name; $this->name = $name;
$this->data = $data; $this->data = $data;
if ($this->has('import')) { if ($this->has('import')) {
$this->importData(); $this->importData();
} }
if ($this->has('fields')) { if ($this->has('fields')) {
$this->data['fields'] = new Fields($this->data['fields']); throw new UnexpectedValueException('Fields may not have other fields inside');
} }
Translator::translate($this);
$this->loadMethods();
} }
/** /**
@ -49,12 +62,7 @@ class Field implements Arrayable
*/ */
public function formName(): string public function formName(): string
{ {
$segments = explode('.', $this->name); return Str::dotNotationToBrackets($this->name());
$formName = array_shift($segments);
foreach ($segments as $segment) {
$formName .= '[' . $segment . ']';
}
return $formName;
} }
/** /**
@ -62,7 +70,7 @@ class Field implements Arrayable
*/ */
public function type(): string public function type(): string
{ {
return $this->get('type'); return $this->baseGet('type');
} }
/** /**
@ -86,7 +94,7 @@ class Field implements Arrayable
*/ */
public function value() public function value()
{ {
return $this->get('value', $this->defaultValue()); return $this->baseGet('value', $this->defaultValue());
} }
/** /**
@ -94,7 +102,7 @@ class Field implements Arrayable
*/ */
public function defaultValue() public function defaultValue()
{ {
return $this->get('default'); return $this->baseGet('default');
} }
/** /**
@ -129,12 +137,37 @@ class Field implements Arrayable
return $this->is('visible', true); return $this->is('visible', true);
} }
public function isHidden(): bool
{
return $this->is('visible', false);
}
/** /**
* Get a value by key and return whether it is equal to boolean `true` * Get a value by key and return whether it is equal to boolean `true`
*/ */
public function is(string $key, bool $default = false): bool public function is(string $key, bool $default = false): bool
{ {
return $this->get($key, $default) === true; return $this->baseGet($key, $default) === true;
}
public function get(string $key, $default = null)
{
$value = $this->baseGet($key, $default);
if ($this->isTranslatable($key)) {
return $this->translate($value);
}
return $value;
}
protected function loadMethods()
{
$config = Formwork::instance()->config()->get('fields.path') . $this->get('type') . '.php';
if (FileSystem::exists($config)) {
$this->methods = include $config;
}
} }
/** /**
@ -146,29 +179,68 @@ class Field implements Arrayable
if ($key === 'import') { if ($key === 'import') {
throw new UnexpectedValueException('Invalid key for import'); throw new UnexpectedValueException('Invalid key for import');
} }
$callback = explode('::', $value, 2); $callback = explode('::', $value, 2);
if (!is_callable($callback)) { if (!is_callable($callback)) {
throw new UnexpectedValueException(sprintf('Invalid import callback "%s"', $value)); throw new UnexpectedValueException(sprintf('Invalid import callback "%s"', $value));
} }
$this->data[$key] = $callback(); $this->data[$key] = $callback();
} }
} }
public function __debugInfo(): array /**
* Return whether a field key is translatable
*/
protected function isTranslatable(string $key): bool
{ {
$return['name'] = $this->name; if (in_array($key, self::UNTRANSLATABLE_KEYS, true)) {
if ($this->has('type')) { return false;
$return['type'] = $this->type();
} }
if ($this->has('default')) {
$return['default'] = $this->defaultValue(); $translatable = $this->baseGet('translate', true);
if (is_array($translatable)) {
return in_array($key, $translatable, true);
} }
if ($this->has('value')) {
$return['value'] = $this->value(); return $translatable;
}
protected function translate($value)
{
$translation = Formwork::instance()->translations()->getCurrent();
$language = $translation->code();
if (is_array($value)) {
if (isset($value[$language])) {
$value = $value[$language];
}
} elseif (!is_string($value)) {
return $value;
} }
if ($this->has('fields')) {
$return['fields'] = $this->get('fields'); $interpolate = fn ($value) => Str::interpolate($value, fn ($key) => $translation->translate($key));
if (is_array($value)) {
return Arr::map($value, $interpolate);
} }
return $return;
return $interpolate($value);
}
protected function callMethod(string $method, array $arguments = [])
{
return $this->methods[$method](...[$this, ...$arguments]);
}
public function __toString(): string
{
if ($this->hasMethod('toString')) {
return $this->callMethod('toString');
}
return (string) $this->value();
} }
} }

View File

@ -0,0 +1,65 @@
<?php
namespace Formwork\Fields;
use Formwork\Data\AbstractCollection;
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Fields\Layout\Layout;
use Formwork\Utils\Arr;
use Formwork\Utils\Constraint;
class FieldCollection extends AbstractCollection
{
protected bool $associative = true;
protected ?string $dataType = Field::class;
protected Layout $layout;
/**
* Create a new FieldCollection instance
*
* @param array $fields Array of Field objects
*/
public function __construct(array $fields, Layout $layout)
{
parent::__construct(Arr::map($fields, fn ($data, $name) => new Field($name, $data)));
$this->layout = $layout;
}
public function layout(): Layout
{
return $this->layout;
}
public function pluck(string $key, $default = null): array
{
return $this->everyItem()->get($key, $default)->toArray();
}
/**
* Validate fields against data
*/
public function validate($data): self
{
$data = Arr::from($data);
foreach ($this->data as $field) {
$value = Arr::get($data, $field->name());
// TODO: move to field
if ($field->isRequired() && Constraint::isEmpty($value)) {
throw new ValidationException(sprintf('Required field "%s" of type "%s" cannot be empty', $field->name(), $field->type()));
}
if ($field->hasMethod('validate')) {
$value = $field->validate($value);
}
$field->set('value', $value);
}
return $this;
}
}

View File

@ -1,90 +0,0 @@
<?php
namespace Formwork\Fields;
use Formwork\Data\AbstractCollection;
use Formwork\Data\DataGetter;
class Fields extends AbstractCollection
{
protected ?string $dataType = Field::class;
/**
* Create a new Fields instance
*
* @param array $fields Array of Field objects
*/
public function __construct(array $fields)
{
parent::__construct();
foreach ($fields as $key => $value) {
if ($value === null) {
continue;
}
if ($value instanceof Field) {
if (is_int($key)) {
$key = $value->name();
}
$this->data[$key] = $value;
} else {
$this->data[$key] = new Field($key, $value);
}
}
}
/**
* Recursively find a field by name
*
* @param string $field Field name
*/
public function find(string $field): ?Field
{
foreach ($this->data as $key => $value) {
if ($key === $field) {
return $this->data[$key];
}
if ($value->has('fields')) {
$found = $value->get('fields')->find($field);
if ($found !== null) {
return $found;
}
}
}
return null;
}
/**
* Convert fields to array
*
* @param bool $flatten Whether to recursively convert Fields instances
*/
public function toArray(bool $flatten = false): array
{
if (!$flatten) {
return $this->data;
}
$result = [];
foreach ($this->data as $key => $value) {
if ($value->has('fields')) {
$result = array_merge($result, $value->get('fields')->toArray(true));
} else {
$result[$key] = $value;
}
}
return $result;
}
/**
* Validate fields against data
*/
public function validate(DataGetter $data): self
{
Validator::validate($this, $data);
return $this;
}
public function __debugInfo(): array
{
return $this->data;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace Formwork\Fields\Layout;
class Layout
{
protected string $type;
protected SectionCollection $sections;
public function __construct(array $data)
{
$this->type = $data['type'];
$this->sections = new SectionCollection($data['sections'] ?? []);
}
/** Get layout type
*
*/
public function type(): string
{
return $this->type;
}
/**
* Get layout sections
*/
public function sections()
{
return $this->sections;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Formwork\Fields\Layout;
use Formwork\Data\Traits\DataGetter;
use Formwork\Formwork;
use Formwork\Utils\Str;
class Section
{
use DataGetter;
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Get a value by key and return whether it is equal to boolean `true`
*/
public function is(string $key, bool $default = false): bool
{
return $this->get($key, $default) === true;
}
/**
* Get field label
*/
public function label(): string
{
$translation = Formwork::instance()->translations()->getCurrent();
return Str::interpolate($this->get('label', ''), fn ($key) => $translation->translate($key));
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Formwork\Fields\Layout;
use Formwork\Data\AbstractCollection;
use Formwork\Utils\Arr;
class SectionCollection extends AbstractCollection
{
protected bool $associative = true;
protected ?string $dataType = Section::class;
public function __construct(array $sections)
{
parent::__construct(Arr::map($sections, fn ($section) => new Section($section)));
}
}

View File

@ -1,78 +0,0 @@
<?php
namespace Formwork\Fields;
use Formwork\Formwork;
class Translator
{
/**
* Language string interpolation regex
*/
protected const INTERPOLATION_REGEX = '/^{{([\-._a-z]+)}}$/i';
/**
* Fields not to translate
*/
protected const IGNORED_PROPERTIES = ['name', 'type', 'import', 'fields'];
/**
* Keys of which array value has to be ignored
*/
protected const IGNORED_ARRAY_KEYS = ['value', 'options'];
/**
* Translate a field
*/
public static function translate(Field $field): void
{
$language = Formwork::instance()->translations()->getCurrent()->code();
foreach ($field->toArray() as $key => $value) {
if (static::isTranslatable($key, $field)) {
if (is_array($value)) {
if (isset($value[$language])) {
$value = $value[$language];
} elseif (!in_array($key, self::IGNORED_ARRAY_KEYS, true)) {
$value = array_shift($value);
}
} elseif (!is_string($value)) {
continue;
}
$field->set($key, static::interpolate($value));
}
}
}
/**
* Return whether a field key is translatable
*/
protected static function isTranslatable(string $key, Field $field): bool
{
if (in_array($key, self::IGNORED_PROPERTIES, true)) {
return false;
}
$translate = $field->get('translate', true);
if (is_array($translate)) {
return in_array($key, $translate, true);
}
return $translate;
}
/**
* Interpolate a string
*
* @param array|string $value
*
* @return array|string
*/
protected static function interpolate($value)
{
if (is_array($value)) {
return array_map(static fn ($value) => static::interpolate($value), $value);
}
if (is_string($value) && (bool) preg_match(self::INTERPOLATION_REGEX, $value, $matches)) {
return Formwork::instance()->translations()->getCurrent()->translate($matches[1]);
}
return $value;
}
}

View File

@ -1,296 +0,0 @@
<?php
namespace Formwork\Fields;
use Formwork\Data\Contracts\Arrayable;
use Formwork\Data\DataGetter;
use Formwork\Fields\Exceptions\ValidationException;
use Formwork\Utils\Constraint;
use Formwork\Utils\Date;
use Formwork\Utils\Str;
use InvalidArgumentException;
class Validator
{
/**
* Field types ignored by the validator
*/
public const IGNORED_FIELDS = ['column', 'header', 'row', 'rows'];
/**
* Date format used for date fields
*/
public const DATE_FORMAT = 'Y-m-d H:i:s';
/**
* Validate all Fields against given data
*/
public static function validate(Fields $fields, DataGetter $data): void
{
foreach ($fields as $field) {
if ($field->has('fields')) {
$field->get('fields')->validate($data);
}
if (in_array($field->type(), self::IGNORED_FIELDS, true)) {
continue;
}
$value = $data->get($field->name());
if ($field->isRequired() && Constraint::isEmpty($value)) {
throw new ValidationException(sprintf('Required field "%s" of type "%s" cannot be empty', $field->name(), $field->type()));
}
$method = 'validate' . ucfirst(strtolower($field->type()));
if (method_exists(static::class, $method)) {
$value = static::$method($value, $field);
}
$field->set('value', $value);
}
}
/**
* Validate "checkbox" fields
*/
public static function validateCheckbox($value, Field $field): bool
{
if (Constraint::isTruthy($value)) {
return true;
}
if (Constraint::isFalsy($value)) {
return false;
}
if ($value === null) {
return false;
}
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
/**
* Validate "togglegroup" fields
*/
public static function validateTogglegroup($value, Field $field)
{
if (Constraint::isTruthy($value)) {
return true;
}
if (Constraint::isFalsy($value)) {
return false;
}
return static::parse($value);
}
/**
* Validate "date" fields
*/
public static function validateDate($value, Field $field): ?string
{
if (Constraint::isEmpty($value)) {
return null;
}
try {
return date(self::DATE_FORMAT, Date::toTimestamp($value));
} catch (InvalidArgumentException $e) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s":%s', $field->name(), $field->type(), Str::after($e->getMessage(), ':')));
}
}
/**
* Validate "number" fields
*
* @return float|int
*/
public static function validateNumber($value, Field $field)
{
$value = static::parse($value);
if (!is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('min') && $value < $field->get('min')) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be greater than or equal to %d', $field->name(), $field->type(), $field->get('min')));
}
if ($field->has('max') && $value > $field->get('max')) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" must be less than or equal to %d', $field->name(), $field->type(), $field->get('max')));
}
if ($field->has('step') && ($value - $field->get('min', 0)) % $field->get('step') !== 0) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not conform to the step value %d', $field->name(), $field->value(), $field->get('step')));
}
return $value;
}
/**
* Validate "range" fields
*
* @return float|int
*/
public static function validateRange($value, Field $field)
{
return static::validateNumber($value, $field);
}
/**
* Validate "select" fields
*/
public static function validateSelect($value, Field $field)
{
return static::parse($value);
}
/**
* Validate "tags" fields
*/
public static function validateTags($value, Field $field): array
{
if (Constraint::isEmpty($value)) {
return [];
}
if (is_string($value)) {
$value = array_map('trim', explode(',', $value));
}
if (!is_array($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('pattern')) {
$value = array_filter($value, static fn ($item): bool => Constraint::matchesRegex($item, $field->get('pattern')));
}
return array_values(array_filter($value));
}
/**
* Validate "array" fields
*/
public static function validateArray($value, Field $field): array
{
if (Constraint::isEmpty($value)) {
return [];
}
if ($value instanceof Arrayable) {
$value = $value->toArray();
}
if (!is_array($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->get('associative')) {
foreach (array_keys($value) as $key) {
if (is_int($key)) {
unset($value[$key]);
}
}
}
return array_filter($value);
}
/**
* Validate "text" fields
*/
public static function validateText($value, Field $field): string
{
if (Constraint::isEmpty($value)) {
return '';
}
if (!is_string($value) && !is_numeric($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
if ($field->has('min') && strlen($value) < $field->get('min')) {
throw new ValidationException(sprintf('The minimum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('min')));
}
if ($field->has('max') && strlen($value) > $field->get('max')) {
throw new ValidationException(sprintf('The maximum allowed length for field "%s" of type "%s" is %d', $field->name(), $field->value(), $field->get('max')));
}
if ($field->has('pattern') && !Constraint::matchesRegex($value, $field->get('pattern'))) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" does not match the required pattern', $field->name(), $field->value()));
}
return (string) $value;
}
/**
* Validate "textarea" fields
*/
public static function validateTextarea($value, Field $field): string
{
return static::validateText($value, $field);
}
/**
* Validate "email" fields
*/
public static function validateEmail($value, Field $field): string
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new ValidationException(sprintf('The value of field "%s" of type "%s" is not a valid e-mail address', $field->name(), $field->value()));
}
return static::validateText($value, $field);
}
/**
* Validate "password" fields
*/
public static function validatePassword($value, Field $field): string
{
return static::validateText($value, $field);
}
/**
* Validate "image" fields
*/
public static function validateImage($value, Field $field): ?string
{
if (Constraint::isEmpty($value)) {
return null;
}
if (!is_string($value)) {
throw new ValidationException(sprintf('Invalid value for field "%s" of type "%s"', $field->name(), $field->type()));
}
return $value;
}
/**
* Validate "duration" fields
*/
public static function validateDuration($value, Field $field)
{
return static::validateNumber($value, $field);
}
/**
* Cast a value to its correct type
*/
protected static function parse($value)
{
if (is_numeric($value)) {
// This reliably casts numeric values to int or float
return $value + 0;
}
return $value;
}
}

View File

@ -6,6 +6,7 @@ use Formwork\Admin\Admin;
use Formwork\Admin\Statistics; use Formwork\Admin\Statistics;
use Formwork\Cache\FilesCache; use Formwork\Cache\FilesCache;
use Formwork\Languages\Languages; use Formwork\Languages\Languages;
use Formwork\Pages\Site;
use Formwork\Parsers\PHP; use Formwork\Parsers\PHP;
use Formwork\Parsers\YAML; use Formwork\Parsers\YAML;
use Formwork\Router\Router; use Formwork\Router\Router;

View File

@ -2,30 +2,90 @@
namespace Formwork\Metadata; namespace Formwork\Metadata;
use Formwork\Data\AbstractCollection; use Formwork\Utils\Str;
class Metadata extends AbstractCollection class Metadata
{ {
protected bool $associative = true; protected const HTTP_EQUIV_NAMES = ['content-type', 'default-style', 'refresh'];
protected ?string $dataType = Metadatum::class; /**
* Metadata name
*/
protected string $name;
protected bool $mutable = true; /**
* Metadata content
*/
protected string $content;
/**
* Metadata prefix
*/
protected string $prefix;
/** /**
* Create a new Metadata instance * Create a new Metadata instance
*/ */
public function __construct(array $data) public function __construct(string $name, string $content)
{ {
parent::__construct(); $this->name = strtolower($name);
$this->setMultiple($data); $this->content = $content;
if ($prefix = Str::before($name, ':')) {
$this->prefix = $prefix;
}
} }
/** /**
* Set a metadatum * Return metadata name
*/ */
public function set(string $key, $value) public function name(): string
{ {
$this->data[$key] = new Metadatum($key, $value); return $this->name;
}
/**
* Return whether the metadata is a charset declaration
*/
public function isCharset(): bool
{
return $this->name === 'charset';
}
/**
* Return whether the metadata is an http-equiv directive
*/
public function isHTTPEquiv(): bool
{
return in_array($this->name, self::HTTP_EQUIV_NAMES, true);
}
/**
* Return metadata content
*/
public function content(): string
{
return $this->content;
}
/**
* Return metadata prefix
*/
public function prefix(): ?string
{
return $this->prefix;
}
/**
* Return whether the metadata has a prefix (e.g. 'twitter' for 'twitter:card', 'og' for 'og:image')
*/
public function hasPrefix(): bool
{
return $this->prefix !== null;
}
public function __toString(): string
{
return $this->content();
} }
} }

View File

@ -0,0 +1,31 @@
<?php
namespace Formwork\Metadata;
use Formwork\Data\AbstractCollection;
class MetadataCollection extends AbstractCollection
{
protected bool $associative = true;
protected ?string $dataType = Metadata::class;
protected bool $mutable = true;
/**
* Create a new Metadata instance
*/
public function __construct(array $data)
{
parent::__construct();
$this->setMultiple($data);
}
/**
* Set a metadata
*/
public function set(string $key, $value)
{
$this->data[$key] = new Metadata($key, $value);
}
}

View File

@ -1,91 +0,0 @@
<?php
namespace Formwork\Metadata;
use Formwork\Utils\Str;
class Metadatum
{
protected const HTTP_EQUIV_NAMES = ['content-type', 'default-style', 'refresh'];
/**
* Metadatum name
*/
protected string $name;
/**
* Metadatum content
*/
protected string $content;
/**
* Metadatum prefix
*/
protected string $prefix;
/**
* Create a new Metadatum instance
*/
public function __construct(string $name, string $content)
{
$this->name = strtolower($name);
$this->content = $content;
if ($prefix = Str::before($name, ':')) {
$this->prefix = $prefix;
}
}
/**
* Return metadatum name
*/
public function name(): string
{
return $this->name;
}
/**
* Return whether the metadatum is a charset declaration
*/
public function isCharset(): bool
{
return $this->name === 'charset';
}
/**
* Return whether the metadatum is an http-equiv directive
*/
public function isHTTPEquiv(): bool
{
return in_array($this->name, self::HTTP_EQUIV_NAMES, true);
}
/**
* Return metadatum content
*/
public function content(): string
{
return $this->content;
}
/**
* Return metadatum prefix
*/
public function prefix(): ?string
{
return $this->prefix;
}
/**
* Return whether the metadatum has a prefix (e.g. 'twitter' for 'twitter:card', 'og' for 'og:image')
*/
public function hasPrefix(): bool
{
return $this->prefix !== null;
}
public function __toString(): string
{
return $this->content();
}
}

View File

@ -1,573 +0,0 @@
<?php
namespace Formwork;
use Formwork\Files\Files;
use Formwork\Metadata\Metadata;
use Formwork\Parsers\Markdown;
use Formwork\Parsers\YAML;
use Formwork\Schemes\Scheme;
use Formwork\Template\Template;
use Formwork\Utils\Date;
use Formwork\Utils\FileSystem;
use Formwork\Utils\Header;
use Formwork\Utils\Str;
use Formwork\Utils\Uri;
use RuntimeException;
class Page extends AbstractPage
{
/**
* Page num regex
*/
public const NUM_REGEX = '/^(\d+)-/';
/**
* Page 'published' status
*/
public const PAGE_STATUS_PUBLISHED = 'published';
/**
* Page 'not published' status
*/
public const PAGE_STATUS_NOT_PUBLISHED = 'not-published';
/**
* Page 'not routable' status
*/
public const PAGE_STATUS_NOT_ROUTABLE = 'not-routable';
/**
* Page id
*
* @deprecated Use the name property
*/
protected string $id;
/**
* Page name (the name of the containing directory)
*/
protected string $name;
/**
* Page slug
*/
protected string $slug;
/**
* Page language
*/
protected ?string $language = null;
/**
* Available page languages
*/
protected array $availableLanguages = [];
/**
* Page filename
*/
protected string $filename;
/**
* Page template
*/
protected Template $template;
/**
* Page scheme
*/
protected Scheme $scheme;
/**
* Page files
*/
protected Files $files;
/**
* Unprocessed page frontmatter
*/
protected string $rawFrontmatter;
/**
* Page frontmatter
*/
protected array $frontmatter = [];
/**
* Unprocessed page content
*/
protected string $rawContent;
/**
* Page content
*/
protected string $content;
/**
* Unprocessed page summary
*/
protected string $rawSummary;
/**
* Page summary
*/
protected string $summary;
/**
* Unprocessed page body text
*/
protected string $rawBody;
/**
* Page body text
*/
protected string $body;
/**
* Page status
*/
protected string $status;
/**
* Whether page is published
*/
protected bool $published;
/**
* Whether page is routable
*/
protected bool $routable;
/**
* Whether page is visible
*/
protected bool $visible;
/**
* Whether page is sortable
*/
protected bool $sortable;
/**
* PageCollection containing page siblings
*/
protected PageCollection $siblings;
/**
* Create a new Page instance
*/
public function __construct(string $path)
{
$this->path = FileSystem::normalizePath($path . DS);
$this->relativePath = Str::wrap(Str::removeStart($this->path, Formwork::instance()->site()->path()), DS);
$this->route = Uri::normalize(preg_replace('~[/\\\\](\d+-)~', '/', $this->relativePath));
$this->name = basename($this->relativePath);
$this->slug = basename($this->route);
$this->loadFiles();
if (!$this->isEmpty()) {
$this->parse();
$this->processData();
}
}
/**
* Return page default data
*/
public function defaults(): array
{
$defaults = [
'published' => true,
'routable' => true,
'visible' => true,
'searchable' => true,
'cacheable' => true,
'sortable' => true,
'headers' => [],
'metadata' => []
];
// Merge with scheme default field values
$defaults = array_merge($defaults, $this->scheme->defaultFieldValues());
// If the page hasn't a num, by default it won't be visible
if ($this->num() === null) {
$defaults['visible'] = false;
}
return $defaults;
}
/**
* Reload page
*/
public function reload(): void
{
$vars = ['filename', 'template', 'rawContent', 'rawSummary', 'summary', 'rawBody', 'body', 'status', 'absoluteUri', 'lastModifiedTime', 'parent', 'parents', 'level', 'children', 'descendants', 'siblings'];
foreach ($vars as $var) {
unset($this->$var);
}
$this->__construct($this->path);
}
/**
* Return whether page is empty
*/
public function isEmpty(): bool
{
return !isset($this->filename);
}
/**
* @inheritdoc
*/
public function lastModifiedTime(): int
{
return max(FileSystem::lastModifiedTime($this->path . $this->filename), parent::lastModifiedTime());
}
/**
* Get page num
*/
public function num(): ?int
{
preg_match(self::NUM_REGEX, $this->name, $matches);
return isset($matches[1]) ? (int) $matches[1] : null;
}
/**
* Return whether this is the currently active page
*/
public function isCurrent(): bool
{
return Formwork::instance()->site()->currentPage() === $this;
}
/**
* @inheritdoc
*/
public function date(string $format = null): string
{
$format ??= Formwork::instance()->config()->get('date.format');
if ($this->has('publish-date')) {
return date($format, Date::toTimestamp($this->data['publish-date']));
}
return parent::date($format);
}
/**
* Get page status
*/
public function status(): string
{
if (isset($this->status)) {
return $this->status;
}
if ($this->published()) {
$status = self::PAGE_STATUS_PUBLISHED;
}
if (!$this->routable()) {
$status = self::PAGE_STATUS_NOT_ROUTABLE;
}
if (!$this->published()) {
$status = self::PAGE_STATUS_NOT_PUBLISHED;
}
return $this->status = $status;
}
/**
* Return a PageCollection containing page siblings
*/
public function siblings(): PageCollection
{
if (isset($this->siblings)) {
return $this->siblings;
}
$parentPath = dirname($this->path) . DS;
return $this->siblings = PageCollection::fromPath($parentPath)->without($this);
}
/**
* Return whether page has siblings
*/
public function hasSiblings(): bool
{
return !$this->siblings()->isEmpty();
}
/**
* @inheritdoc
*/
public function isSite(): bool
{
return false;
}
/**
* @inheritdoc
*/
public function isIndexPage(): bool
{
return trim($this->route(), '/') === Formwork::instance()->config()->get('pages.index');
}
/**
* @inheritdoc
*/
public function isErrorPage(): bool
{
return trim($this->route(), '/') === Formwork::instance()->config()->get('pages.error');
}
/**
* @inheritdoc
*/
public function isDeletable(): bool
{
return !($this->hasChildren() || $this->isSite() || $this->isIndexPage() || $this->isErrorPage());
}
/**
* @inheritdoc
*/
public function metadata(): Metadata
{
if (isset($this->metadata)) {
return $this->metadata;
}
$metadata = clone Formwork::instance()->site()->metadata();
$metadata->setMultiple($this->data['metadata']);
return $this->metadata = $metadata;
}
/**
* Return whether page has a language
*/
public function hasLanguage(string $language): bool
{
return in_array($language, $this->availableLanguages, true);
}
/**
* Set page language
*/
public function setLanguage(string $language): void
{
if (!$this->hasLanguage($language)) {
throw new RuntimeException(sprintf('Invalid page language "%s"', $language));
}
$this->language = $language;
$this->__construct($this->path);
}
/**
* Return a Files collection containing only images
*/
public function images(): Files
{
return $this->files()->filterByType('image');
}
/**
* Render page to string
*
* @param array $vars Variables to pass to the page
*/
public function renderToString(array $vars = []): string
{
return $this->template()->render(true);
}
/**
* Render page and return rendered content
*
* @param array $vars Variables to pass to the page
* @param bool $sendHeaders Whether to send headers before rendering
*/
public function render(array $vars = [], bool $sendHeaders = true): string
{
if ($sendHeaders) {
$this->sendHeaders();
}
echo $renderedPage = $this->renderToString($vars);
return $renderedPage;
}
/**
* Return an array containing page data
*/
public function toArray(): array
{
return [
'route' => $this->route(),
'uri' => $this->uri(),
'name' => $this->name(),
'slug' => $this->slug(),
'template' => $this->template()->name(),
'num' => $this->num(),
'data' => $this->data()
];
}
/**
* Return raw content
*/
public function rawContent(): string
{
if (isset($this->rawContent)) {
return $this->rawContent;
}
return $this->rawContent = str_replace("\r\n", "\n", empty($this->rawSummary) ? $this->rawBody : $this->rawSummary . "\n\n===\n\n" . $this->rawBody);
}
/**
* Return summary text
*/
public function summary(): string
{
if (isset($this->summary)) {
return $this->summary;
}
return $this->summary = Markdown::parse($this->rawSummary, ['baseRoute' => $this->route]);
}
/**
* Return body text
*/
public function body(): string
{
if (isset($this->body)) {
return $this->body;
}
return $this->body = Markdown::parse($this->rawBody, ['baseRoute' => $this->route]);
}
/**
* Return page content (summary and body text)
*/
public function content(): string
{
return $this->summary() . $this->body();
}
/**
* Load files related to page
*/
protected function loadFiles(): void
{
$contentFiles = [];
$files = [];
foreach (FileSystem::listFiles($this->path) as $file) {
$name = FileSystem::name($file);
$extension = '.' . FileSystem::extension($file);
if ($extension === Formwork::instance()->config()->get('content.extension')) {
$language = null;
if (preg_match('/([a-z0-9]+)\.([a-z]+)/', $name, $matches)) {
// Parse double extension
[$match, $name, $language] = $matches;
}
if (Formwork::instance()->site()->hasTemplate($name)) {
$contentFiles[$language] = [
'filename' => $file,
'template' => $name
];
if ($language !== null && !in_array($language, $this->availableLanguages, true)) {
$this->availableLanguages[] = $language;
}
}
} elseif (in_array($extension, Formwork::instance()->config()->get('files.allowed_extensions'), true)) {
$files[] = $file;
}
}
if (!empty($contentFiles)) {
// Get correct content file based on current language
ksort($contentFiles);
$currentLanguage = $this->language ?: Formwork::instance()->site()->languages()->current();
$key = isset($contentFiles[$currentLanguage]) ? $currentLanguage : array_keys($contentFiles)[0];
// Set actual language
$this->language = $key ?: null;
$this->filename = $contentFiles[$key]['filename'];
$this->template = new Template($contentFiles[$key]['template'], $this);
$this->scheme = Formwork::instance()->schemes()->get('pages', $this->template);
}
$this->files = Files::fromPath($this->path, $files);
}
/**
* Parse page content
*/
protected function parse(): void
{
$contents = FileSystem::read($this->path . $this->filename);
if (!preg_match('/(?:\s|^)-{3}\s*(.+?)\s*-{3}\s*(?:(.+?)\s+={3}\s+)?(.*?)\s*$/s', $contents, $matches)) {
throw new RuntimeException('Invalid page format');
}
[$match, $this->rawFrontmatter, $this->rawSummary, $this->rawBody] = $matches;
$this->frontmatter = YAML::parse($this->rawFrontmatter);
$this->data = array_merge($this->defaults(), $this->frontmatter);
}
/**
* Process page data
*/
protected function processData(): void
{
$this->published = $this->data['published'];
if ($this->has('publish-date')) {
$this->published = $this->published && Date::toTimestamp($this->get('publish-date')) < time();
}
if ($this->has('unpublish-date')) {
$this->published = $this->published && Date::toTimestamp($this->get('unpublish-date')) > time();
}
$this->routable = $this->data['routable'];
$this->visible = $this->data['visible'];
// If the page isn't published, it won't also be visible
if (!$this->published) {
$this->visible = false;
}
$this->sortable = $this->data['sortable'];
if ($this->num() === null || $this->scheme->get('num') === 'date') {
$this->sortable = false;
}
// Set default 404 Not Found status to error page
if ($this->isErrorPage() && !$this->has('response_status')) {
$this->set('response_status', 404);
}
}
/**
* Send page headers
*/
protected function sendHeaders(): void
{
if ($this->has('response_status')) {
Header::status((int) $this->get('response_status'));
}
foreach ($this->headers() as $name => $value) {
Header::send($name, $value);
}
}
public function __toString(): string
{
return $this->name();
}
public function __debugInfo(): array
{
return $this->toArray();
}
}

464
formwork/src/Pages/Page.php Normal file
View File

@ -0,0 +1,464 @@
<?php
namespace Formwork\Pages;
use Formwork\Data\Contracts\Arrayable;
use Formwork\Fields\FieldCollection;
use Formwork\Files\Files;
use Formwork\Formwork;
use Formwork\Metadata\MetadataCollection;
use Formwork\Pages\Traits\PageStatus;
use Formwork\Pages\Traits\PageTraversal;
use Formwork\Pages\Traits\PageUid;
use Formwork\Pages\Traits\PageUri;
use Formwork\Parsers\YAML;
use Formwork\Schemes\Scheme;
use Formwork\Pages\Templates\Template;
use Formwork\Pages\Traits\PageData;
use Formwork\Utils\Arr;
use Formwork\Utils\Date;
use Formwork\Utils\FileSystem;
use Formwork\Utils\Path;
use Formwork\Utils\Str;
use Formwork\Utils\Uri;
use RuntimeException;
class Page implements Arrayable
{
use PageData;
use PageStatus;
use PageTraversal;
use PageUid;
use PageUri;
/**
* Page num regex
*/
public const NUM_REGEX = '/^(\d+)-/';
/**
* Page 'published' status
*/
public const PAGE_STATUS_PUBLISHED = 'published';
/**
* Page 'not published' status
*/
public const PAGE_STATUS_NOT_PUBLISHED = 'not-published';
/**
* Page 'not routable' status
*/
public const PAGE_STATUS_NOT_ROUTABLE = 'not-routable';
/**
* Page path relative to content path
*/
protected string $relativePath;
/**
* Page unique identifier
*/
protected string $uid;
/**
* Page route
*/
protected string $route;
/**
* Page data
*/
protected array $data = [];
/**
* Page uri
*/
protected string $uri;
/**
* Page absolute uri
*/
protected string $absoluteUri;
/**
* Page last modified time
*/
protected int $lastModifiedTime;
/**
* Page modified date
*/
protected string $timestamp;
/**
* Page name (the name of the containing directory)
*/
protected string $name;
/**
* Page slug
*/
protected string $slug;
/**
* Page language
*/
protected ?string $language;
/**
* Available page languages
*/
protected array $availableLanguages;
/**
* Page filename
*/
protected string $filename;
/**
* Page template
*/
protected Template $template;
/**
* Page scheme
*/
protected Scheme $scheme;
/**
* Page files
*/
protected Files $files;
/**
* Page frontmatter
*/
protected array $frontmatter;
protected FieldCollection $fields;
protected ?string $canonical;
protected MetadataCollection $metadata;
protected int $responseStatus;
protected ?int $num;
/**
* Create a new Page instance
*/
public function __construct(string $path)
{
$this->path = FileSystem::normalizePath($path . DS);
$this->relativePath = Str::prepend(Path::makeRelative($this->path, Formwork::instance()->site()->path(), DS), DS);
$this->route = Uri::normalize(preg_replace('~[/\\\\](\d+-)~', '/', $this->relativePath));
$this->name = basename($this->relativePath);
$this->slug = basename($this->route);
$this->language = null;
$this->availableLanguages = [];
$this->loadFiles();
if (!$this->isEmpty()) {
$this->loadContents();
}
}
/**
* Return page default data
*/
public function defaults(): array
{
$defaults = [
'published' => true,
'routable' => true,
'listed' => true,
'searchable' => true,
'cacheable' => true,
'orderable' => true,
'canonical' => null,
'headers' => [],
'responseStatus' => 200,
'metadata' => []
];
// Merge with scheme default field values
$defaults = array_merge($defaults, Arr::reject($this->fields()->pluck('default'), fn ($value) => $value === null));
// If the page hasn't a num, by default it won't be listed
if ($this->num() === null) {
$defaults['listed'] = false;
}
// If the page hasn't a num or numbering is 'date', by default it won't be orderable
if ($this->num() === null || $this->scheme->get('num') === 'date') {
$defaults['orderable'] = false;
}
return $defaults;
}
public function canonical(): ?string
{
if (isset($this->canonical)) {
return $this->canonical;
}
return $this->canonical = !empty($this->data['canonical'])
? Path::normalize($this->data['canonical'])
: null;
}
public function metadata(): MetadataCollection
{
if (isset($this->metadata)) {
return $this->metadata;
}
$metadata = Formwork::instance()->site()->metadata()->clone();
$metadata->setMultiple($this->data['metadata']);
return $this->metadata = $metadata;
}
public function responseStatus(): int
{
if (isset($this->responseStatus)) {
return $this->responseStatus;
}
// Normalize response status
$this->responseStatus = (int) $this->data['responseStatus'];
// Set default 404 Not Found status to error page
if ($this->isErrorPage() && $this->responseStatus() === 200 && !isset($this->frontmatter['responseStatus'])) {
$this->responseStatus = 404;
}
return $this->responseStatus;
}
public function lastModifiedTime(): int
{
if (isset($this->lastModifiedTime)) {
return $this->lastModifiedTime;
}
return $this->lastModifiedTime = FileSystem::lastModifiedTime($this->path . $this->filename);
}
public function timestamp(): int
{
if (isset($this->timestamp)) {
return $this->timestamp;
}
return $this->timestamp = isset($this->data['publishDate'])
? Date::toTimestamp($this->data['publishDate'])
: $this->lastModifiedTime();
}
/**
* Get page num
*/
public function num(): ?int
{
if (isset($this->num)) {
return $this->num;
}
preg_match(self::NUM_REGEX, $this->name, $matches);
return $this->num = isset($matches[1]) ? (int) $matches[1] : null;
}
/**
* Set page language
*/
public function setLanguage(string $language): void
{
if (!$this->hasLanguage($language)) {
throw new RuntimeException(sprintf('Invalid page language "%s"', $language));
}
$this->resetProperties();
$this->language = $language;
$this->__construct($this->path);
}
public function files(): Files
{
return $this->files;
}
/**
* Return a Files collection containing only images
*/
public function images(): Files
{
return $this->files()->filterByType('image');
}
/**
* Render page to string
*/
public function render(): string
{
return $this->template()->render(true);
}
/**
* Return whether page is empty
*/
public function isEmpty(): bool
{
return !isset($this->filename);
}
public function isPublished(): bool
{
return $this->status() === self::PAGE_STATUS_PUBLISHED;
}
/**
* Return whether this is the currently active page
*/
public function isCurrent(): bool
{
return Formwork::instance()->site()->currentPage() === $this;
}
public function isSite(): bool
{
return false;
}
public function isIndexPage(): bool
{
return trim($this->route(), '/') === Formwork::instance()->config()->get('pages.index');
}
public function isErrorPage(): bool
{
return trim($this->route(), '/') === Formwork::instance()->config()->get('pages.error');
}
public function isDeletable(): bool
{
return !($this->hasChildren() || $this->isIndexPage() || $this->isErrorPage());
}
public function hasLanguage(string $language): bool
{
return in_array($language, $this->availableLanguages, true);
}
/**
* Reload page
*
* @internal
*/
public function reload(): void
{
$path = $this->path;
$this->resetProperties();
$this->__construct($path);
}
/**
* Load files related to page
*/
protected function loadFiles(): void
{
$contentFiles = [];
$files = [];
foreach (FileSystem::listFiles($this->path) as $file) {
$name = FileSystem::name($file);
$extension = '.' . FileSystem::extension($file);
if ($extension === Formwork::instance()->config()->get('content.extension')) {
$language = null;
if (preg_match('/([a-z0-9]+)\.([a-z]+)/', $name, $matches)) {
// Parse double extension
[$match, $name, $language] = $matches;
}
if (Formwork::instance()->site()->templates()->has($name)) {
$contentFiles[$language] = [
'filename' => $file,
'template' => $name
];
if ($language !== null && !in_array($language, $this->availableLanguages, true)) {
$this->availableLanguages[] = $language;
}
}
} elseif (in_array($extension, Formwork::instance()->config()->get('files.allowed_extensions'), true)) {
$files[] = $file;
}
}
if (!empty($contentFiles)) {
// Get correct content file based on current language
ksort($contentFiles);
$currentLanguage = $this->language ?? Formwork::instance()->site()->languages()->current();
$key = isset($contentFiles[$currentLanguage]) ? $currentLanguage : array_keys($contentFiles)[0];
// Set actual language
$this->language = $key ?: null;
$this->filename = $contentFiles[$key]['filename'];
$this->template = new Template($contentFiles[$key]['template'], $this);
$this->scheme = Formwork::instance()->schemes()->get('pages', $this->template);
$this->fields = $this->scheme()->fields();
}
$this->files = Files::fromPath($this->path, $files);
}
/**
* Parse page content
*/
protected function loadContents(): void
{
$contents = FileSystem::read($this->path . $this->filename);
if (!preg_match('/(?:\s|^)-{3}\s*(.+?)\s*-{3}\s*(.*?)\s*$/s', $contents, $matches)) {
throw new RuntimeException('Invalid page format');
}
[, $rawFrontmatter, $rawContent] = $matches;
$this->frontmatter = YAML::parse($rawFrontmatter);
$rawContent = str_replace("\r\n", "\n", $rawContent);
$this->data = array_merge($this->defaults(), $this->frontmatter, ['content' => $rawContent]);
$this->fields->validate($this->data);
}
protected function resetProperties(): void
{
foreach (array_keys(get_class_vars(static::class)) as $property) {
unset($this->$property);
}
}
public function __toString(): string
{
return $this->title() ?? $this->slug();
}
}

View File

@ -1,20 +1,16 @@
<?php <?php
namespace Formwork; namespace Formwork\Pages;
use Formwork\Data\AbstractCollection; use Formwork\Data\AbstractCollection;
use Formwork\Data\Collection; use Formwork\Data\Contracts\Paginable;
use Formwork\Formwork;
use Formwork\Utils\FileSystem; use Formwork\Utils\FileSystem;
use Formwork\Utils\Str; use Formwork\Utils\Str;
class PageCollection extends AbstractCollection class PageCollection extends AbstractCollection implements Paginable
{ {
/** protected ?string $dataType = Page::class . '|' . Site::class;
* Default property used to sort pages
*/
protected const DEFAULT_SORT_PROPERTY = 'relativePath';
protected ?string $dataType = AbstractPage::class;
/** /**
* Pagination related to the collection * Pagination related to the collection
@ -34,67 +30,29 @@ class PageCollection extends AbstractCollection
* *
* @param int $length Number of items in the pagination * @param int $length Number of items in the pagination
*/ */
public function paginate(int $length): static public function paginate(int $length, int $currentPage): self
{ {
$pagination = new Pagination($this->count(), $length); $pagination = new Pagination($this, $length);
$pagination->setCurrentPage($currentPage);
$pageCollection = $this->slice($pagination->offset(), $pagination->length()); $pageCollection = $this->slice($pagination->offset(), $pagination->length());
$pageCollection->pagination = $pagination; $pageCollection->pagination = $pagination;
return $pageCollection; return $pageCollection;
} }
/** public function pluck(string $key, $default = null): array
* Return an array containing the specified property of each collection item
*/
public function pluck(string $property): array
{ {
$result = []; return $this->everyItem()->get($key, $default)->toArray();
foreach ($this->data as $page) {
$result[] = $page->get($property);
}
return $result;
} }
/** public function listed(): static
* Filter collection items
*
* @param string $property Property to find in filtered items
* @param $value Value to check in filtered items (default: true)
* @param callable $process Callable to process items before filtering
*/
public function filterBy(string $property, $value = true, callable $process = null): static
{ {
return $this->filter(static function (Page $item) use ($property, $value, $process): bool { return $this->filterBy('listed');
if ($item->has($property)) {
$propertyValue = $item->get($property);
if (is_callable($process)) {
$propertyValue = is_array($propertyValue) ? array_map($process, $propertyValue) : $process($propertyValue);
$value = $process($value);
}
if (is_array($propertyValue)) {
return in_array($value, $propertyValue);
}
return $propertyValue == $value;
}
return false;
});
} }
/** public function published(): static
* Sort collection items {
*/ return $this->filterBy('status', 'published');
public function sortBy(
string $property = self::DEFAULT_SORT_PROPERTY,
int $direction = SORT_ASC,
int $type = SORT_NATURAL,
bool $caseSensitive = false,
bool $preserveKeys = true
): static {
return parent::sort($direction, $type, $this->pluck($property), $caseSensitive, $preserveKeys);
} }
/** /**
@ -112,7 +70,7 @@ class PageCollection extends AbstractCollection
$keywords = explode(' ', $query); $keywords = explode(' ', $query);
$keywords = array_diff($keywords, (array) Formwork::instance()->config()->get('search.stopwords')); $keywords = array_diff($keywords, (array) Formwork::instance()->config()->get('search.stopwords'));
$keywords = array_filter($keywords, static fn (string $item): bool => strlen($item) > $min); $keywords = array_filter($keywords, fn (string $item): bool => strlen($item) > $min);
$queryRegex = '/\b' . preg_quote($query, '/') . '\b/iu'; $queryRegex = '/\b' . preg_quote($query, '/') . '\b/iu';
$keywordsRegex = '/(?:\b' . implode('\b|\b', $keywords) . '\b)/iu'; $keywordsRegex = '/(?:\b' . implode('\b|\b', $keywords) . '\b)/iu';
@ -173,6 +131,6 @@ class PageCollection extends AbstractCollection
$pages = new static($pages); $pages = new static($pages);
return $pages->sortBy('path'); return $pages->sortBy('relativePath');
} }
} }

View File

@ -0,0 +1,16 @@
<?php
namespace Formwork\Pages;
use Formwork\Data\Pagination as BasePagination;
use Formwork\Pages\Traits\PaginationUri;
class Pagination extends BasePagination
{
use PaginationUri;
public function __construct(PageCollection $collection, int $length)
{
parent::__construct($collection, $length);
}
}

View File

@ -1,44 +1,69 @@
<?php <?php
namespace Formwork; namespace Formwork\Pages;
use Formwork\Data\Contracts\Arrayable;
use Formwork\Fields\FieldCollection;
use Formwork\Formwork;
use Formwork\Languages\Languages; use Formwork\Languages\Languages;
use Formwork\Metadata\Metadata; use Formwork\Metadata\MetadataCollection;
use Formwork\Pages\Templates\TemplateCollection;
use Formwork\Pages\Traits\PageData;
use Formwork\Pages\Traits\PageTraversal;
use Formwork\Pages\Traits\PageUid;
use Formwork\Pages\Traits\PageUri;
use Formwork\Schemes\Scheme;
use Formwork\Utils\FileSystem; use Formwork\Utils\FileSystem;
use RuntimeException;
class Site extends AbstractPage class Site implements Arrayable
{ {
/** use PageData;
* Array containing all loaded pages use PageTraversal;
*/ use PageUid;
use PageUri;
protected string $relativePath;
protected string $route;
protected int $lastModifiedTime;
protected array $storage = []; protected array $storage = [];
/**
* Current page
*/
protected ?Page $currentPage = null; protected ?Page $currentPage = null;
/**
* Array containing all available templates
*/
protected array $templates = [];
/**
* Site languages
*/
protected Languages $languages; protected Languages $languages;
protected Scheme $scheme;
protected FieldCollection $fields;
protected TemplateCollection $templates;
protected array $aliases;
protected MetadataCollection $metadata;
/** /**
* Create a new Site instance * Create a new Site instance
*/ */
public function __construct(array $data) public function __construct(array $data)
{ {
$this->path = FileSystem::normalizePath(Formwork::instance()->config()->get('content.path')); $this->path = FileSystem::normalizePath(Formwork::instance()->config()->get('content.path'));
$this->relativePath = DS; $this->relativePath = DS;
$this->route = '/'; $this->route = '/';
$this->scheme = Formwork::instance()->schemes()->get('config', 'site');
$this->data = array_replace_recursive($this->defaults(), $data); $this->data = array_replace_recursive($this->defaults(), $data);
$this->loadTemplates();
$this->fields = $this->scheme->fields()->validate($this->data);
$this->templates = TemplateCollection::fromPath(Formwork::instance()->config()->get('templates.path'));
$this->loadAliases();
} }
/** /**
@ -46,38 +71,21 @@ class Site extends AbstractPage
*/ */
public function defaults(): array public function defaults(): array
{ {
// Formwork::instance()->schemes()->get('config', 'site')->fields();
return [ return [
'title' => 'Formwork', 'title' => 'Formwork',
'aliases' => [], 'aliases' => [],
'metadata' => [] 'metadata' => [],
'canonical' => null
]; ];
} }
/** public function lastModifiedTime(): int
* Get all available templates
*/
public function templates(): array
{ {
return array_map('strval', array_keys($this->templates)); if (isset($this->lastModifiedTime)) {
} return $this->lastModifiedTime;
/**
* Return whether a template exists
*/
public function hasTemplate(string $template): bool
{
return array_key_exists($template, $this->templates);
}
/**
* Return template filename
*/
public function template(string $name): string
{
if (!$this->hasTemplate($name)) {
throw new RuntimeException(sprintf('Invalid template "%s"', $name));
} }
return $this->templates[$name]; return $this->lastModifiedTime = FileSystem::lastModifiedTime($this->path);
} }
/** /**
@ -88,14 +96,6 @@ class Site extends AbstractPage
return FileSystem::directoryModifiedSince($this->path, $time); return FileSystem::directoryModifiedSince($this->path, $time);
} }
/**
* @inheritdoc
*/
public function parent()
{
return null;
}
/** /**
* Return a PageCollection containing site pages * Return a PageCollection containing site pages
*/ */
@ -109,121 +109,18 @@ class Site extends AbstractPage
*/ */
public function hasPages(): bool public function hasPages(): bool
{ {
return !$this->children()->isEmpty(); return $this->hasChildren();
} }
/** /**
* Return alias of a given route * Retrieve page from the storage creating a new one if not existing
*
* @return string|void
*/ */
public function alias(string $route) public function retrievePage(string $path): Page
{ {
if ($this->has('aliases')) { if (isset($this->storage[$path])) {
$route = trim($route, '/'); return $this->storage[$path];
if (isset($this->data['aliases'][$route])) {
return $this->data['aliases'][$route];
}
} }
} return $this->storage[$path] = new Page($path);
/**
* Set and return site current page
*/
public function setCurrentPage(Page $page): Page
{
return $this->currentPage = $page;
}
/**
* Navigate to and return a page from its route, setting then the current page
*/
public function navigate(string $route): Page
{
return $this->currentPage = $this->findPage($route);
}
/**
* Set site languages
*/
public function setLanguages(Languages $languages): void
{
$this->languages = $languages;
}
/**
* Get site index page
*/
public function indexPage(): ?Page
{
return $this->findPage(Formwork::instance()->config()->get('pages.index'));
}
/**
* Return or render site error page
*
* @param bool $navigate Whether to navigate to the error page or not
*/
public function errorPage(bool $navigate = false): ?Page
{
$errorPage = $this->findPage(Formwork::instance()->config()->get('pages.error'));
if ($navigate) {
$this->currentPage = $errorPage;
}
return $errorPage;
}
/**
* @inheritdoc
*/
public function isSite(): bool
{
return true;
}
/**
* @inheritdoc
*/
public function isIndexPage(): bool
{
return false;
}
/**
* @inheritdoc
*/
public function isErrorPage(): bool
{
return false;
}
/**
* @inheritdoc
*/
public function isDeletable(): bool
{
return false;
}
/**
* @inheritdoc
*/
public function metadata(): Metadata
{
if (isset($this->metadata)) {
return $this->metadata;
}
$defaults = [
'charset' => Formwork::instance()->config()->get('charset'),
'author' => $this->get('author'),
'description' => $this->get('description'),
'generator' => 'Formwork'
];
$data = array_filter(array_merge($defaults, $this->data['metadata']));
if (!Formwork::instance()->config()->get('metadata.set_generator')) {
unset($data['generator']);
}
return $this->metadata = new Metadata($data);
} }
/** /**
@ -258,31 +155,96 @@ class Site extends AbstractPage
} }
/** /**
* Retrieve page from the storage creating a new one if not existing * Set and return site current page
*/ */
public function retrievePage(string $path): Page public function setCurrentPage(Page $page): Page
{ {
if (isset($this->storage[$path])) { return $this->currentPage = $page;
return $this->storage[$path];
}
return $this->storage[$path] = new Page($path);
} }
/** /**
* Load all available templates * Set site languages
*/ */
protected function loadTemplates(): void public function setLanguages(Languages $languages): void
{ {
$templatesPath = Formwork::instance()->config()->get('templates.path'); $this->languages = $languages;
$templates = [];
foreach (FileSystem::listFiles($templatesPath) as $file) {
$templates[FileSystem::name($file)] = $templatesPath . $file;
}
$this->templates = $templates;
} }
public function __toString(): string /**
* Return alias of a given route
*/
public function resolveAlias(string $route): ?string
{ {
return 'site'; return $this->aliases[$route] ?? null;
}
public function metadata(): MetadataCollection
{
if (isset($this->metadata)) {
return $this->metadata;
}
$defaults = [
'charset' => Formwork::instance()->config()->get('charset'),
'author' => $this->get('author'),
'description' => $this->get('description'),
'generator' => 'Formwork'
];
$data = array_filter(array_merge($defaults, $this->data['metadata']));
if (!Formwork::instance()->config()->get('metadata.set_generator')) {
unset($data['generator']);
}
return $this->metadata = new MetadataCollection($data);
}
/**
* Get site index page
*/
public function indexPage(): ?Page
{
return $this->findPage(Formwork::instance()->config()->get('pages.index'));
}
/**
* Return or render site error page
*/
public function errorPage(): ?Page
{
return $this->findPage(Formwork::instance()->config()->get('pages.error'));
}
public function isSite(): bool
{
return true;
}
public function isIndexPage(): bool
{
return false;
}
public function isErrorPage(): bool
{
return false;
}
public function isDeletable(): bool
{
return false;
}
protected function loadAliases(): void
{
foreach ($this->data['aliases'] as $from => $to) {
$this->aliases[trim($from, '/')] = trim($to, '/');
}
}
public function __toString()
{
return $this->title();
} }
} }

View File

@ -1,12 +1,11 @@
<?php <?php
namespace Formwork\Template; namespace Formwork\Pages\Templates;
use Formwork\Assets; use Formwork\Assets;
use Formwork\Formwork; use Formwork\Formwork;
use Formwork\Page; use Formwork\Pages\Page;
use Formwork\Utils\FileSystem; use Formwork\Utils\FileSystem;
use Formwork\Utils\Str;
use Formwork\View\Renderer; use Formwork\View\Renderer;
use Formwork\View\View; use Formwork\View\View;
@ -33,7 +32,7 @@ class Template extends View
public function __construct(string $name, Page $page) public function __construct(string $name, Page $page)
{ {
$this->page = $page; $this->page = $page;
parent::__construct($name, [], Formwork::instance()->config()->get('templates.path')); parent::__construct($name, [], Formwork::instance()->config()->get('templates.path'), $this->defaultMethods());
} }
/** /**
@ -50,22 +49,10 @@ class Template extends View
); );
} }
/**
* Insert a template
*/
public function insert(string $name, array $vars = []): void
{
if (Str::startsWith($name, '_')) {
$name = 'partials' . DS . Str::removeStart($name, '_');
}
parent::insert($name, $vars);
}
/** /**
* Render template * Render template
*/ */
public function render(bool $return = false) public function render(): string
{ {
$isCurrentPage = $this->page->isCurrent(); $isCurrentPage = $this->page->isCurrent();
@ -73,10 +60,10 @@ class Template extends View
// Render correct page if the controller has changed the current one // Render correct page if the controller has changed the current one
if ($isCurrentPage && !$this->page->isCurrent()) { if ($isCurrentPage && !$this->page->isCurrent()) {
return Formwork::instance()->site()->currentPage()->template()->render($return); return Formwork::instance()->site()->currentPage()->render();
} }
return parent::render($return); return parent::render();
} }
/** /**
@ -92,11 +79,13 @@ class Template extends View
} }
/** /**
* @inheritdoc * Default template methods
*/ */
protected function createLayoutView(string $name): View protected function defaultMethods(): array
{ {
return new static('layouts' . DS . $name, $this->page); return [
'assets' => fn () => $this->assets()
];
} }
/** /**
@ -107,7 +96,9 @@ class Template extends View
$controllerFile = $this->path . 'controllers' . DS . $this->name . '.php'; $controllerFile = $this->path . 'controllers' . DS . $this->name . '.php';
if (FileSystem::exists($controllerFile)) { if (FileSystem::exists($controllerFile)) {
$this->allowMethods = true;
$this->vars = array_merge($this->vars, (array) Renderer::load($controllerFile, $this->vars, $this)); $this->vars = array_merge($this->vars, (array) Renderer::load($controllerFile, $this->vars, $this));
$this->allowMethods = false;
} }
} }

View File

@ -0,0 +1,35 @@
<?php
namespace Formwork\Pages\Templates;
use Formwork\Data\AbstractCollection;
use Formwork\Utils\FileSystem;
class TemplateCollection extends AbstractCollection
{
protected bool $associative = true;
protected ?string $dataType = Template::class;
public function load(string $path): void
{
if (FileSystem::isReadable($path) && FileSystem::extension($path) === 'php') {
$name = FileSystem::name($path);
$this->data[$name] = $path;
}
}
public function loadFromPath(string $path): void
{
foreach (FileSystem::listFiles($path) as $file) {
$this->load(FileSystem::joinPaths($path, $file));
}
}
public static function fromPath(string $path): self
{
$instance = new static();
$instance->loadFromPath($path);
return $instance;
}
}

View File

@ -0,0 +1,79 @@
<?php
namespace Formwork\Pages\Traits;
use BadMethodCallException;
use Formwork\Data\Traits\DataMultipleGetter;
use Formwork\Data\Traits\DataMultipleSetter;
use Formwork\Utils\Arr;
trait PageData
{
use DataMultipleGetter;
use DataMultipleSetter;
public function has(string $key): bool
{
return property_exists($this, $key) || $this->fields->has($key) || Arr::has($this->data, $key);
}
public function get(string $key, $default = null)
{
// Get values from property
if (property_exists($this, $key)) {
// Call getter method if exists. We check property existence before
// to avoid using get to call methods arbitrarily
if (method_exists($this, $key)) {
return $this->$key();
}
return $this->$key;
}
// Get values from fields
if ($this->fields->has($key)) {
$field = $this->fields->get($key);
// If defined use the value returned by `return()`
if ($field->hasMethod('return')) {
return $field->return();
}
return $field->value();
}
// Get values from data
return Arr::get($this->data, $key, $default);
}
public function set(string $key, $value): void
{
if (property_exists($this, $key)) {
$this->$key = $value;
} else {
Arr::set($this->data, $key, $value);
}
}
public function toArray(): array
{
$properties = array_keys(get_class_vars($this::class));
Arr::pull($properties, 'data');
$data = array_merge($this->data, $this->getMultiple($properties));
ksort($data);
return $data;
}
public function __call(string $name, array $arguments)
{
if ($this->has($name)) {
return $this->get($name);
}
throw new BadMethodCallException(sprintf('Call to undefined method %s::%s()', static::class, $name));
}
}

View File

@ -0,0 +1,44 @@
<?php
namespace Formwork\Pages\Traits;
use Formwork\Pages\Page;
use Formwork\Utils\Date;
trait PageStatus
{
/**
* Page status
*/
protected string $status;
/**
* Get page status
*/
public function status(): string
{
if (isset($this->status)) {
return $this->status;
}
$published = $this->data['published'];
$now = time();
if ($publishDate = $this->data['publishDate'] ?? null) {
$published = $published && Date::toTimestamp($publishDate) < $now;
}
if ($unpublishDate = $this->data['unpublishDate'] ?? null) {
$published = $published && Date::toTimestamp($unpublishDate) > $now;
}
$this->status = match (true) {
$published => Page::PAGE_STATUS_PUBLISHED,
!$this->routable() => Page::PAGE_STATUS_NOT_ROUTABLE,
!$published => Page::PAGE_STATUS_NOT_PUBLISHED
};
return $this->status;
}
}

View File

@ -0,0 +1,178 @@
<?php
namespace Formwork\Pages\Traits;
use Formwork\Formwork;
use Formwork\Pages\Page;
use Formwork\Pages\PageCollection;
use Formwork\Pages\Site;
trait PageTraversal
{
protected string $path;
protected Page|Site|null $parent;
protected PageCollection $children;
protected PageCollection $descendants;
protected PageCollection $ancestors;
protected PageCollection $siblings;
protected PageCollection $inclusiveSiblings;
abstract public function isSite(): bool;
public function parent(): Page|Site|null
{
if (isset($this->parent)) {
return $this->parent;
}
if ($this->isSite()) {
return $this->parent = null;
}
$parentPath = dirname($this->path) . DS;
if ($parentPath === Formwork::instance()->config()->get('content.path')) {
return $this->parent = Formwork::instance()->site();
}
return $this->parent = Formwork::instance()->site()->retrievePage($parentPath);
}
public function hasParent(): bool
{
return $this->parent() !== null;
}
public function isParentOf(Page|Site $page): bool
{
return $page->parent() === $page;
}
public function children(): PageCollection
{
if (isset($this->children)) {
return $this->children;
}
return $this->children = PageCollection::fromPath($this->path);
}
public function hasChildren(): bool
{
return !$this->children()->isEmpty();
}
public function isChildOf(Page|Site $page): bool
{
return $page->children()->contains($this);
}
public function descendants(): PageCollection
{
if (isset($this->descendants)) {
return $this->descendants;
}
return $this->descendants = PageCollection::fromPath($this->path, recursive: true);
}
public function hasDescendants(): bool
{
return !$this->descendants()->isEmpty();
}
public function isDescendantOf(Page|Site $page): bool
{
return $page->descendants()->contains($this);
}
public function ancestors(): PageCollection
{
if (isset($this->ancestors)) {
return $this->ancestors;
}
$ancestors = [];
$page = $this;
while (($parent = $page->parent()) !== null) {
$ancestors[] = $parent;
$page = $parent;
}
return $this->ancestors = new PageCollection($ancestors);
}
public function hasAncestors(): bool
{
return !$this->ancestors()->isEmpty();
}
public function isAncestorOf(Page|Site $page): bool
{
return $page->ancestors()->contains($this);
}
public function siblings(): PageCollection
{
if (isset($this->siblings)) {
return $this->siblings;
}
if ($this->isSite()) {
return $this->siblings = new PageCollection();
}
return $this->siblings = $this->inclusiveSiblings()->without($this);
}
public function inclusiveSiblings(): PageCollection
{
if (isset($this->inclusiveSiblings)) {
return $this->inclusiveSiblings;
}
if ($this->isSite()) {
return $this->inclusiveSiblings = new PageCollection([$this]);
}
return $this->inclusiveSiblings = $this->parent()->children();
}
public function hasSiblings(): bool
{
return !$this->siblings()->isEmpty();
}
public function isSiblingOf(Page|Site $page): bool
{
return !$page->siblings()->contains($this);
}
public function previousSibling(): ?Page
{
return $this->inclusiveSiblings()->nth($this->index() - 1);
}
public function nextSibling(): ?Page
{
return $this->inclusiveSiblings()->nth($this->index() + 1);
}
public function index(): int
{
return $this->inclusiveSiblings()->indexOf($this);
}
public function level(): int
{
return $this->ancestors()->count();
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Formwork\Pages\Traits;
use Formwork\Utils\Str;
trait PageUid
{
protected string $uid;
public function uid(): string
{
if (isset($this->uid)) {
return $this->uid;
}
return $this->uid = Str::chunk(substr(hash('sha256', $this->relativePath), 0, 32), 8, '-');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Formwork\Pages\Traits;
use Formwork\Formwork;
use Formwork\Utils\HTTPRequest;
use Formwork\Utils\Path;
use Formwork\Utils\Uri;
trait PageUri
{
protected string $route;
protected string $absoluteUri;
/**
* Return a URI relative to page
*/
public function uri(string $path = '', bool|string $includeLanguage = true): string
{
$base = HTTPRequest::root();
$route = $this->canonical() ?? $this->route;
if ($includeLanguage) {
$language = is_string($includeLanguage) ? $includeLanguage : Formwork::instance()->site()->languages()->current();
$default = Formwork::instance()->site()->languages()->default();
$preferred = Formwork::instance()->site()->languages()->preferred();
if (($language !== null && $language !== $default) || ($preferred !== null && $preferred !== $default)) {
return Path::join([$base, $language, $route, $path]);
}
}
return Path::join([$base, $route, $path]);
}
/**
* Get page absolute URI
*/
public function absoluteUri(): string
{
if (isset($this->absoluteUri)) {
return $this->absoluteUri;
}
return $this->absoluteUri = Uri::resolveRelative($this->uri());
}
}

View File

@ -0,0 +1,126 @@
<?php
namespace Formwork\Pages\Traits;
use Formwork\Formwork;
use Formwork\Router\Route;
use Formwork\Utils\Str;
use RuntimeException;
use UnexpectedValueException;
trait PaginationUri
{
protected static string $routeParam = 'paginationPage';
protected static string $routeSuffix = '.pagination';
protected Route $baseRoute;
protected Route $paginationRoute;
public function route(int $pageNumber): string
{
if (!$this->has($pageNumber)) {
throw new UnexpectedValueException(sprintf('Cannot get the route for page %d, the pagination has only %d pages', $pageNumber, $this->length));
}
$router = Formwork::instance()->router();
if ($pageNumber === 1) {
return $router->generateWith($this->baseRoute()->getName(), []);
}
return $router->generateWith($this->paginationRoute()->getName(), [
static::$routeParam => $pageNumber
]);
}
public function uri(int $pageNumber): string
{
return Formwork::instance()->site()->uri($this->route($pageNumber));
}
public function firstPageRoute(): string
{
return $this->route($this->firstPage());
}
public function firstPageUri(): string
{
return $this->uri($this->firstPage());
}
public function lastPageRoute(): string
{
return $this->route($this->lastPage());
}
public function lastPageUri(): string
{
return $this->uri($this->lastPage());
}
/**
* Get the route of the next pagination page
*/
public function previousPageRoute(): string
{
return $this->route($this->previousPage());
}
/**
* Get the URI of the next pagination page
*/
public function previousPageUri(): string
{
return $this->uri($this->previousPage());
}
/**
* Get the route of the next pagination page
*/
public function nextPageRoute(): string
{
return $this->route($this->nextPage());
}
/**
* Get the URI of the next pagination page
*/
public function nextPageUri(): string
{
return $this->uri($this->nextPage());
}
protected function baseRoute(): ?Route
{
if (isset($this->baseRoute)) {
return $this->baseRoute;
}
$router = Formwork::instance()->router();
$routeName = Str::removeEnd($router->current()->getName(), static::$routeSuffix);
if (!$router->routes()->has($routeName)) {
throw new RuntimeException(sprintf('Cannot generate pagination routes, base route "%s" is not defined', $this->routeName));
}
return $this->baseRoute = $router->routes()->get($routeName);
}
protected function paginationRoute(): ?Route
{
if (isset($this->paginationRoute)) {
return $this->paginationRoute;
}
$router = Formwork::instance()->router();
$routeName = $this->baseRoute()->getName() . static::$routeSuffix;
if (!$router->routes()->has($routeName)) {
throw new RuntimeException(sprintf('Cannot generate pagination for route "%s", route "%s" is not defined', $this->baseRoute()->getName(), $routeName));
}
return $this->paginationRoute = $router->routes()->get($routeName);
}
}

View File

@ -1,182 +0,0 @@
<?php
namespace Formwork;
use Formwork\Utils\Header;
use Formwork\Utils\Uri;
class Pagination
{
/**
* Number of all items to paginate
*/
protected int $count = 0;
/**
* Number of items in each pagination page
*/
protected int $length = 0;
/**
* Number of pagination pages
*/
protected int $pages = 0;
/**
* Base URI to which append pagination page number
*/
protected string $baseUri;
/**
* Current pagination page
*/
protected int $currentPage = 1;
/**
* Create a new Pagination instance
*/
public function __construct(int $count, int $length)
{
$router = Formwork::instance()->router();
$this->count = $count;
$this->length = $length;
$this->pages = $count > 0 ? (int) ceil($count / $length) : 1;
$this->baseUri = Formwork::instance()->site()->uri(preg_replace('~/page/[0-9]+/?$~', '/', $router->request()));
$this->currentPage = (int) $router->params()->get('paginationPage', 1);
if ($router->params()->get('paginationPage') == 1) {
Header::redirect($this->baseUri, 301);
}
if ($this->currentPage > $this->pages || $this->currentPage < 1) {
Formwork::instance()->site()->errorPage(true);
}
}
/**
* Get current page
*/
public function currentPage(): int
{
return $this->currentPage;
}
/**
* Get pagination length
*/
public function length(): int
{
return $this->length;
}
/**
* Get current pagination offset
*/
public function offset(): int
{
return ($this->currentPage - 1) * $this->length;
}
/**
* Return whether a given page number exists
*/
public function hasPage(int $number): bool
{
return (int) $number > 1 && (int) $number <= $this->pages;
}
/**
* Return whether current page is the first
*/
public function firstPage(): bool
{
return $this->currentPage === 1;
}
/**
* Return whether current page is the last
*/
public function lastPage(): bool
{
return $this->currentPage === $this->pages;
}
/**
* Return whether pagination has more than one page
*/
public function hasPages(): bool
{
return $this->pages > 1;
}
/**
* Return whether a previous page exists
*/
public function hasPreviousPage(): bool
{
return !$this->firstPage();
}
/**
* Return whether a next page exists
*/
public function hasNextPage(): bool
{
return !$this->lastPage();
}
/**
* Get previous pagination page number
*
* @return bool|int
*/
public function previousPage()
{
$previous = $this->currentPage - 1;
return $previous > 0 ? $previous : false;
}
/**
* Get next pagination page number
*
* @return bool|int
*/
public function nextPage()
{
$next = $this->currentPage + 1;
return $next > $this->pages ? false : $next;
}
/**
* Get the URI of the next pagination page
*/
public function nextPageUri(): string
{
return Uri::make(['host' => '', 'path' => $this->baseUri . 'page/' . $this->nextPage()]);
}
/**
* Get the URI of the previous pagination page
*/
public function previousPageUri(): string
{
if ($this->previousPage() === 1) {
return Uri::make(['host' => '', 'path' => $this->baseUri]);
}
return Uri::make(['host' => '', 'path' => $this->baseUri . 'page/' . $this->previousPage()]);
}
public function __debugInfo(): array
{
return [
'count' => $this->count,
'length' => $this->length,
'pages' => $this->pages,
'currentPage' => $this->currentPage,
'previousPage' => $this->previousPageUri()
];
}
}

View File

@ -3,9 +3,12 @@
namespace Formwork\Router; namespace Formwork\Router;
use Formwork\Data\Traits\DataGetter; use Formwork\Data\Traits\DataGetter;
use Formwork\Data\Contracts\Arrayable;
use Formwork\Data\Traits\DataArrayable;
class RouteParams class RouteParams implements Arrayable
{ {
use DataArrayable;
use DataGetter; use DataGetter;
public function __construct(array $data) public function __construct(array $data)

View File

@ -72,7 +72,9 @@ class Router
{ {
$this->routes = new RouteCollection(); $this->routes = new RouteCollection();
$this->filters = new RouteFilterCollection(); $this->filters = new RouteFilterCollection();
$this->request = $request ?? Str::wrap(Uri::path(HTTPRequest::uri()), '/');
// Ensure requested route is wrapped in slashes
$this->request = Str::wrap($request ?? Uri::path(HTTPRequest::uri()), '/');
} }
/** /**
@ -188,6 +190,14 @@ class Router
return $this->generateRoute($this->routes->get($name), $params); return $this->generateRoute($this->routes->get($name), $params);
} }
/**
* Generate a route with given params overriding the current ones
*/
public function generateWith(string $name, array $params): string
{
return $this->generateRoute($this->routes->get($name), $params + $this->params->toArray());
}
/** /**
* Rewrite current route with given params * Rewrite current route with given params
*/ */

View File

@ -5,6 +5,8 @@ namespace Formwork\Schemes;
use Formwork\Data\Contracts\Arrayable; use Formwork\Data\Contracts\Arrayable;
use Formwork\Data\Traits\DataArrayable; use Formwork\Data\Traits\DataArrayable;
use Formwork\Data\Traits\DataGetter; use Formwork\Data\Traits\DataGetter;
use Formwork\Fields\FieldCollection;
use Formwork\Fields\Layout\Layout;
use Formwork\Formwork; use Formwork\Formwork;
use Formwork\Parsers\YAML; use Formwork\Parsers\YAML;
use Formwork\Utils\FileSystem; use Formwork\Utils\FileSystem;
@ -69,30 +71,11 @@ class Scheme implements Arrayable
/** /**
* Get scheme fields * Get scheme fields
*/ */
public function fields(): array public function fields(): FieldCollection
{ {
return $this->get('fields', []); return new FieldCollection(
} $this->get('fields', []),
new Layout($this->get('layout', ['type' => 'default', 'sections' => []]))
/** );
* Get scheme data
*/
public function data(): array
{
return $this->get('data', []);
}
/**
* Return default field values
*/
public function defaultFieldValues(): array
{
$result = [];
foreach ($this->fields() as $name => $value) {
if (isset($value['default'])) {
$result[$name] = $value['default'];
}
}
return $result;
} }
} }

View File

@ -12,6 +12,8 @@ class Schemes
*/ */
protected array $storage = []; protected array $storage = [];
protected array $data = [];
/** /**
* Load a scheme * Load a scheme
*/ */

View File

@ -14,6 +14,8 @@ class Translations
*/ */
protected array $storage = []; protected array $storage = [];
protected array $data = [];
/** /**
* Current translation * Current translation
*/ */

View File

@ -195,5 +195,4 @@ class Str
{ {
return implode($delimiter, str_split($string, $length)); return implode($delimiter, str_split($string, $length));
} }
} }

View File

@ -1,91 +1,46 @@
title: Site title: Site
layout:
type: sections
sections:
info:
label: '{{admin.options.site.info}}'
collapsible: true
fields: [title, author, description]
advanced:
label: '{{admin.options.site.advanced}}'
collapsible: true
collapsed: true
fields: [metadata, aliases]
fields: fields:
section1: title:
type: header type: text
label: '{{admin.options.site.info}}' label: '{{admin.options.site.info.title}}'
required: true
rows1: author:
type: rows type: text
fields: label: '{{admin.options.site.info.author}}'
row1: default: null
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.site.info.title}}'
column2:
type: column
width: 2-3
fields:
title:
type: text
required: true
row2:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.site.info.author}}'
column2:
type: column
width: 2-3
fields:
author:
type: text
row3: description:
type: row type: textarea
fields: label: '{{admin.options.site.info.description}}'
column1: default: null
type: column
width: 1-3
label: '{{admin.options.site.info.description}}'
column2:
type: column
width: 2-3
fields:
description:
type: textarea
section2: metadata:
type: header type: array
label: '{{admin.options.site.advanced}}' label: '{{admin.options.site.advanced.metadata}}'
associative: true
placeholder_key: '{{admin.options.site.advanced.metadata.name}}'
placeholder_value: '{{admin.options.site.advanced.metadata.content}}'
rows2: aliases:
type: rows type: array
fields: label: '{{admin.options.site.advanced.aliases}}'
row1: associative: true
type: row placeholder_key: '{{admin.options.site.advanced.aliases.alias}}'
fields: placeholder_value: '{{admin.options.site.advanced.aliases.route}}'
column1:
type: column
width: 1-3
label: '{{admin.options.site.advanced.metadata}}'
column2:
type: column
width: 2-3
fields:
metadata:
type: array
associative: true
placeholder_key: '{{admin.options.site.advanced.metadata.name}}'
placeholder_value: '{{admin.options.site.advanced.metadata.content}}'
row2:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.site.advanced.aliases}}'
column2:
type: column
width: 2-3
fields:
aliases:
type: array
associative: true
placeholder_key: '{{admin.options.site.advanced.aliases.alias}}'
placeholder_value: '{{admin.options.site.advanced.aliases.route}}'

View File

@ -1,364 +1,176 @@
title: System title: System
layout:
type: sections
sections:
dateTime:
collapsible: true
label: '{{admin.options.system.date-and-time}}'
fields: [date.format, date.time_format, date.timezone, date.week_starts]
languages:
collapsible: true
label: '{{admin.options.system.languages}}'
fields: [languages.available, languages.http_preferred]
files:
collapsible: true
label: '{{admin.options.system.files}}'
fields: [files.allowed_extensions]
cache:
collapsible: true
label: '{{admin.options.system.cache}}'
fields: [cache.enabled, cache.time]
admin:
collapsible: true
label: '{{admin.options.system.admin-panel}}'
fields: [admin.lang, admin.logout_redirect, admin.session_timeout, admin.color_scheme]
images:
collapsible: true
label: '{{admin.options.system.images}}'
fields: [images.jpeg_quality, images.png_compression, images.webp_quality, images.jpeg_progressive, images.process_uploads]
backup:
collapsible: true
label: '{{admin.options.system.backup}}'
fields: [backup.max_files]
fields: fields:
section1: date.format:
type: header type: select
label: '{{admin.options.system.date-and-time}}' label: '{{admin.options.system.date-and-time.date-format}}'
import:
options: 'Formwork\Admin\Utils\DateFormats::date'
rows1: date.time_format:
type: rows type: select
fields: label: '{{admin.options.system.date-and-time.hour-format}}'
row1: import:
type: row options: 'Formwork\Admin\Utils\DateFormats::hour'
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.date-and-time.date-format}}'
column2:
type: column
width: 2-3
fields:
date.format:
type: select
import:
options: 'Formwork\Admin\Utils\DateFormats::date'
row2:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.date-and-time.hour-format}}'
column2:
type: column
width: 2-3
fields:
date.time_format:
type: select
import:
options: 'Formwork\Admin\Utils\DateFormats::hour'
row3:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.date-and-time.timezone}}'
column2:
type: column
width: 2-3
fields:
date.timezone:
type: select
import:
options: 'Formwork\Admin\Utils\DateFormats::timezones'
row4:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.date-and-time.first-weekday}}'
column2:
type: column
width: 2-3
fields:
date.week_starts:
type: select
options:
0: '{{admin.options.system.date-and-time.first-weekday.sunday}}'
1: '{{admin.options.system.date-and-time.first-weekday.monday}}'
section2: date.timezone:
type: header type: select
label: '{{admin.options.system.languages}}' label: '{{admin.options.system.date-and-time.timezone}}'
import:
options: 'Formwork\Admin\Utils\DateFormats::timezones'
rows2: date.week_starts:
type: rows type: select
fields: label: '{{admin.options.system.date-and-time.first-weekday}}'
row1: options:
type: row 0: '{{admin.options.system.date-and-time.first-weekday.sunday}}'
fields: 1: '{{admin.options.system.date-and-time.first-weekday.monday}}'
column1:
type: column
width: 1-3
label: '{{admin.options.system.languages.available-languages}}'
column2:
type: column
width: 2-3
fields:
languages.available:
type: tags
placeholder: '{{admin.options.system.languages.available-languages.no-languages}}'
pattern: '^[a-z]{2,3}$'
translate: [placeholder]
import:
options: 'Formwork\Languages\LanguageCodes::names'
row2:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.languages.preferred-language}}'
column2:
type: column
width: 2-3
fields:
languages.http_preferred:
type: togglegroup
options:
1: '{{admin.options.system.languages.preferred-language.enabled}}'
0: '{{admin.options.system.languages.preferred-language.disabled}}'
section3: languages.available:
type: header type: tags
label: '{{admin.options.system.files}}' label: '{{admin.options.system.languages.available-languages}}'
placeholder: '{{admin.options.system.languages.available-languages.no-languages}}'
pattern: '^[a-z]{2,3}$'
translate: [label, placeholder]
import:
options: 'Formwork\Languages\LanguageCodes::names'
rows3: languages.http_preferred:
type: rows type: togglegroup
fields: label: '{{admin.options.system.languages.preferred-language}}'
row1: options:
type: row 1: '{{admin.options.system.languages.preferred-language.enabled}}'
fields: 0: '{{admin.options.system.languages.preferred-language.disabled}}'
column1:
type: column
width: 1-3
label: '{{admin.options.system.files.allowed-extensions}}'
column2:
type: column
width: 2-3
fields:
files.allowed_extensions:
type: tags
pattern: '^\.[a-zA-Z0-9]+$'
required: true
section4: files.allowed_extensions:
type: header type: tags
label: '{{admin.options.system.files.allowed-extensions}}'
pattern: '^\.[a-zA-Z0-9]+$'
required: true
cache.enabled:
type: togglegroup
label: '{{admin.options.system.cache}}' label: '{{admin.options.system.cache}}'
options:
1: '{{admin.options.system.cache.enabled}}'
0: '{{admin.options.system.cache.disabled}}'
rows4: cache.time:
type: rows type: duration
fields: label: '{{admin.options.system.cache.time}}'
row1: min: 900
type: row step: 900
fields: intervals: [weeks, days, hours, minutes]
column1: translate: [label]
type: column required: true
width: 1-3
label: '{{admin.options.system.cache}}'
column2:
type: column
width: 2-3
fields:
cache.enabled:
type: togglegroup
options:
1: '{{admin.options.system.cache.enabled}}'
0: '{{admin.options.system.cache.disabled}}'
row2:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.cache.time}}'
column2:
type: column
width: 2-3
fields:
cache.time:
type: duration
min: 900
step: 900
intervals: [weeks, days, hours, minutes]
translate: false
required: true
section5: admin.lang:
type: header type: select
label: '{{admin.options.system.admin-panel}}' label: '{{admin.options.system.admin-panel.default-language}}'
translate: [label]
import:
options: 'Formwork\Admin\Admin::availableTranslations'
rows5: admin.logout_redirect:
type: rows type: togglegroup
fields: label: '{{admin.options.system.admin-panel.logout-redirects-to}}'
row1: options:
type: row login: '{{admin.options.system.admin-panel.logout-redirects-to.login}}'
fields: home: '{{admin.options.system.admin-panel.logout-redirects-to.home}}'
column1:
type: column
width: 1-3
label: '{{admin.options.system.admin-panel.default-language}}'
column2:
type: column
width: 2-3
fields:
admin.lang:
type: select
translate: false
import:
options: 'Formwork\Admin\Admin::availableTranslations'
row2:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.admin-panel.logout-redirects-to}}'
column2:
type: column
width: 2-3
fields:
admin.logout_redirect:
type: togglegroup
options:
login: '{{admin.options.system.admin-panel.logout-redirects-to.login}}'
home: '{{admin.options.system.admin-panel.logout-redirects-to.home}}'
row3:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.admin-panel.session-timeout}}'
column2:
type: column
width: 2-3
fields:
admin.session_timeout:
type: duration
min: 0
unit: minutes
intervals: [hours, minutes]
translate: false
required: true
row4:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.admin-panel.default-color-scheme}}'
column2:
type: column
width: 2-3
fields:
admin.color_scheme:
type: togglegroup
options:
light: '{{admin.options.system.admin-panel.default-color-scheme.light}}'
dark: '{{admin.options.system.admin-panel.default-color-scheme.dark}}'
section6: admin.session_timeout:
type: header type: duration
label: '{{admin.options.system.images}}' label: '{{admin.options.system.admin-panel.session-timeout}}'
min: 0
unit: minutes
intervals: [hours, minutes]
translate: [label]
required: true
rows6: admin.color_scheme:
type: rows type: togglegroup
fields: label: '{{admin.options.system.admin-panel.default-color-scheme}}'
row1: options:
type: row light: '{{admin.options.system.admin-panel.default-color-scheme.light}}'
fields: dark: '{{admin.options.system.admin-panel.default-color-scheme.dark}}'
column1:
type: column
width: 1-3
label: '{{admin.options.system.images.jpeg-quality}}'
column2:
type: column
width: 2-3
fields:
images.jpeg_quality:
type: range
min: 0
max: 100
step: 5
row2:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.images.png-compression-level}}'
column2:
type: column
width: 2-3
fields:
images.png_compression:
type: range
min: 0
max: 9
row3:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.images.webp-quality}}'
column2:
type: column
width: 2-3
fields:
images.webp_quality:
type: range
min: 0
max: 100
step: 5
row4:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.images.jpeg-save-progressive}}'
column2:
type: column
width: 2-3
fields:
images.jpeg_progressive:
type: togglegroup
options:
1: '{{admin.options.system.images.jpeg-save-progressive.enabled}}'
0: '{{admin.options.system.images.jpeg-save-progressive.disabled}}'
row5:
type: row
fields:
column1:
type: column
width: 1-3
label: '{{admin.options.system.images.process-uploads}}'
column2:
type: column
width: 2-3
fields:
images.process_uploads:
type: togglegroup
options:
1: '{{admin.options.system.images.process-uploads.enabled}}'
0: '{{admin.options.system.images.process-uploads.disabled}}'
section7: images.jpeg_quality:
type: header type: range
label: '{{admin.options.system.backup}}' label: '{{admin.options.system.images.jpeg-quality}}'
min: 0
max: 100
step: 5
rows7: images.png_compression:
type: rows type: range
fields: label: '{{admin.options.system.images.png-compression-level}}'
row1: min: 0
type: row max: 9
fields:
column1: images.webp_quality:
type: column type: range
width: 1-3 label: '{{admin.options.system.images.webp-quality}}'
label: '{{admin.options.system.backup.backup-files-to-keep}}' min: 0
column2: max: 100
type: column step: 5
width: 2-3
fields: images.jpeg_progressive:
backup.max_files: type: togglegroup
type: select label: '{{admin.options.system.images.jpeg-save-progressive}}'
options: options:
5: 5 1: '{{admin.options.system.images.jpeg-save-progressive.enabled}}'
10: 10 0: '{{admin.options.system.images.jpeg-save-progressive.disabled}}'
15: 15
20: 20 images.process_uploads:
type: togglegroup
label: '{{admin.options.system.images.process-uploads}}'
options:
1: '{{admin.options.system.images.process-uploads.enabled}}'
0: '{{admin.options.system.images.process-uploads.disabled}}'
backup.max_files:
type: select
label: '{{admin.options.system.backup.backup-files-to-keep}}'
options:
5: 5
10: 10
15: 15
20: 20

View File

@ -0,0 +1,4 @@
author: ''
description: ''
aliases:
/hello-world/: /blog/hello-world/

View File

@ -0,0 +1,4 @@
languages:
available: [it, en]
admin:
logout_redirect: home

View File

@ -1,5 +1,5 @@
--- ---
title: Hello World! title: 'Hello World!'
publish-date: '2018-06-15' publishDate: '2018-06-15 00:00:00'
--- ---
This is an example of blog post. This is an example of blog post.

View File

@ -1,6 +1,6 @@
--- ---
title: 'Another Blog Post' title: 'Another Blog Post'
publish-date: '2018-06-16' publishDate: '2018-06-16 00:00:00'
--- ---
This is the summary of another blog post. This is the summary of another blog post.

View File

@ -1,22 +1,32 @@
title: Blog title: Blog
# Extend the `page` scheme
extend: page extend: page
default: false default: false
# Specify the page type, in this case `listing`, which means it can show children
type: listing type: listing
# This controls which template can have children, their order and other attributes
children: children:
templates: [post, blog] templates: [post, blog]
reverse: true reverse: true
sortable: true orderable: false
layout:
type: sections
sections:
options:
fields: [postsPerPage, published, publishDate, unpublishDate, routable, listed, cacheable]
fields: fields:
tabs: postsPerPage:
fields: type: select
options-section: label: '{{admin.pages.page.posts-per-page}}'
fields: options:
posts-per-page: 5: 5
type: select 10: 10
options: 15: 15
5: 5 20: 20
10: 10 default: 5
15: 15
20: 20
default: 5
label: '{{admin.pages.page.posts-per-page}}'

View File

@ -1,79 +1,91 @@
title: Page title: Page
default: true default: true
layout:
type: sections
sections:
content:
label: '{{admin.pages.content}}'
active: true
fields: [title, content]
options:
collapsible: true
label: '{{admin.pages.options}}'
fields: [published, publishDate, unpublishDate, routable, listed, cacheable]
attributes:
collapsible: true
collapsed: true
label: '{{admin.pages.attributes}}'
fields: [parent, template]
files:
collapsible: true
collapsed: false
label: '{{admin.pages.files}}'
fields: [uploadedFile, files]
fields: fields:
tabs: title:
type: sections type: text
fields: class: input-large
required: true
content-section: content:
type: section type: markdown
label: '{{admin.pages.content}}' label: '{{admin.pages.text}}'
active: true
fields:
title:
type: text
class: input-large
required: true
content:
type: editor
options-section: published:
type: section type: checkbox
collapsible: true label: '{{admin.pages.status.published}}'
collapsed: true default: true
label: '{{admin.pages.options}}'
fields:
published:
type: checkbox
default: true
label: '{{admin.pages.status.published}}'
publish-date:
type: date
default: null
label: '{{admin.pages.page.publish-date}}'
placeholder: '{{admin.pages.page.no-date}}'
unpublish-date:
type: date
default: null
label: '{{admin.pages.page.unpublish-date}}'
placeholder: '{{admin.pages.page.no-date}}'
routable:
type: checkbox
default: true
label: '{{admin.pages.status.routable}}'
visible:
type: checkbox
default: true
label: '{{admin.pages.page.visible}}'
cacheable:
type: checkbox
default: true
label: '{{admin.pages.page.cacheable}}'
attributes-section: publishDate:
type: section type: date
collapsible: true label: '{{admin.pages.page.publish-date}}'
collapsed: true placeholder: '{{admin.pages.page.no-date}}'
label: '{{admin.pages.attributes}}' default: null
fields:
parent:
type: page-parents
label: '{{admin.pages.parent}}'
template:
type: page.template
label: '{{admin.pages.template}}'
unpublishDate:
type: date
label: '{{admin.pages.page.unpublish-date}}'
placeholder: '{{admin.pages.page.no-date}}'
default: null
files-section: routable:
type: section type: checkbox
collapsible: true label: '{{admin.pages.status.routable}}'
collapsed: false default: true
label: '{{admin.pages.files}}'
fields: listed:
uploaded-file: type: checkbox
type: file label: '{{admin.pages.page.listed}}'
auto-upload: true default: true
multiple: true
files: cacheable:
type: page-files type: checkbox
label: '{{admin.pages.page.cacheable}}'
default: true
parent:
type: page.parents
access: panel
label: '{{admin.pages.parent}}'
template:
type: page.template
access: panel
label: '{{admin.pages.template}}'
uploadedFile:
type: file
access: panel
auto-upload: true
multiple: true
files:
type: page.files
access: panel

Some files were not shown because too many files have changed in this diff Show More