Skip to content

Commit 01319ba

Browse files
committed
fix(LinkDialog): Fix anchor regex to handle hyphens in URL fragments
The fragment-stripping regex used \w+ which only matches word characters and fails on hyphens, causing double hash anchors (e.g. /page#my-section#new-section). The new ANCHOR_REGEX covers all RFC 3986 unreserved characters ([\w.~-]). (cherry picked from commit f6467a9)
1 parent 2d944de commit 01319ba

File tree

2 files changed

+82
-1
lines changed

2 files changed

+82
-1
lines changed

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+
})

0 commit comments

Comments
 (0)