Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This is especially useful for creating short, memorable links for marketing camp
- Complete Control: Break free from the CMS's predetermined URL paths and create addresses that best serve your content and your audience.
- Hit Count: See how many times an alias is being visited (including the date/time of the last visit)
- Test Interface: You can easily test if an alias works as expected (also with browser languages that differ from your default one)
- Support for Fragment Identifiers: When the alias target is a page, you can specify the exact point where the browser should land (for example, `/target#point-in-page`)

### How It Works

Expand Down
2 changes: 1 addition & 1 deletion controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Controller extends Package
{
protected $pkgHandle = 'url_aliases';

protected $pkgVersion = '0.0.3';
protected $pkgVersion = '0.0.4';

/**
* {@inheritdoc}
Expand Down
22 changes: 22 additions & 0 deletions controllers/single_page/dashboard/system/url_aliases.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public function saveUrlAlias(): JsonResponse
->setQuerystring($this->normalizeQuerystring($post->get('querystring')))
->setTargetType($targetType)
->setTargetValue($targetValue)
->setFragmentIdentifier($this->normalizeFragmentIdentifier($post->get('fragmentIdentifier')))
->setEnabled($post->getBoolean('enabled'))
->setAcceptAdditionalQuerystringParams($post->getBoolean('acceptAdditionalQuerystringParams'))
->setForwardQuerystringParams($post->getBoolean('forwardQuerystringParams'))
Expand Down Expand Up @@ -198,6 +199,7 @@ public function saveLocalizedTarget(): JsonResponse
->setTerritory(trim($post->get('territory', '')))
->setTargetType($targetType)
->setTargetValue($targetValue)
->setFragmentIdentifier($this->normalizeFragmentIdentifier($post->get('fragmentIdentifier')))
;
if ($localizedTarget->getLanguage() === '') {
throw new UserMessageException(t('Please specify the language'));
Expand Down Expand Up @@ -269,6 +271,7 @@ private function serializeUrlAlias(UrlAlias $urlAlias, array $services): array
'enabled' => $urlAlias->isEnabled(),
'targetType' => $urlAlias->getTargetType(),
'targetValue' => $urlAlias->getTargetValue(),
'fragmentIdentifier' => $urlAlias->getFragmentIdentifier(),
'forwardQuerystringParams' => $urlAlias->isForwardQuerystringParams(),
'firstHit' => ($d = $urlAlias->getFirstHit()) === null ? null : $d->getTimestamp(),
'lastHit' => ($d = $urlAlias->getLastHit()) === null ? null : $d->getTimestamp(),
Expand All @@ -294,6 +297,7 @@ private function serializeLocalizedTarget(UrlAlias\LocalizedTarget $localizedTar
'territory' => $localizedTarget->getTerritory(),
'targetType' => $localizedTarget->getTargetType(),
'targetValue' => $localizedTarget->getTargetValue(),
'fragmentIdentifier' => $localizedTarget->getFragmentIdentifier(),
'targetInfo' => $services['targetResolver']->resolve($localizedTarget),
];
}
Expand Down Expand Up @@ -416,6 +420,24 @@ private function normalizeTargetExternalUrl($raw): string
return $externalUrl;
}

/**
* @param string|mixed $raw
*/
private function normalizeFragmentIdentifier($raw): string
{
if (!is_string($raw) || ($raw = trim($raw)) === '' || $raw === '#') {
return '';
}
if ($raw[0] === '#') {
$raw = substr($raw, 1);
}
if (!preg_match('/^[A-Za-z][A-Za-z0-9\-_:.]*$/', $raw)) {
throw new UserMessageException(t('The name of an anchor must start with a letter, and may be followed by any number of letters, digits, hyphens, underscores, colons, and periods.'));
}

return $raw;
}

private function getAcceptLanguageDictionaries(): array
{
$result = [
Expand Down
18 changes: 9 additions & 9 deletions single_pages/dashboard/system/url-aliases.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,23 @@
ob_start();
?>
<div class="row">
<div class="col-4">
<div class="col-sm-4">
<div class="form-group">
<label v-bind:for="`${idPrefix}-language`" class="form-label"><?= t('Language') ?></label>
<div class="input-group">
<select class="form-control form-control-sm" v-bind:id="`${idPrefix}-language`" v-bind:value="language" v-on:change="$emit('update-language', $event.target.value)" v-bind:disabled="disabled">
<div class="input-group input-group-sm">
<select class="form-control" v-bind:id="`${idPrefix}-language`" v-bind:value="language" v-on:change="$emit('update-language', $event.target.value)" v-bind:disabled="disabled">
<option v-if="language === ''" value="">** <?= t('Please Select') ?> **</option>
<option v-for="l in DICTIONARY.LANGUAGES" v-bind:key="l.code" v-bind:value="l.code">{{ l.name }}</option>
</select>
<span class="input-group-addon input-group-text" v-if="language !== ''"><code>{{ language }}</code></span>
</div>
</div>
</div>
<div class="col-4">
<div class="col-sm-4">
<div class="form-group">
<label v-bind:for="`${idPrefix}-script`" class="form-label"><?= t('Script') ?></label>
<div class="input-group">
<select class="form-control form-control-sm" v-bind:id="`${idPrefix}-script`" v-bind:value="script" v-on:input="$emit('update-script', $event.target.value)" v-bind:disabled="disabled">
<div class="input-group input-group-sm">
<select class="form-control" v-bind:id="`${idPrefix}-script`" v-bind:value="script" v-on:input="$emit('update-script', $event.target.value)" v-bind:disabled="disabled">
<option v-if="allowAny" value="*">** <?= tc('Script', 'Any') ?> **</option>
<option value="">** <?= tc('Script', 'None') ?> **</option>
<option v-for="s in DICTIONARY.SCRIPTS" v-bind:key="s.code" v-bind:value="s.code">{{ s.name }}</option>
Expand All @@ -45,11 +45,11 @@
</div>
</div>
</div>
<div class="col-4">
<div class="col-sm-4">
<div class="form-group">
<label v-bind:for="`${idPrefix}-territory`" class="form-label"><?= t('Territory') ?></label>
<div class="input-group">
<select class="form-control form-control-sm" v-bind:id="`${idPrefix}-territory`" v-bind:value="territory" v-on:input="$emit('update-territory', $event.target.value)" v-bind:disabled="disabled">
<div class="input-group input-group-sm">
<select class="form-control" v-bind:id="`${idPrefix}-territory`" v-bind:value="territory" v-on:input="$emit('update-territory', $event.target.value)" v-bind:disabled="disabled">
<option v-if="allowAny" value="*">** <?= tc('Territory', 'Any') ?> **</option>
<option value="">** <?= tc('Territory', 'None') ?> **</option>
<optgroup v-for="c in DICTIONARY.CONTINENTS" v-bind:key="c.name" v-bind:label="c.name">
Expand Down
5 changes: 5 additions & 0 deletions src/Concrete/Entity/Target.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ public function getTargetValue(): string;
* Forward querystring parameters?
*/
public function isForwardQuerystringParams(): bool;

/**
* Get the fragment identifier to be appended to page targets.
*/
public function getFragmentIdentifier(): string;
}
36 changes: 36 additions & 0 deletions src/Concrete/Entity/UrlAlias.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,29 @@ class UrlAlias implements Target
* The target type (see the Target::TARGETTYPE constants).
*
* @Doctrine\ORM\Mapping\Column(type="string", length=20, nullable=false, options={"comment":"Target type"})
*
* @var string
*/
protected $targetType;

/**
* The target value.
*
* @Doctrine\ORM\Mapping\Column(type="text", nullable=false, options={"comment":"Target value"})
*
* @var string
*/
protected $targetValue;

/**
* The fragment identifier to be appended to page targets.
*
* @Doctrine\ORM\Mapping\Column(type="string", length=255, nullable=false, options={"comment":"Fragment identifier to be appended to page targets"})
*
* @var string
*/
protected $fragmentIdentifier;

/**
* Forward querystring parameters?
*
Expand Down Expand Up @@ -161,6 +174,7 @@ public function __construct()
$this->enabled = true;
$this->targetType = Target::TARGETTYPE_PAGE;
$this->targetValue = '';
$this->fragmentIdentifier = '';
$this->forwardQuerystringParams = false;
$this->firstHit = null;
$this->lastHit = null;
Expand Down Expand Up @@ -319,6 +333,28 @@ public function setTargetValue(string $value): self
return $this;
}

/**
* Get the fragment identifier to be appended to page targets.
*
* @see \Concrete\Package\UrlAliases\Entity\Target::getFragmentIdentifier()
*/
public function getFragmentIdentifier(): string
{
return $this->fragmentIdentifier;
}

/**
* Set the fragment identifier to be appended to page targets.
*
* @return $this
*/
public function setFragmentIdentifier(string $value): self
{
$this->fragmentIdentifier = $value;

return $this;
}

/**
* Forward querystring parameters?
*
Expand Down
32 changes: 32 additions & 0 deletions src/Concrete/Entity/UrlAlias/LocalizedTarget.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ class LocalizedTarget implements Target
*/
protected $targetValue;

/**
* The fragment identifier to be appended to page targets.
*
* @Doctrine\ORM\Mapping\Column(type="string", length=255, nullable=false, options={"comment":"Fragment identifier to be appended to page targets"})
*
* @var string
*/
protected $fragmentIdentifier;

public function __construct(UrlAlias $urlAlias)
{
$this->id = null;
Expand All @@ -98,6 +107,7 @@ public function __construct(UrlAlias $urlAlias)
$this->territory = '*';
$this->targetType = Target::TARGETTYPE_PAGE;
$this->targetValue = '';
$this->fragmentIdentifier = '';
}

/**
Expand Down Expand Up @@ -232,6 +242,28 @@ public function setTargetValue(string $value): self
return $this;
}

/**
* Get the fragment identifier to be appended to page targets.
*
* @see \Concrete\Package\UrlAliases\Entity\Target::getFragmentIdentifier()
*/
public function getFragmentIdentifier(): string
{
return $this->fragmentIdentifier;
}

/**
* Set the fragment to be appended to page targets.
*
* @return $this
*/
public function setFragmentIdentifier(string $value): self
{
$this->fragmentIdentifier = $value;

return $this;
}

/**
* Forward querystring parameters?
*
Expand Down
7 changes: 7 additions & 0 deletions src/Concrete/TargetResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,13 @@ private function resolveExternalUrl(Target $target, ?Request $request = null): R

private function finalizeUrl(string $targetUrl, Target $target, ?Request $request): string
{
switch ($target->getTargetType()) {
case Target::TARGETTYPE_PAGE:
if (($fragmentIdentifier = $target->getFragmentIdentifier()) !== '') {
$targetUrl .= '#' . $fragmentIdentifier;
}
break;
}
$components = parse_url($targetUrl);
if ($components === false) {
throw new RuntimeException(t('Failed to parse the URL "%s"', $targetUrl));
Expand Down
35 changes: 34 additions & 1 deletion views/dialogs/edit_localized_target.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@
<label class="form-label"><?= t('Target') ?></label>
<?= $destinationPicker->generate('target', $targetDestinationPickerConfig, $localizedTarget->getTargetType(), $localizedTarget->getTargetValue()) ?>
</div>
<div class="form-group" v-if="askFragmentIdentifier">
<label class="form-label" for="ua-localizedtarget-editing-fragmentidentifier">
<?= t('Point in the page where users should be redirected to') ?>
</label>
<input class="form-control" id="ua-localizedtarget-editing-fragmentidentifier" type="text" maxlength="255" spellcheck="false" v-model.trim="fragmentIdentifier" />
<div class="small text-muted"><?= t('Specify the value after the %s character', '<code>#</code>') ?></div>
</div>
<div class="dialog-buttons">
<button class="btn btn-secondary pull-left" v-on:click.prevent="cancel()"><?= t('Cancel') ?></button>
<?php
Expand Down Expand Up @@ -61,18 +68,43 @@ static function (array $matches) use (&$scripts) {
<script>
(function() {

let myVueApp = null;

function destinationPickerHook()
{
const select = myVueApp?.$el?.querySelector(':scope [name="target__which"]');
if (!select) {
return;
}
switch (select.value) {
case 'page':
myVueApp.askFragmentIdentifier = true;
break;
default:
myVueApp.askFragmentIdentifier = false;
break;
}
}

function ready() {
new Vue({
myVueApp = new Vue({
el: '#ua-localizedtarget-editing',
data() {
return {
language: <?= json_encode($localizedTarget->getLanguage()) ?>,
script: <?= json_encode($localizedTarget->getScript()) ?>,
territory: <?= json_encode($localizedTarget->getTerritory()) ?>,
askFragmentIdentifier: false,
fragmentIdentifier: <?= json_encode($localizedTarget->getFragmentIdentifier()) ?>,
};
},
mounted() {
<?= implode("\n", $scripts) ?>;
this.$el.querySelector(':scope [name="target__which"]').addEventListener('change', destinationPickerHook);
setTimeout(() => destinationPickerHook(), 100);
},
beforeDestroy() {
this.$el.querySelector(':scope [name="target__which"]')?.removeEventListener('change', destinationPickerHook);
},
methods: {
cancel() {
Expand Down Expand Up @@ -106,6 +138,7 @@ function ready() {
script: this.script,
territory: this.territory,
targetType: this.$el.querySelector(':scope [name="target__which"]').value,
fragmentIdentifier: this.fragmentIdentifier,
};
data.targetValue = this.$el.querySelector(`:scope [name="target_${data.targetType}"]`).value;
const ev = new CustomEvent('ccm.url_aliases.saveLocalizedTarget', {
Expand Down
35 changes: 34 additions & 1 deletion views/dialogs/edit_url_alias.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@
<label class="form-label"><?= t('Target') ?></label>
<?= $destinationPicker->generate('target', $targetDestinationPickerConfig, $urlAlias->getTargetType(), $urlAlias->getTargetValue()) ?>
</div>
<div class="form-group" v-if="askFragmentIdentifier">
<label class="form-label" for="ua-urlalias-editing-fragmentidentifier">
<?= t('Point in the page where users should be redirected to') ?>
</label>
<input class="form-control" id="ua-urlalias-editing-fragmentidentifier" type="text" maxlength="255" spellcheck="false" v-model.trim="fragmentIdentifier" />
<div class="small text-muted"><?= t('Specify the value after the %s character', '<code>#</code>') ?></div>
</div>
<div class="form-group">
<label class="form-label"><?= t('Options') ?></label>
<div class="form-check">
Expand Down Expand Up @@ -82,19 +89,44 @@ static function (array $matches) use (&$scripts) {
<script>
(function() {

let myVueApp = null;

function destinationPickerHook()
{
const select = myVueApp?.$el?.querySelector(':scope [name="target__which"]');
if (!select) {
return;
}
switch (select.value) {
case 'page':
myVueApp.askFragmentIdentifier = true;
break;
default:
myVueApp.askFragmentIdentifier = false;
break;
}
}

function ready() {
new Vue({
myVueApp = new Vue({
el: '#ua-urlalias-editing',
data() {
return {
pathAndQuerystring: <?= json_encode($urlAlias->getPathAndQuerystring()) ?>,
acceptAdditionalQuerystringParams: <?= json_encode($urlAlias->isAcceptAdditionalQuerystringParams()) ?>,
forwardQuerystringParams: <?= json_encode($urlAlias->isForwardQuerystringParams()) ?>,
enabled: <?= json_encode($urlAlias->isEnabled()) ?>,
askFragmentIdentifier: false,
fragmentIdentifier: <?= json_encode($urlAlias->getFragmentIdentifier()) ?>,
};
},
mounted() {
<?= implode("\n", $scripts) ?>;
this.$el.querySelector(':scope [name="target__which"]').addEventListener('change', destinationPickerHook);
setTimeout(() => destinationPickerHook(), 100);
},
beforeDestroy() {
this.$el.querySelector(':scope [name="target__which"]')?.removeEventListener('change', destinationPickerHook);
},
computed: {
hasQuerystring() {
Expand Down Expand Up @@ -138,6 +170,7 @@ function ready() {
path: finalPath.path,
querystring: finalPath.querystring,
targetType: this.$el.querySelector(':scope [name="target__which"]').value,
fragmentIdentifier: this.fragmentIdentifier,
acceptAdditionalQuerystringParams: this.acceptAdditionalQuerystringParams,
enabled: this.enabled,
forwardQuerystringParams: this.forwardQuerystringParams,
Expand Down