Skip to content

Commit e4e9aeb

Browse files
author
Jonathan D.A. Jewell
committed
Integrate SafeDOM mounter pattern for content overlay mounting
1 parent 6697c6c commit e4e9aeb

2 files changed

Lines changed: 212 additions & 8 deletions

File tree

src/content.res

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,50 @@ external runtime: runtime = "runtime"
88
@send
99
external sendMessage: (runtime, Js.Json.t) => Js.Promise.t<Js.Json.t> = "sendMessage"
1010

11+
let ensureOverlayMountPoint: unit => unit = %raw(`() => {
12+
if (document.querySelector("#blocky-writer-overlay") !== null) return;
13+
const root = document.createElement("div");
14+
root.id = "blocky-writer-overlay";
15+
root.style.position = "fixed";
16+
root.style.right = "16px";
17+
root.style.bottom = "16px";
18+
root.style.zIndex = "2147483647";
19+
root.style.width = "320px";
20+
root.style.maxWidth = "90vw";
21+
root.style.fontFamily = "ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif";
22+
document.body.appendChild(root);
23+
}`)
24+
25+
let escapeHtml: string => string = %raw(`(value) =>
26+
value
27+
.replace(/&/g, "&amp;")
28+
.replace(/</g, "&lt;")
29+
.replace(/>/g, "&gt;")
30+
.replace(/"/g, "&quot;")
31+
.replace(/'/g, "&#39;")
32+
`)
33+
34+
let renderPanel = (~title: string, ~detail: string): unit => {
35+
let safeTitle = escapeHtml(title)
36+
let safeDetail = escapeHtml(detail)
37+
let html =
38+
"<section style='background:#111827;color:#fff;border-radius:10px;padding:12px;box-shadow:0 10px 30px rgba(0,0,0,.3);'>"
39+
++ "<div style='font-size:13px;font-weight:700;margin-bottom:6px;'>Blocky Writer</div>"
40+
++ "<div style='font-size:12px;line-height:1.4;'><strong>"
41+
++ safeTitle
42+
++ "</strong><br />"
43+
++ safeDetail
44+
++ "</div>"
45+
++ "</section>"
46+
47+
SafeDOM.mountWhenReady(
48+
"#blocky-writer-overlay",
49+
html,
50+
~onSuccess={_ => ()},
51+
~onError={error => Js.log2("Blocky Writer overlay mount error", error)},
52+
)
53+
}
54+
1155
let unsafeJson = (value: 'a): Js.Json.t => Obj.magic(value)
1256

1357
let makeDetectMessage = (url: string): Js.Json.t => {
@@ -17,7 +61,12 @@ let makeDetectMessage = (url: string): Js.Json.t => {
1761
unsafeJson(payload)
1862
}
1963

20-
let isPdfUrl = (url: string): bool => url->Js.String2.toLowerCase->Js.String2.endsWith(".pdf")
64+
let isPdfUrl = (url: string): bool => {
65+
let lower = url->Js.String2.toLowerCase
66+
lower->Js.String2.endsWith(".pdf")
67+
|| lower->Js.String2.includes(".pdf?")
68+
|| lower->Js.String2.includes(".pdf#")
69+
}
2170

2271
let findPdfTarget = (): option<string> => {
2372
let currentUrl = Webapi.Dom.location->Webapi.Dom.Location.href
@@ -41,7 +90,7 @@ let findPdfTarget = (): option<string> => {
4190
}
4291
}
4392

44-
let logDetectionResponse = (response: Js.Json.t): unit => {
93+
let handleDetectionResponse = (response: Js.Json.t): unit => {
4594
switch Js.Json.decodeObject(response) {
4695
| Some(payload) =>
4796
let ok =
@@ -53,29 +102,37 @@ let logDetectionResponse = (response: Js.Json.t): unit => {
53102
->Belt.Option.flatMap(Js.Json.decodeArray)
54103
->Belt.Option.map(Belt.Array.length)
55104
->Belt.Option.getWithDefault(0)
56-
Js.log2("Blocky Writer detected blocks", count)
105+
if count == 0 {
106+
renderPanel(~title="No widgets detected", ~detail="This PDF did not expose form widgets.")
107+
} else {
108+
renderPanel(
109+
~title="Detection successful",
110+
~detail="Detected " ++ Belt.Int.toString(count) ++ " PDF widgets ready for filling.",
111+
)
112+
}
57113
} else {
58114
let message =
59115
payload
60116
->Js.Dict.get("error")
61117
->Belt.Option.flatMap(Js.Json.decodeString)
62118
->Belt.Option.getWithDefault("unknown error")
63-
Js.log2("Blocky Writer detection failed", message)
119+
renderPanel(~title="Detection failed", ~detail=message)
64120
}
65-
| None => Js.log("Blocky Writer received non-object response")
121+
| None => renderPanel(~title="Detection failed", ~detail="Background returned an invalid payload.")
66122
}
67123
}
68124

69125
let requestBlockDetection = (targetUrl: string): unit => {
70-
Js.log2("Blocky Writer detectBlocks target", targetUrl)
126+
renderPanel(~title="Scanning PDF", ~detail="Requesting block detection...")
71127
let request = makeDetectMessage(targetUrl)
72128
let sendPromise = sendMessage(runtime, request)
73129
let loggedPromise = Js.Promise2.then(sendPromise, response => {
74-
logDetectionResponse(response)
130+
handleDetectionResponse(response)
75131
Js.Promise.resolve(response)
76132
})
77133
let _ = Js.Promise2.catch(loggedPromise, err => {
78134
Js.log2("Blocky Writer message failed", err)
135+
renderPanel(~title="Detection failed", ~detail="Unable to contact extension background worker.")
79136
Js.Promise.resolve(Js.Json.null)
80137
})
81138
()
@@ -84,8 +141,9 @@ let requestBlockDetection = (targetUrl: string): unit => {
84141
let currentUrl = Webapi.Dom.location->Webapi.Dom.Location.href
85142

86143
if currentUrl->Js.String2.includes("gov.uk") {
144+
ensureOverlayMountPoint()
87145
switch findPdfTarget() {
88146
| Some(targetUrl) => requestBlockDetection(targetUrl)
89-
| None => Js.log("Blocky Writer found no PDF target on page")
147+
| None => renderPanel(~title="No PDF target found", ~detail="Open a .pdf URL or include a direct PDF link on this page.")
90148
}
91149
}

src/core/SafeDOM.res

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/* SPDX-License-Identifier: AGPL-3.0-or-later WITH Palimpsest */
2+
/* Adapted from /mnt/eclipse/repos/rescript-ecosystem/packages/web/dom-mounter/src/SafeDOM.res */
3+
4+
type mountResult =
5+
| Mounted(Dom.element)
6+
| MountPointNotFound(string)
7+
| InvalidSelector(string)
8+
| InvalidHTML(string)
9+
10+
module ProvenSelector = {
11+
type validated = ValidSelector(string)
12+
13+
let hasInvalidSelectorChars: string => bool = %raw(`(selector) => /[^\w\-#.\[\]():>~+= ]/.test(selector)`)
14+
15+
let validate = (selector: string): result<validated, string> => {
16+
let len = selector->Js.String2.length
17+
if len == 0 {
18+
Error("Selector cannot be empty")
19+
} else if len > 255 {
20+
Error("Selector exceeds maximum length (255 characters)")
21+
} else if hasInvalidSelectorChars(selector) {
22+
Error("Selector contains invalid CSS characters")
23+
} else {
24+
Ok(ValidSelector(selector))
25+
}
26+
}
27+
28+
let toString = (ValidSelector(selector)) => selector
29+
}
30+
31+
module ProvenHTML = {
32+
type validated = ValidHTML(string)
33+
34+
let countOpenTags: string => int = %raw(`(html) => (html.match(/<[^\/][^>]*>/g) || []).length`)
35+
let countCloseTags: string => int = %raw(`(html) => (html.match(/<\/[^>]+>/g) || []).length`)
36+
let countSelfClosing: string => int = %raw(`(html) => (html.match(/<[^>]+\/>/g) || []).length`)
37+
38+
let validate = (html: string): result<validated, string> => {
39+
let len = html->Js.String2.length
40+
if len == 0 {
41+
Ok(ValidHTML(""))
42+
} else if len > 1048576 {
43+
Error("HTML content exceeds maximum size (1MB)")
44+
} else {
45+
let openTags = countOpenTags(html)
46+
let closeTags = countCloseTags(html)
47+
let selfClosing = countSelfClosing(html)
48+
if openTags - selfClosing != closeTags {
49+
Error(
50+
"Unbalanced HTML tags: "
51+
++ Belt.Int.toString(openTags - selfClosing)
52+
++ " open, "
53+
++ Belt.Int.toString(closeTags)
54+
++ " close",
55+
)
56+
} else {
57+
Ok(ValidHTML(html))
58+
}
59+
}
60+
}
61+
62+
let toString = (ValidHTML(html)) => html
63+
}
64+
65+
let findMountPoint = (selector: ProvenSelector.validated): option<Dom.element> =>
66+
Webapi.Dom.document->Webapi.Dom.Document.querySelector(selector->ProvenSelector.toString)
67+
68+
let mount = (selector: ProvenSelector.validated, html: ProvenHTML.validated): mountResult => {
69+
switch findMountPoint(selector) {
70+
| None => MountPointNotFound(selector->ProvenSelector.toString)
71+
| Some(element) =>
72+
element->Webapi.Dom.Element.setInnerHTML(html->ProvenHTML.toString)
73+
Mounted(element)
74+
}
75+
}
76+
77+
let mountString = (selector: string, html: string): mountResult => {
78+
switch ProvenSelector.validate(selector) {
79+
| Error(error) => InvalidSelector(error)
80+
| Ok(validSelector) =>
81+
switch ProvenHTML.validate(html) {
82+
| Error(error) => InvalidHTML(error)
83+
| Ok(validHtml) => mount(validSelector, validHtml)
84+
}
85+
}
86+
}
87+
88+
let mountSafe = (
89+
selector: string,
90+
html: string,
91+
~onSuccess: Dom.element => unit,
92+
~onError: string => unit,
93+
): unit => {
94+
switch mountString(selector, html) {
95+
| Mounted(element) => onSuccess(element)
96+
| MountPointNotFound(value) => onError("Mount point not found: " ++ value)
97+
| InvalidSelector(value) => onError("Invalid selector: " ++ value)
98+
| InvalidHTML(value) => onError("Invalid HTML: " ++ value)
99+
}
100+
}
101+
102+
type mountSpec = {
103+
selector: string,
104+
html: string,
105+
}
106+
107+
let mountBatch = (specs: array<mountSpec>): result<array<Dom.element>, string> => {
108+
let mounted = []
109+
let rec loop = (index: int): result<array<Dom.element>, string> => {
110+
if index >= Belt.Array.length(specs) {
111+
Ok(mounted)
112+
} else {
113+
switch specs[index] {
114+
| spec =>
115+
switch mountString(spec.selector, spec.html) {
116+
| Mounted(element) =>
117+
mounted->Belt.Array.push(element)
118+
loop(index + 1)
119+
| MountPointNotFound(value) => Error("Mount point not found: " ++ value)
120+
| InvalidSelector(value) => Error("Invalid selector: " ++ value)
121+
| InvalidHTML(value) => Error("Invalid HTML: " ++ value)
122+
}
123+
}
124+
}
125+
}
126+
loop(0)
127+
}
128+
129+
let domReadyState: unit => string = %raw(`() => document.readyState`)
130+
let onDOMContentLoaded: (unit => unit) => unit = %raw(`(callback) => document.addEventListener("DOMContentLoaded", callback)`)
131+
132+
let onDOMReady = (callback: unit => unit): unit => {
133+
let state = domReadyState()
134+
if state == "complete" || state == "interactive" {
135+
callback()
136+
} else {
137+
onDOMContentLoaded(callback)
138+
}
139+
}
140+
141+
let mountWhenReady = (
142+
selector: string,
143+
html: string,
144+
~onSuccess: Dom.element => unit,
145+
~onError: string => unit,
146+
): unit => onDOMReady(() => mountSafe(selector, html, ~onSuccess, ~onError))

0 commit comments

Comments
 (0)