Skip to content

Commit 9dfe8bc

Browse files
authored
Merge pull request #3685 from AlchemyCMS/backport/8.0-stable/pr-3683
[8.0-stable] fix(LinkDialog): Fix anchor regex to handle hyphens in URL fragments
2 parents 2d944de + dc4a159 commit 9dfe8bc

File tree

4 files changed

+84
-3
lines changed

4 files changed

+84
-3
lines changed

app/components/alchemy/admin/toolbar_button.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def permissions_from_url
106106
action_controller = url.delete_prefix("/").split("/")
107107
[
108108
action_controller.last.to_sym,
109-
action_controller[0..action_controller.length - 2].join("_").to_sym
109+
action_controller[0..-2].join("_").to_sym
110110
]
111111
end
112112
end

app/javascript/alchemy_admin/link_dialog.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { translate } from "alchemy_admin/i18n"
22
import { Dialog } from "alchemy_admin/dialog"
33

4+
// Matches a URL fragment (#anchor) at the end of a string.
5+
// Covers RFC 3986 unreserved characters (ALPHA, DIGIT, "-", ".", "_", "~")
6+
// which are the characters valid in URL fragments and common in DOM element IDs.
7+
const ANCHOR_REGEX = /#[\w.~-]+$/
8+
49
// Represents the link Dialog that appears, if a user clicks the link buttons
510
// in TinyMCE or on an Ingredient that has links enabled (e.g. Picture)
611
//
@@ -103,7 +108,7 @@ export class LinkDialog extends Dialog {
103108

104109
if (linkType === "internal" && elementAnchor.value !== "") {
105110
// remove possible fragments on the url and attach the fragment (which contains the #)
106-
url = url.replace(/#\w+$/, "") + elementAnchor.value
111+
url = url.replace(ANCHOR_REGEX, "") + elementAnchor.value
107112
} else if (linkType === "external" && !url.match(Alchemy.link_url_regexp)) {
108113
// show validation error and prevent link creation
109114
this.#showValidationError()
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { vi } from "vitest"
2+
import { LinkDialog } from "alchemy_admin/link_dialog"
3+
4+
vi.mock("alchemy_admin/spinner")
5+
vi.mock("alchemy_admin/hotkeys")
6+
7+
describe("LinkDialog", () => {
8+
beforeEach(() => {
9+
document.body.innerHTML = ""
10+
Alchemy.routes.link_admin_pages_path = "/admin/pages/link"
11+
})
12+
13+
/**
14+
* Helper to create a LinkDialog, inject form HTML, and submit the internal form.
15+
* Returns the promise that resolves with the link data on form submission.
16+
*/
17+
function submitInternalForm(internalLinkValue, anchorValue) {
18+
const dialog = new LinkDialog({
19+
url: internalLinkValue,
20+
type: "internal"
21+
})
22+
23+
const promise = dialog.open()
24+
25+
// Build the form HTML that the server would render
26+
const formHtml = `
27+
<div data-link-form-type="internal">
28+
<input id="internal_link" value="${internalLinkValue}" />
29+
<select id="element_anchor">
30+
<option value="">None</option>
31+
${anchorValue ? `<option value="${anchorValue}" selected>${anchorValue}</option>` : ""}
32+
</select>
33+
<input id="internal_link_title" value="" />
34+
<select id="internal_link_target"><option value="">Default</option></select>
35+
<alchemy-dom-id-api-select></alchemy-dom-id-api-select>
36+
</div>
37+
<div data-link-form-type="file">
38+
<alchemy-attachment-select></alchemy-attachment-select>
39+
<input id="file_link" value="" />
40+
<input id="file_link_title" value="" />
41+
<select id="file_link_target"><option value="">Default</option></select>
42+
</div>
43+
`
44+
45+
// replace() injects HTML into dialog body and attaches event listeners
46+
dialog.replace(formHtml)
47+
48+
// Submit the internal form
49+
const form = document.querySelector('[data-link-form-type="internal"]')
50+
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }))
51+
52+
return promise
53+
}
54+
55+
describe("submitting an internal link with anchor", () => {
56+
it("strips anchor with hyphens before appending new anchor", async () => {
57+
const result = await submitInternalForm("/page#my-section", "#new-section")
58+
expect(result.url).toBe("/page#new-section")
59+
})
60+
61+
it("does not create double hash when anchor contains hyphens", async () => {
62+
const result = await submitInternalForm("/page#my-section", "#my-section")
63+
expect(result.url).toBe("/page#my-section")
64+
})
65+
66+
it("strips simple word-only anchors correctly", async () => {
67+
const result = await submitInternalForm("/page#section", "#other")
68+
expect(result.url).toBe("/page#other")
69+
})
70+
71+
it("handles url without existing anchor", async () => {
72+
const result = await submitInternalForm("/page", "#section")
73+
expect(result.url).toBe("/page#section")
74+
})
75+
})
76+
})

spec/libraries/auth_accessors_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class MyCustomUser
4040

4141
context "and the default user class does not exist" do
4242
before do
43-
if Object.constants.include?(:User)
43+
if Object.const_defined?(:User)
4444
Object.send(:remove_const, :User)
4545
end
4646
end

0 commit comments

Comments
 (0)