senren-ui 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -2
  3. data/CONTRIBUTING.md +79 -9
  4. data/README.md +149 -14
  5. data/Rakefile +14 -1
  6. data/docs/components.md +222 -0
  7. data/docs/hot_reload.md +103 -0
  8. data/docs/performance_testing.md +38 -0
  9. data/lib/commands/senren/add/add_command.rb +35 -0
  10. data/lib/generators/senren/component/templates/controller.js.tt +7 -4
  11. data/lib/generators/senren/install/install_generator.rb +74 -0
  12. data/lib/generators/senren/install/templates/base_component.rb.tt +89 -8
  13. data/lib/generators/senren/install/templates/conventions.md.tt +26 -7
  14. data/lib/senren/rails/agent_rules_writer.rb +62 -19
  15. data/lib/senren/rails/asset_path_guard.rb +128 -0
  16. data/lib/senren/rails/base_component_patch.rb +64 -0
  17. data/lib/senren/rails/component_copier.rb +138 -18
  18. data/lib/senren/rails/component_installer.rb +73 -0
  19. data/lib/senren/rails/doctor.rb +7 -4
  20. data/lib/senren/rails/engine.rb +23 -0
  21. data/lib/senren/rails/host_paths.rb +11 -2
  22. data/lib/senren/rails/marker_block.rb +81 -0
  23. data/lib/senren/rails/registry.rb +73 -35
  24. data/lib/senren/rails/safe_write.rb +169 -0
  25. data/lib/senren/rails/skill_writer.rb +48 -12
  26. data/lib/senren/rails/version.rb +1 -1
  27. data/lib/senren/rails.rb +2 -1
  28. data/lib/senren-ui.rb +15 -0
  29. data/lib/tasks/senren.rake +53 -34
  30. data/registry/components.yml +45 -0
  31. data/registry/recipes.yml +12 -0
  32. data/templates/components/alert_dialog/alert_dialog_component.rb +1 -1
  33. data/templates/components/api_key_field/api_key_field_component.html.erb +1 -1
  34. data/templates/components/aspect_ratio/aspect_ratio_component.rb +7 -0
  35. data/templates/components/avatar/avatar_component.rb +8 -1
  36. data/templates/components/billing_plan_card/billing_plan_card_component.html.erb +1 -1
  37. data/templates/components/breadcrumb/breadcrumb_component.rb +2 -2
  38. data/templates/components/button/button_component.html.erb +1 -1
  39. data/templates/components/carousel/carousel_component.rb +1 -1
  40. data/templates/components/cart/cart_component.html.erb +61 -0
  41. data/templates/components/cart/cart_component.rb +71 -0
  42. data/templates/components/checkbox/checkbox_component.rb +1 -1
  43. data/templates/components/clipboard/clipboard_component.html.erb +1 -1
  44. data/templates/components/command/command_component.rb +2 -2
  45. data/templates/components/date_picker/date_picker_component.html.erb +1 -1
  46. data/templates/components/dialog/dialog_component.rb +1 -1
  47. data/templates/components/dropdown_menu/dropdown_menu_component.rb +10 -7
  48. data/templates/components/form/form_component.html.erb +8 -1
  49. data/templates/components/form/form_component.rb +12 -2
  50. data/templates/components/input/input_component.html.erb +1 -1
  51. data/templates/components/input/input_component.rb +19 -0
  52. data/templates/components/invite_member_dialog/invite_member_dialog_component.rb +1 -1
  53. data/templates/components/label/label_component.html.erb +1 -2
  54. data/templates/components/label/label_component.rb +12 -2
  55. data/templates/components/link/link_component.html.erb +1 -1
  56. data/templates/components/native_select/native_select_component.html.erb +19 -5
  57. data/templates/components/native_select/native_select_component.rb +17 -5
  58. data/templates/components/pagination/pagination_component.rb +2 -1
  59. data/templates/components/product_card/product_card_component.html.erb +38 -0
  60. data/templates/components/product_card/product_card_component.rb +49 -0
  61. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.html.erb +1 -1
  62. data/templates/components/rich_text_editor_lite/rich_text_editor_lite_component.rb +1 -1
  63. data/templates/components/separator/separator_component.rb +7 -0
  64. data/templates/components/sheet/sheet_component.rb +1 -1
  65. data/templates/components/sidebar/sidebar_component.rb +2 -2
  66. data/templates/components/switch/switch_component.html.erb +2 -2
  67. data/templates/components/tooltip/tooltip_component.rb +2 -2
  68. data/templates/components/top_nav/top_nav_component.rb +2 -2
  69. data/templates/components/typography/typography_component.rb +7 -0
  70. data/templates/controllers/accordion_controller.js +1 -1
  71. data/templates/controllers/alert_dialog_controller.js +31 -7
  72. data/templates/controllers/cart_controller.js +83 -0
  73. data/templates/controllers/clipboard_controller.js +12 -1
  74. data/templates/controllers/command_controller.js +3 -4
  75. data/templates/controllers/context_menu_controller.js +38 -11
  76. data/templates/controllers/data_table_controller.js +8 -3
  77. data/templates/controllers/dialog_controller.js +47 -21
  78. data/templates/controllers/dropdown_menu_controller.js +40 -27
  79. data/templates/controllers/hover_card_controller.js +8 -0
  80. data/templates/controllers/invite_member_dialog_controller.js +6 -0
  81. data/templates/controllers/masked_input_controller.js +8 -1
  82. data/templates/controllers/popover_controller.js +25 -10
  83. data/templates/controllers/rich_text_editor_lite_controller.js +175 -28
  84. data/templates/controllers/sheet_controller.js +41 -11
  85. metadata +20 -6
  86. data/lib/senren/rails/installer.rb +0 -84
@@ -1,50 +1,76 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // senren--dialog
4
- // Local UI: open/close, focus trap, Escape to close, body scroll lock.
4
+ // Local UI: open/close, Escape to close, body scroll lock.
5
+ //
6
+ // State lives in `openValue`, not in the DOM nodes this controller touches.
7
+ // Actions only assign the value; every DOM change happens in openValueChanged.
8
+ //
9
+ // That inversion is what makes the state server-renderable (render
10
+ // open-value="true" and the dialog is open with no JavaScript), survivable
11
+ // across a Turbo morph, and reachable from a Turbo Stream that changes a single
12
+ // attribute. It is the pattern every stateful Senren controller follows.
5
13
  export default class extends Controller {
6
14
  static targets = ["overlay", "panel", "trigger"]
7
- static values = { open: Boolean }
15
+ static values = { open: Boolean }
8
16
 
9
17
  connect() {
10
18
  this._onKey = this._onKey.bind(this)
11
- if (this.openValue) this._show()
19
+ this._onBeforeCache = this._onBeforeCache.bind(this)
20
+ document.addEventListener("turbo:before-cache", this._onBeforeCache)
12
21
  }
13
22
 
14
23
  disconnect() {
15
24
  document.removeEventListener("keydown", this._onKey)
16
- document.body.style.overflow = ""
25
+ document.removeEventListener("turbo:before-cache", this._onBeforeCache)
26
+ this._releaseScroll()
17
27
  }
18
28
 
19
29
  open(event) {
20
30
  event?.preventDefault()
21
- this._show()
31
+ this.openValue = true
22
32
  }
23
33
 
24
34
  close(event) {
25
35
  event?.preventDefault()
26
- this._hide()
36
+ this.openValue = false
27
37
  }
28
38
 
29
- _show() {
30
- this.openValue = true
31
- if (this.hasOverlayTarget) this.overlayTarget.hidden = false
32
- if (this.hasPanelTarget) this.panelTarget.hidden = false
33
- document.addEventListener("keydown", this._onKey)
34
- document.body.style.overflow = "hidden"
35
- queueMicrotask(() => this.panelTarget?.focus())
36
- }
39
+ // Stimulus calls this during initialization too, so the server-rendered value
40
+ // paints the first frame.
41
+ openValueChanged(isOpen, wasOpen) {
42
+ if (this.hasOverlayTarget) this.overlayTarget.hidden = !isOpen
43
+ if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
44
+
45
+ if (isOpen) {
46
+ document.addEventListener("keydown", this._onKey)
47
+ document.body.style.overflow = "hidden"
48
+ queueMicrotask(() => this.panelTarget?.focus())
49
+ this.dispatch("opened")
50
+ return
51
+ }
37
52
 
38
- _hide() {
39
- this.openValue = false
40
- if (this.hasOverlayTarget) this.overlayTarget.hidden = true
41
- if (this.hasPanelTarget) this.panelTarget.hidden = true
42
53
  document.removeEventListener("keydown", this._onKey)
43
- document.body.style.overflow = ""
44
- this.triggerTarget?.focus?.()
54
+ this._releaseScroll()
55
+ // Only return focus on a real close, not on the initial render.
56
+ if (wasOpen !== undefined) {
57
+ this.triggerTarget?.focus?.()
58
+ this.dispatch("closed")
59
+ }
45
60
  }
46
61
 
47
62
  _onKey(event) {
48
- if (event.key === "Escape") this._hide()
63
+ if (event.key === "Escape") this.close()
64
+ }
65
+
66
+ // Turbo snapshots the page before navigating away. Without this the scroll
67
+ // lock is captured while still applied, and a back navigation restores a page
68
+ // that cannot be scrolled.
69
+ _onBeforeCache() {
70
+ this.openValue = false
71
+ }
72
+
73
+ _releaseScroll() {
74
+ document.body.style.overflow = ""
49
75
  }
50
76
  }
@@ -2,35 +2,36 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // senren--dropdown-menu
4
4
  // Local UI: toggle visibility, close on outside click and Escape, basic arrow nav.
5
+ //
6
+ // State lives in `openValue`. Actions assign it; openValueChanged performs
7
+ // every DOM change — including the `data-state` attributes the chevron styling
8
+ // reads — and owns the document listeners.
5
9
  export default class extends Controller {
6
10
  static targets = ["trigger", "menu"]
11
+ static values = { open: Boolean }
7
12
 
8
13
  connect() {
9
14
  this._onDocClick = this._onDocClick.bind(this)
10
15
  this._onKey = this._onKey.bind(this)
11
- this._setOpenState(false)
12
16
  }
13
17
 
14
18
  disconnect() {
15
- document.removeEventListener("click", this._onDocClick)
16
- document.removeEventListener("keydown", this._onKey)
19
+ this._stopListening()
17
20
  }
18
21
 
19
22
  toggle(event) {
20
23
  event?.preventDefault()
21
- this.menuTarget.hidden ? this._show() : this.close()
24
+ this.openValue = !this.openValue
22
25
  }
23
26
 
24
27
  close() {
25
- this._setOpenState(false)
26
- document.removeEventListener("click", this._onDocClick)
27
- document.removeEventListener("keydown", this._onKey)
28
+ this.openValue = false
28
29
  }
29
30
 
30
31
  onTriggerKey(event) {
31
32
  if (["Enter", " ", "ArrowDown"].includes(event.key)) {
32
33
  event.preventDefault()
33
- this._show()
34
+ this.openValue = true
34
35
  this._focusItem(0)
35
36
  }
36
37
  }
@@ -39,32 +40,44 @@ export default class extends Controller {
39
40
  const items = this._items()
40
41
  const idx = items.indexOf(document.activeElement)
41
42
  if (event.key === "ArrowDown") { event.preventDefault(); this._focusItem((idx + 1) % items.length) }
42
- if (event.key === "ArrowUp") { event.preventDefault(); this._focusItem((idx - 1 + items.length) % items.length) }
43
- if (event.key === "Escape") { this.close(); this.triggerTarget?.focus?.() }
43
+ if (event.key === "ArrowUp") { event.preventDefault(); this._focusItem((idx - 1 + items.length) % items.length) }
44
+ if (event.key === "Escape") { this.close(); this.triggerTarget?.focus?.() }
44
45
  }
45
46
 
46
- _show() {
47
- this._setOpenState(true)
48
- document.addEventListener("click", this._onDocClick)
49
- document.addEventListener("keydown", this._onKey)
50
- }
47
+ openValueChanged(isOpen) {
48
+ const state = isOpen ? "open" : "closed"
51
49
 
52
- _setOpenState(open) {
53
- const state = open ? "open" : "closed"
54
- this.menuTarget.hidden = !open
50
+ if (this.hasMenuTarget) this.menuTarget.hidden = !isOpen
55
51
  this.element.dataset.state = state
56
- this.triggerTarget.dataset.state = state
57
- this.triggerTarget.setAttribute("aria-expanded", open ? "true" : "false")
58
52
 
59
- const triggerControl = this._triggerControl()
60
- if (triggerControl && triggerControl !== this.triggerTarget) {
61
- triggerControl.dataset.state = state
62
- triggerControl.setAttribute("aria-expanded", open ? "true" : "false")
53
+ if (this.hasTriggerTarget) {
54
+ this.triggerTarget.dataset.state = state
55
+ this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
56
+
57
+ const control = this._triggerControl()
58
+ if (control && control !== this.triggerTarget) {
59
+ control.dataset.state = state
60
+ control.setAttribute("aria-expanded", isOpen ? "true" : "false")
61
+ }
62
+
63
+ this.triggerTarget.querySelectorAll("[data-senren-chevron]").forEach((chevron) => {
64
+ chevron.dataset.state = state
65
+ })
63
66
  }
64
67
 
65
- this.triggerTarget.querySelectorAll("[data-senren-chevron]").forEach((chevron) => {
66
- chevron.dataset.state = state
67
- })
68
+ if (isOpen) {
69
+ document.addEventListener("click", this._onDocClick)
70
+ document.addEventListener("keydown", this._onKey)
71
+ this.dispatch("opened")
72
+ return
73
+ }
74
+
75
+ this._stopListening()
76
+ }
77
+
78
+ _stopListening() {
79
+ document.removeEventListener("click", this._onDocClick)
80
+ document.removeEventListener("keydown", this._onKey)
68
81
  }
69
82
 
70
83
  _triggerControl() {
@@ -14,4 +14,12 @@ export default class extends Controller {
14
14
  clearTimeout(this._showTimer)
15
15
  this._hideTimer = setTimeout(() => { if (this.hasPanelTarget) this.panelTarget.hidden = true }, 120)
16
16
  }
17
+
18
+ // Either timer can still be pending when a Turbo navigation removes the
19
+ // element, which retains this controller and its detached subtree until it
20
+ // fires.
21
+ disconnect() {
22
+ clearTimeout(this._showTimer)
23
+ clearTimeout(this._hideTimer)
24
+ }
17
25
  }
@@ -8,6 +8,12 @@ export default class extends Controller {
8
8
  this.closeOnEscape = this.closeOnEscape.bind(this)
9
9
  }
10
10
 
11
+ // open() registers a document-level listener; without this the listener and
12
+ // the detached subtree survive every Turbo navigation made while open.
13
+ disconnect() {
14
+ document.removeEventListener("keydown", this.closeOnEscape)
15
+ }
16
+
11
17
  open() {
12
18
  this.overlayTarget.hidden = false
13
19
  this.panelTarget.hidden = false
@@ -10,7 +10,14 @@ export default class extends Controller {
10
10
  static values = { mask: String }
11
11
 
12
12
  connect() {
13
- this.element.addEventListener("input", this._onInput.bind(this))
13
+ // Keep the bound reference: Stimulus re-connects on Turbo morph/restore,
14
+ // and an unremovable listener would stack up one mask pass per cycle.
15
+ this._boundOnInput = this._onInput.bind(this)
16
+ this.element.addEventListener("input", this._boundOnInput)
17
+ }
18
+
19
+ disconnect() {
20
+ this.element.removeEventListener("input", this._boundOnInput)
14
21
  }
15
22
 
16
23
  _onInput(event) {
@@ -2,8 +2,13 @@ import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // senren--popover
4
4
  // Toggles a small panel; closes on outside click and Escape.
5
+ //
6
+ // State lives in `openValue`. Actions assign it; openValueChanged performs
7
+ // every DOM change and owns the document listeners, so the open state is
8
+ // server-renderable and survives a Turbo morph.
5
9
  export default class extends Controller {
6
10
  static targets = ["trigger", "panel"]
11
+ static values = { open: Boolean }
7
12
 
8
13
  connect() {
9
14
  this._onDocClick = this._onDocClick.bind(this)
@@ -11,25 +16,35 @@ export default class extends Controller {
11
16
  }
12
17
 
13
18
  disconnect() {
14
- document.removeEventListener("click", this._onDocClick)
15
- document.removeEventListener("keydown", this._onKey)
19
+ this._stopListening()
16
20
  }
17
21
 
18
22
  toggle(event) {
19
23
  event?.preventDefault()
20
- this.panelTarget.hidden ? this._show() : this.close()
24
+ this.openValue = !this.openValue
21
25
  }
22
26
 
23
27
  close() {
24
- this.panelTarget.hidden = true
25
- document.removeEventListener("click", this._onDocClick)
26
- document.removeEventListener("keydown", this._onKey)
28
+ this.openValue = false
29
+ }
30
+
31
+ openValueChanged(isOpen) {
32
+ if (this.hasPanelTarget) this.panelTarget.hidden = !isOpen
33
+ if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", isOpen ? "true" : "false")
34
+
35
+ if (isOpen) {
36
+ document.addEventListener("click", this._onDocClick)
37
+ document.addEventListener("keydown", this._onKey)
38
+ this.dispatch("opened")
39
+ return
40
+ }
41
+
42
+ this._stopListening()
27
43
  }
28
44
 
29
- _show() {
30
- this.panelTarget.hidden = false
31
- document.addEventListener("click", this._onDocClick)
32
- document.addEventListener("keydown", this._onKey)
45
+ _stopListening() {
46
+ document.removeEventListener("click", this._onDocClick)
47
+ document.removeEventListener("keydown", this._onKey)
33
48
  }
34
49
 
35
50
  _onDocClick(event) {
@@ -1,5 +1,17 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
+ const ALLOWED_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:", "tel:"])
4
+
5
+ // Mirrors the server-side sanitize() allowlist in
6
+ // rich_text_editor_lite_component.html.erb, so both ends agree on what the
7
+ // stored value may contain.
8
+ const ALLOWED_PASTE_TAGS = new Set([
9
+ "P", "BR", "STRONG", "B", "EM", "I", "A", "UL", "OL", "LI", "H1", "H2", "H3"
10
+ ])
11
+ const ALLOWED_PASTE_ATTRIBUTES = new Set(["href", "rel", "target", "data-align"])
12
+ // Dropped whole rather than unwrapped: their text content is code, not prose.
13
+ const DROPPED_PASTE_TAGS = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "OBJECT", "EMBED"])
14
+
3
15
  // senren--rich-text-editor-lite
4
16
  // Local UI: tiny contenteditable toolbar synced to a hidden textarea.
5
17
  export default class extends Controller {
@@ -15,7 +27,7 @@ export default class extends Controller {
15
27
  this.sync()
16
28
  this.rememberSelection()
17
29
  this.updateToolbar()
18
- this.debug("connect", this.snapshot())
30
+ this.debug("connect", () => (this.snapshot()))
19
31
  }
20
32
 
21
33
  keepSelection(event) {
@@ -31,7 +43,12 @@ export default class extends Controller {
31
43
  if (!event.metaKey && !event.ctrlKey) return
32
44
 
33
45
  event.preventDefault()
34
- window.open(link.href, "_blank", "noopener,noreferrer")
46
+ // Anchors can arrive from a paste, so gate the protocol here too rather
47
+ // than trusting the browser to refuse javascript:/data: in window.open.
48
+ const href = this.normalizeHref(link.getAttribute("href"))
49
+ if (!href) return
50
+
51
+ window.open(href, "_blank", "noopener,noreferrer")
35
52
  }
36
53
 
37
54
  format(event) {
@@ -39,11 +56,11 @@ export default class extends Controller {
39
56
  const command = event.currentTarget.dataset.command
40
57
  if (!command) return
41
58
 
42
- this.debug("format:start", { command, before: this.snapshot() })
59
+ this.debug("format:start", () => ({ command, before: this.snapshot() }))
43
60
  this.restoreSelection()
44
61
  if (command === "createLink") {
45
62
  const url = window.prompt("Paste a URL")
46
- this.debug("createLink:prompt", { url, afterPrompt: this.snapshot() })
63
+ this.debug("createLink:prompt", () => ({ url, afterPrompt: this.snapshot() }))
47
64
  if (!url) return
48
65
  this.restoreSelection()
49
66
  this.insertLink(url)
@@ -63,7 +80,7 @@ export default class extends Controller {
63
80
  this.sync()
64
81
  this.updateToolbar()
65
82
  this.rememberSelection()
66
- this.debug("format:done", { command, after: this.snapshot() })
83
+ this.debug("format:done", () => ({ command, after: this.snapshot() }))
67
84
  }
68
85
 
69
86
  sync() {
@@ -75,6 +92,96 @@ export default class extends Controller {
75
92
  requestAnimationFrame(() => this.sync())
76
93
  }
77
94
 
95
+ // Handles both paste and drop. The browser's own contenteditable paste path
96
+ // keeps on* handler attributes, so pasted markup must be rebuilt against an
97
+ // allowlist before it reaches the document.
98
+ paste(event) {
99
+ const transfer = event.clipboardData || event.dataTransfer
100
+ if (!transfer) return
101
+
102
+ event.preventDefault()
103
+ const html = transfer.getData("text/html")
104
+ const text = transfer.getData("text/plain")
105
+
106
+ if (html) {
107
+ this.insertSanitized(this.sanitizeToFragment(html))
108
+ } else if (text) {
109
+ document.execCommand("insertText", false, text)
110
+ }
111
+
112
+ this.sync()
113
+ this.rememberSelection()
114
+ this.updateToolbar()
115
+ }
116
+
117
+ insertSanitized(fragment) {
118
+ if (fragment.childNodes.length === 0) return
119
+
120
+ const range = this.activeRange()
121
+ if (!range) {
122
+ this.editorTarget.appendChild(fragment)
123
+ return
124
+ }
125
+
126
+ const last = fragment.lastChild
127
+ range.deleteContents()
128
+ range.insertNode(fragment)
129
+ if (last) {
130
+ range.setStartAfter(last)
131
+ range.collapse(true)
132
+ const selection = window.getSelection()
133
+ selection.removeAllRanges()
134
+ selection.addRange(range)
135
+ }
136
+ }
137
+
138
+ // DOMParser builds an inert document: no scripts run and no resources load,
139
+ // so onerror/onload never fire while we walk the tree.
140
+ sanitizeToFragment(html) {
141
+ const parsed = new DOMParser().parseFromString(html, "text/html")
142
+ return this.sanitizeChildren(parsed.body)
143
+ }
144
+
145
+ sanitizeChildren(node) {
146
+ const fragment = document.createDocumentFragment()
147
+ Array.from(node.childNodes).forEach((child) => {
148
+ const clean = this.sanitizeNode(child)
149
+ if (clean) fragment.appendChild(clean)
150
+ })
151
+ return fragment
152
+ }
153
+
154
+ sanitizeNode(node) {
155
+ if (node.nodeType === Node.TEXT_NODE) return document.createTextNode(node.nodeValue)
156
+ if (node.nodeType !== Node.ELEMENT_NODE) return null
157
+
158
+ const tag = node.tagName.toUpperCase()
159
+ if (DROPPED_PASTE_TAGS.has(tag)) return null
160
+ // Other disallowed elements are unwrapped, not dropped: keep their text.
161
+ if (!ALLOWED_PASTE_TAGS.has(tag)) return this.sanitizeChildren(node)
162
+
163
+ const element = document.createElement(tag.toLowerCase())
164
+ Array.from(node.attributes).forEach((attr) => {
165
+ const name = attr.name.toLowerCase()
166
+ if (!ALLOWED_PASTE_ATTRIBUTES.has(name)) return
167
+
168
+ if (name === "href") {
169
+ const href = this.normalizeHref(attr.value)
170
+ if (href) element.setAttribute("href", href)
171
+ return
172
+ }
173
+
174
+ element.setAttribute(name, attr.value)
175
+ })
176
+
177
+ if (tag === "A" && element.hasAttribute("target")) {
178
+ element.setAttribute("rel", "noopener noreferrer")
179
+ }
180
+
181
+ element.appendChild(this.sanitizeChildren(node))
182
+ return element
183
+ }
184
+
78
185
  updateToolbar() {
79
186
  this.buttonTargets.forEach((button) => {
80
187
  const command = button.dataset.command
@@ -120,9 +227,9 @@ export default class extends Controller {
120
227
  const range = selection.getRangeAt(0)
121
228
  if (this.rangeInsideEditor(range)) {
122
229
  this.savedRange = range.cloneRange()
123
- this.debug("rememberSelection:saved", this.describeRange(this.savedRange))
230
+ this.debug("rememberSelection:saved", () => (this.describeRange(this.savedRange)))
124
231
  } else {
125
- this.debug("rememberSelection:outside", this.describeRange(range))
232
+ this.debug("rememberSelection:outside", () => (this.describeRange(range)))
126
233
  }
127
234
  }
128
235
 
@@ -132,17 +239,17 @@ export default class extends Controller {
132
239
  const selection = window.getSelection()
133
240
  selection.removeAllRanges()
134
241
  selection.addRange(this.savedRange)
135
- this.debug("restoreSelection:saved", this.describeRange(this.savedRange))
242
+ this.debug("restoreSelection:saved", () => (this.describeRange(this.savedRange)))
136
243
  return
137
244
  }
138
245
 
139
- this.debug("restoreSelection:endFallback", this.snapshot())
246
+ this.debug("restoreSelection:endFallback", () => (this.snapshot()))
140
247
  this.placeCaretAtEnd()
141
248
  }
142
249
 
143
250
  insertLink(rawUrl) {
144
251
  const url = this.normalizeUrl(rawUrl)
145
- this.debug("insertLink:start", { rawUrl, url, before: this.snapshot() })
252
+ this.debug("insertLink:start", () => ({ rawUrl, url, before: this.snapshot() }))
146
253
  if (!url) return
147
254
 
148
255
  const range = this.activeRange()
@@ -155,7 +262,7 @@ export default class extends Controller {
155
262
  anchor.href = url
156
263
  anchor.rel = "noopener noreferrer"
157
264
  anchor.target = "_blank"
158
- this.debug("insertLink:range", this.describeRange(range))
265
+ this.debug("insertLink:range", () => (this.describeRange(range)))
159
266
 
160
267
  if (range.collapsed) {
161
268
  anchor.textContent = url
@@ -170,12 +277,12 @@ export default class extends Controller {
170
277
  const selection = window.getSelection()
171
278
  selection.removeAllRanges()
172
279
  selection.addRange(range)
173
- this.debug("insertLink:done", { anchor: anchor.outerHTML, after: this.snapshot() })
280
+ this.debug("insertLink:done", () => ({ anchor: anchor.outerHTML, after: this.snapshot() }))
174
281
  }
175
282
 
176
283
  toggleList(tagName) {
177
284
  const range = this.activeRange()
178
- this.debug("toggleList:start", { tagName, range: this.describeRange(range), before: this.snapshot() })
285
+ this.debug("toggleList:start", () => ({ tagName, range: this.describeRange(range), before: this.snapshot() }))
179
286
  if (!range) return
180
287
 
181
288
  const activeList = this.closestElement(range.startContainer, "ul, ol")
@@ -185,12 +292,12 @@ export default class extends Controller {
185
292
  } else {
186
293
  this.convertList(activeList, tagName)
187
294
  }
188
- this.debug("toggleList:existingList", { tagName, after: this.snapshot() })
295
+ this.debug("toggleList:existingList", () => ({ tagName, after: this.snapshot() }))
189
296
  return
190
297
  }
191
298
 
192
299
  const blocks = this.selectedBlocks(range)
193
- this.debug("toggleList:blocks", { tagName, blocks: blocks.map((block) => block.outerHTML) })
300
+ this.debug("toggleList:blocks", () => ({ tagName, blocks: blocks.map((block) => block.outerHTML) }))
194
301
  if (blocks.length === 0) return
195
302
 
196
303
  const list = document.createElement(tagName)
@@ -202,15 +309,15 @@ export default class extends Controller {
202
309
  })
203
310
 
204
311
  blocks[0].before(list)
205
- blocks.forEach((block) => block.remove())
312
+ blocks.forEach((block) => { block.remove() })
206
313
  this.selectNodeContents(list)
207
- this.debug("toggleList:done", { list: list.outerHTML, after: this.snapshot() })
314
+ this.debug("toggleList:done", () => ({ list: list.outerHTML, after: this.snapshot() }))
208
315
  }
209
316
 
210
317
  formatBlocks(tagName) {
211
318
  const normalizedTagName = this.normalizedBlockTag(tagName)
212
319
  const range = this.activeRange()
213
- this.debug("formatBlocks:start", { tagName: normalizedTagName, range: this.describeRange(range) })
320
+ this.debug("formatBlocks:start", () => ({ tagName: normalizedTagName, range: this.describeRange(range) }))
214
321
  if (!range) return
215
322
 
216
323
  const blocks = this.selectedBlocks(range)
@@ -218,13 +325,13 @@ export default class extends Controller {
218
325
 
219
326
  const replacements = blocks.map((block) => this.replaceBlock(block, normalizedTagName))
220
327
  this.selectNodeContents(replacements[replacements.length - 1])
221
- this.debug("formatBlocks:done", { tagName: normalizedTagName, blocks: replacements.map((block) => block.outerHTML) })
328
+ this.debug("formatBlocks:done", () => ({ tagName: normalizedTagName, blocks: replacements.map((block) => block.outerHTML) }))
222
329
  }
223
330
 
224
331
  alignBlocks(alignment) {
225
332
  const normalizedAlignment = this.normalizedAlignment(alignment)
226
333
  const range = this.activeRange()
227
- this.debug("alignBlocks:start", { alignment: normalizedAlignment, range: this.describeRange(range) })
334
+ this.debug("alignBlocks:start", () => ({ alignment: normalizedAlignment, range: this.describeRange(range) }))
228
335
  if (!range) return
229
336
 
230
337
  const blocks = this.selectedBlocks(range)
@@ -237,7 +344,7 @@ export default class extends Controller {
237
344
  })
238
345
 
239
346
  if (blocks.length > 0) this.selectNodeContents(blocks[blocks.length - 1])
240
- this.debug("alignBlocks:done", { alignment: normalizedAlignment, blocks: blocks.map((block) => block.outerHTML) })
347
+ this.debug("alignBlocks:done", () => ({ alignment: normalizedAlignment, blocks: blocks.map((block) => block.outerHTML) }))
241
348
  }
242
349
 
243
350
  unwrapList(list) {
@@ -277,11 +384,49 @@ export default class extends Controller {
277
384
  }
278
385
  }
279
386
 
387
+ // Shapes that are unsafe in every context. Browsers treat "\" as "/" and
388
+ // strip TAB/CR/LF before parsing, and any leading "//" is protocol-relative
389
+ // however many slashes follow — "///evil.example" resolves to
390
+ // https://evil.example/.
391
+ rejectedUrlShape(url) {
392
+ if (url.length === 0) return true
393
+ if (url.includes("\\")) return true
394
+ if ([...url].some((ch) => ch.charCodeAt(0) <= 0x1f || ch.charCodeAt(0) === 0x7f)) return true
395
+
396
+ return url.startsWith("//")
397
+ }
398
+
399
+ // Policy for an href that already exists in markup — a pasted anchor, or one
400
+ // being opened. It mirrors safe_url on the server: a relative reference stays
401
+ // relative, because in HTML `href="settings"` means "relative to this page",
402
+ // not "the host settings". Promoting it to https://settings/ would turn a
403
+ // same-origin link into an off-origin one.
404
+ normalizeHref(rawUrl) {
405
+ const url = String(rawUrl || "").trim()
406
+ if (this.rejectedUrlShape(url)) return null
407
+ if (url.startsWith("#") || url.startsWith("/")) return url
408
+
409
+ const scheme = url.match(/^[a-z][a-z0-9+.-]*:/i)
410
+ if (!scheme) return url
411
+
412
+ return ALLOWED_LINK_PROTOCOLS.has(scheme[0].toLowerCase()) ? url : null
413
+ }
414
+
415
+ // Policy for a URL a person typed into the link prompt, where a bare host is
416
+ // the common case and should become https://host/. Only ever applied to input
417
+ // the user just entered, never to markup.
280
418
  normalizeUrl(rawUrl) {
281
419
  const url = String(rawUrl || "").trim()
282
- if (url.length === 0) return null
283
- if (/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(url)) return url
284
- return `https://${url}`
420
+ if (this.rejectedUrlShape(url)) return null
421
+ if (url.startsWith("#") || url.startsWith("/")) return url
422
+
423
+ const candidate = /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`
424
+ try {
425
+ const parsed = new URL(candidate, window.location.origin)
426
+ return ALLOWED_LINK_PROTOCOLS.has(parsed.protocol) ? parsed.href : null
427
+ } catch (_) {
428
+ return null
429
+ }
285
430
  }
286
431
 
287
432
  activeRange() {
@@ -289,19 +434,19 @@ export default class extends Controller {
289
434
  if (selection && selection.rangeCount > 0) {
290
435
  const range = selection.getRangeAt(0)
291
436
  if (this.rangeInsideEditor(range)) {
292
- this.debug("activeRange:selection", this.describeRange(range))
437
+ this.debug("activeRange:selection", () => (this.describeRange(range)))
293
438
  return range
294
439
  }
295
440
  }
296
441
 
297
442
  if (this.savedRange && this.rangeInsideEditor(this.savedRange)) {
298
- this.debug("activeRange:saved", this.describeRange(this.savedRange))
443
+ this.debug("activeRange:saved", () => (this.describeRange(this.savedRange)))
299
444
  return this.savedRange
300
445
  }
301
446
 
302
447
  this.placeCaretAtEnd()
303
448
  const range = window.getSelection().getRangeAt(0)
304
- this.debug("activeRange:endFallback", this.describeRange(range))
449
+ this.debug("activeRange:endFallback", () => (this.describeRange(range)))
305
450
  return range
306
451
  }
307
452
 
@@ -435,9 +580,11 @@ export default class extends Controller {
435
580
  return `${node.tagName.toLowerCase()}${id}${classes}`
436
581
  }
437
582
 
583
+ // `detail` may be a thunk so callers can defer building expensive payloads
584
+ // (full innerHTML/textContent snapshots) until debugging is actually on.
438
585
  debug(message, detail = {}) {
439
586
  if (!this.debugValue) return
440
587
 
441
- console.debug("[senren rich text]", message, detail)
588
+ console.debug("[senren rich text]", message, typeof detail === "function" ? detail() : detail)
442
589
  }
443
590
  }