Skip to content

Commit 1764b3d

Browse files
committed
Fix rich text element links retaining outdated URLs. (#84)
1 parent 90007de commit 1764b3d

3 files changed

Lines changed: 113 additions & 9 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
### Added
66
- Added a **Rich Text Toolbar** field setting to choose the formatting buttons in rich-text cells, with new underline, strikethrough, subscript, superscript and inline code options ([#84](https://github.com/verbb/tablemaker/issues/84)).
77

8+
### Fixed
9+
- Fixed rich-text element links retaining outdated URLs and reference fragments in rendered tables ([#84](https://github.com/verbb/tablemaker/issues/84)).
10+
811
## 5.1.7 - 2026-11-05
912

1013
### Removed

‎src/helpers/TableValue.php‎

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use craft\helpers\HtmlPurifier;
1111
use craft\helpers\Json;
1212
use craft\helpers\StringHelper;
13+
use craft\helpers\UrlHelper;
1314
use craft\validators\ColorValidator;
1415
use craft\validators\UrlValidator;
1516

@@ -877,23 +878,60 @@ private static function renderCellHtml(string $type, mixed $value, ?int $siteId
877878
if ($type === 'richtext') {
878879
$html = self::sanitizeRichText($string);
879880

880-
// CraftLink stores a stable reference fragment beside the current URL.
881-
// Resolve it only for rendered output; raw cell data must retain the tag
882-
// so later URI changes can still be reflected.
883-
if ($siteId !== null) {
884-
$html = Craft::$app->getElements()->parseRefs($html, $siteId);
881+
// CraftLink's URL fragments are not ref tags. Convert them only for
882+
// output so saved cells keep their stable IDs after URI changes.
883+
$html = self::_resolveRichTextLinkRefs($html, $siteId);
884+
$html = Craft::$app->getElements()->parseRefs($html, $siteId);
885885

886-
// Reference attributes are dynamic content, so enforce the same
887-
// rich-text policy after Craft has inserted their current values.
888-
$html = self::sanitizeRichText($html);
889-
}
886+
// Reference attributes are dynamic content, so enforce the same
887+
// rich-text policy after Craft has inserted their current values.
888+
$html = self::sanitizeRichText($html);
890889

891890
return $html;
892891
}
893892

894893
return Html::encode($string);
895894
}
896895

896+
private static function _resolveRichTextLinkRefs(string $html, ?int $defaultSiteId): string
897+
{
898+
$resolved = [];
899+
900+
// Purification has normalized attribute quotes. Match anchor attributes
901+
// only, leaving reference-like text in prose and code samples untouched.
902+
return preg_replace_callback(
903+
'/(<a\b[^>]*?\shref=")([^"?#{}]*)(\?[^"#{}]*)?(#[^"#{}]*)?(?:#|%23)(entry|category|asset):(\d+)(?:@(\d+))?(?::url)?"/',
904+
function(array $matches) use ($defaultSiteId, &$resolved): string {
905+
[, $prefix, $url, $query, $fragment, $type, $id, $siteId] = array_pad($matches, 8, '');
906+
$ref = '{' . $type . ':' . $id . ($siteId !== '' ? '@' . $siteId : '') . ':url}';
907+
$currentUrl = $resolved[$ref] ??= Craft::$app->getElements()->parseRefs($ref, $defaultSiteId);
908+
909+
// Deleted elements retain their last saved URL without the editor marker.
910+
if ($currentUrl === $ref) {
911+
return $prefix . $url . $query . $fragment . '"';
912+
}
913+
914+
// Keep custom suffixes, but let the element own its current route
915+
// parameters (e.g. ?p=news/updated) and any intrinsic fragment.
916+
if ($query !== '') {
917+
$currentQuery = parse_url($currentUrl, PHP_URL_QUERY);
918+
$currentUrl = UrlHelper::urlWithParams($currentUrl, Html::decode(ltrim($query, '?')));
919+
920+
if ($currentQuery) {
921+
$currentUrl = UrlHelper::urlWithParams($currentUrl, $currentQuery);
922+
}
923+
}
924+
925+
if ($fragment !== '' && !parse_url($currentUrl, PHP_URL_FRAGMENT)) {
926+
$currentUrl .= Html::decode($fragment);
927+
}
928+
929+
return $prefix . Html::encode($currentUrl) . '"';
930+
},
931+
$html,
932+
);
933+
}
934+
897935
/** Keep the cell contract as HTML text while enforcing the vocabulary of Table Maker's toolbar options. */
898936
private static function sanitizeRichText(string $value): string
899937
{

‎tests/Services/TableWorkflowTest.php‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use craft\elements\Entry;
66
use craft\fieldlayoutelements\CustomField;
7+
use craft\helpers\Html;
8+
use craft\helpers\UrlHelper;
79
use craft\models\EntryType;
810
use craft\models\FieldLayout;
911
use craft\models\FieldLayoutTab;
@@ -135,6 +137,67 @@
135137
->and($html)->not->toContain('onerror');
136138
});
137139

140+
it('resolves CraftLink fragments after the linked entry URI changes', function(string $marker, bool $explicitSite, string $suffix) {
141+
$siteId = $this->entry->siteId;
142+
$settings = $this->section->getSiteSettings();
143+
$settings[$siteId]->hasUrls = true;
144+
$settings[$siteId]->uriFormat = 'news/{slug}';
145+
$this->section->setSiteSettings($settings);
146+
expect(Craft::$app->getEntries()->saveSection($this->section))->toBeTrue();
147+
expect(Craft::$app->getElements()->saveElement($this->entry))->toBeTrue();
148+
$oldUrl = $this->entry->getUrl();
149+
$ref = $marker . 'entry:' . $this->entry->id . ($explicitSite ? '@' . $siteId : '');
150+
$sourceUrl = UrlHelper::urlWithParams($oldUrl, Html::decode($suffix));
151+
$source = '<p><a href="' . Html::encode($sourceUrl) . $ref . '" target="_blank">Read more</a></p>';
152+
$value = $this->tableField->normalizeValue([
153+
'columns' => [['heading' => 'Reference', 'type' => 'richtext']],
154+
'rows' => [[$source]],
155+
], $this->entry);
156+
$stored = $value->toStorage();
157+
158+
$this->entry->slug = 'updated-link';
159+
expect(Craft::$app->getElements()->saveElement($this->entry))->toBeTrue();
160+
$currentUrl = $this->entry->getUrl();
161+
$expectedUrl = Html::encode(UrlHelper::urlWithParams($currentUrl, Html::decode($suffix)));
162+
expect($currentUrl)->not->toBe($oldUrl);
163+
164+
// An explicit link site must win over the owning cell's site.
165+
$value->siteId = $explicitSite ? 999999 : $siteId;
166+
$html = (string)$value->getTable();
167+
expect($html)->toContain('href="' . $expectedUrl . '"')
168+
->and($html)->toContain('target="_blank"')
169+
->and($html)->not->toContain($marker . 'entry:', '{entry:')
170+
->and($value->toStorage())->toBe($stored);
171+
172+
// Standalone values without an owner still use Craft's default site.
173+
$standalone = verbb\tablemaker\helpers\TableValue::normalize($stored);
174+
expect((string)$standalone->getTable())->toContain('href="' . $expectedUrl . '"');
175+
})->with([
176+
'explicit site' => ['#', true, ''],
177+
'owning site and suffix' => ['#', false, '?source=table&amp;page=2#details'],
178+
'encoded marker' => ['%23', true, '?source=table#details'],
179+
]);
180+
181+
it('keeps ordinary links and falls back to saved URLs for missing CraftLink elements', function() {
182+
$source = '<p><a href="https://example.test/page#details">Anchor</a> '
183+
. '<a href="https://example.test/old?source=table#details#entry:999999999@1">Missing entry</a> '
184+
. '<a href="https://example.test/category#category:999999999">Missing category</a> '
185+
. '<a href="https://example.test/file.pdf%23asset:999999999">Missing asset</a></p>'
186+
. '<p><code>href="https://example.test/sample#entry:123"</code></p>';
187+
$value = $this->tableField->normalizeValue([
188+
'columns' => [['heading' => 'Reference', 'type' => 'richtext']],
189+
'rows' => [[$source]],
190+
], $this->entry);
191+
192+
expect((string)$value->getTable())->toContain(
193+
'href="https://example.test/page#details"',
194+
'href="https://example.test/old?source=table#details"',
195+
'href="https://example.test/category"',
196+
'href="https://example.test/file.pdf"',
197+
'<code>href="https://example.test/sample#entry:123"</code>',
198+
)->not->toContain('#entry:999999999', '#category:', '%23asset:');
199+
});
200+
138201
it('rejects invalid numbers without replacing saved table content', function(string $number) {
139202
$handle = $this->tableField->handle;
140203
$this->entry->setFieldValueFromRequest($handle, [

0 commit comments

Comments
 (0)