portal-mojo 0.1.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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,386 @@
1
+ // Markdown → block AST. ONE parser, two emitters:
2
+ //
3
+ // · `markdownToNodes` (markdown-fallback.tsx) → React elements. The client
4
+ // fallback path — it never touches innerHTML, so nothing on it can be
5
+ // markup at all.
6
+ // · `markdownToHtml` (here) → an HTML STRING. Used ONLY by the mock's
7
+ // /api/docit/render route, which stands in for django-mojo's renderer so
8
+ // mock and live behave alike. Its output re-enters through the sanitizer
9
+ // exactly like real server HTML — the mock does not get a trusted lane.
10
+ //
11
+ // Parity with the backend renderer (mojo/apps/docit/services/markdown.py —
12
+ // mistune, `render_safe`), which is what /api/docit/render actually runs:
13
+ // · hard_wrap=True → a single newline inside a paragraph is a <br>.
14
+ // · escape=True → raw HTML in the SOURCE is escaped to text, never
15
+ // re-emitted as markup. Both emitters here do the same by construction
16
+ // (React escapes; markdownToHtml runs every text node through esc()).
17
+ //
18
+ // Deliberately a SUBSET of mistune: headings, paragraphs, hr, fenced code,
19
+ // blockquotes, nested/task lists, GFM tables, and inline emphasis / code /
20
+ // links / images / autolinks. Footnotes, math, abbr, spoiler and syntax
21
+ // highlighting are server-only — when the server answers you get them; on the
22
+ // fallback path they degrade to their literal text.
23
+
24
+ export type MdInline =
25
+ | { t: 'text'; v: string }
26
+ | { t: 'br' }
27
+ | { t: 'code'; v: string }
28
+ | { t: 'strong'; c: MdInline[] }
29
+ | { t: 'em'; c: MdInline[] }
30
+ | { t: 'del'; c: MdInline[] }
31
+ | { t: 'link'; href: string; c: MdInline[] }
32
+ | { t: 'img'; src: string; alt: string };
33
+
34
+ export interface MdListItem {
35
+ /** null = a plain bullet; true/false = a `- [x]` / `- [ ]` task item. */
36
+ task: boolean | null;
37
+ /**
38
+ * No blank line inside the item — its paragraphs render WITHOUT a <p>
39
+ * wrapper, which is what keeps `- a` + a nested list on one line instead of
40
+ * growing a paragraph's margins mid-bullet.
41
+ */
42
+ tight: boolean;
43
+ blocks: MdBlock[];
44
+ }
45
+
46
+ export type MdBlock =
47
+ | { t: 'heading'; level: number; c: MdInline[] }
48
+ | { t: 'para'; c: MdInline[] }
49
+ | { t: 'hr' }
50
+ | { t: 'code'; lang: string | null; v: string }
51
+ | { t: 'quote'; blocks: MdBlock[] }
52
+ | { t: 'list'; ordered: boolean; start: number; items: MdListItem[] }
53
+ | { t: 'table'; head: MdInline[][]; rows: MdInline[][][] };
54
+
55
+ // ── Block grammar ────────────────────────────────────────────────────────
56
+ const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)\s*$/;
57
+ const HEADING_RE = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
58
+ const HR_RE = /^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/;
59
+ const QUOTE_RE = /^ {0,3}>[ \t]?(.*)$/;
60
+ const LIST_RE = /^([ \t]*)(?:([-*+])|(\d{1,9})[.)])[ \t]+(.*)$/;
61
+ const TABLE_DIV_RE = /^[ \t]*\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
62
+ /** Guards pathological nesting (a fallback must never hang the tab). */
63
+ const MAX_DEPTH = 6;
64
+
65
+ function indentOf(line: string): number {
66
+ return line.length - line.trimStart().length;
67
+ }
68
+
69
+ /** Drop up to `n` leading spaces — list-item continuation lines. */
70
+ function dedent(line: string, n: number): string {
71
+ let k = 0;
72
+ while (k < n && (line[k] === ' ' || line[k] === '\t')) k++;
73
+ return line.slice(k);
74
+ }
75
+
76
+ /**
77
+ * What a container renders as once MAX_DEPTH is hit: its text as one
78
+ * paragraph. Never "render nothing" — the depth guard is a performance
79
+ * ceiling, not a reason to lose the reader's content.
80
+ */
81
+ function depthLimitBlocks(lines: string[]): MdBlock[] {
82
+ const text = lines.join('\n').trim();
83
+ return text ? [{ t: 'para', c: parseInline(text) }] : [];
84
+ }
85
+
86
+ function startsBlock(line: string): boolean {
87
+ return (
88
+ FENCE_RE.test(line) ||
89
+ HEADING_RE.test(line) ||
90
+ HR_RE.test(line) ||
91
+ QUOTE_RE.test(line) ||
92
+ LIST_RE.test(line)
93
+ );
94
+ }
95
+
96
+ function isTableStart(lines: string[], i: number): boolean {
97
+ const head = lines[i]!;
98
+ const div = lines[i + 1];
99
+ return !!div && head.includes('|') && div.includes('-') && TABLE_DIV_RE.test(div);
100
+ }
101
+
102
+ /** Split a table row on unescaped pipes, dropping the optional outer ones. */
103
+ function splitRow(line: string): string[] {
104
+ let s = line.trim();
105
+ if (s.startsWith('|')) s = s.slice(1);
106
+ if (s.endsWith('|') && !s.endsWith('\\|')) s = s.slice(0, -1);
107
+ return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, '|'));
108
+ }
109
+
110
+ export function parseMarkdown(source: string, depth = 0): MdBlock[] {
111
+ return parseBlocks(source.replace(/\r\n?/g, '\n').split('\n'), depth);
112
+ }
113
+
114
+ function parseBlocks(lines: string[], depth: number): MdBlock[] {
115
+ const out: MdBlock[] = [];
116
+ let i = 0;
117
+ while (i < lines.length) {
118
+ const line = lines[i]!;
119
+ if (!line.trim()) { i++; continue; }
120
+
121
+ const fence = FENCE_RE.exec(line);
122
+ if (fence) {
123
+ const closer = fence[1]![0] === '`' ? /^\s{0,3}`{3,}\s*$/ : /^\s{0,3}~{3,}\s*$/;
124
+ const body: string[] = [];
125
+ i++;
126
+ while (i < lines.length && !closer.test(lines[i]!)) { body.push(lines[i]!); i++; }
127
+ if (i < lines.length) i++; // consume the closing fence
128
+ out.push({ t: 'code', lang: fence[2] || null, v: body.join('\n') });
129
+ continue;
130
+ }
131
+
132
+ const heading = HEADING_RE.exec(line);
133
+ if (heading) {
134
+ out.push({ t: 'heading', level: heading[1]!.length, c: parseInline(heading[2]!) });
135
+ i++;
136
+ continue;
137
+ }
138
+
139
+ // Before the list rule: `---` is a rule, `- x` is a bullet.
140
+ if (HR_RE.test(line)) { out.push({ t: 'hr' }); i++; continue; }
141
+
142
+ if (QUOTE_RE.test(line)) {
143
+ const inner: string[] = [];
144
+ while (i < lines.length) {
145
+ const q = QUOTE_RE.exec(lines[i]!);
146
+ if (!q) break;
147
+ inner.push(q[1]!);
148
+ i++;
149
+ }
150
+ out.push({ t: 'quote', blocks: depth < MAX_DEPTH ? parseBlocks(inner, depth + 1) : depthLimitBlocks(inner) });
151
+ continue;
152
+ }
153
+
154
+ if (isTableStart(lines, i)) {
155
+ const head = splitRow(line).map((c) => parseInline(c));
156
+ i += 2; // header + divider
157
+ const rows: MdInline[][][] = [];
158
+ while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) {
159
+ rows.push(splitRow(lines[i]!).map((c) => parseInline(c)));
160
+ i++;
161
+ }
162
+ out.push({ t: 'table', head, rows });
163
+ continue;
164
+ }
165
+
166
+ const listStart = LIST_RE.exec(line);
167
+ if (listStart) {
168
+ const baseIndent = listStart[1]!.length;
169
+ const ordered = !!listStart[3];
170
+ const listLines: string[] = [];
171
+ while (i < lines.length) {
172
+ const cur = lines[i]!;
173
+ if (!cur.trim()) {
174
+ // A blank line only ends the list when what follows it is
175
+ // neither a sibling marker nor indented item content.
176
+ let j = i + 1;
177
+ while (j < lines.length && !lines[j]!.trim()) j++;
178
+ if (j >= lines.length) break;
179
+ const nextMarker = LIST_RE.exec(lines[j]!);
180
+ const continues =
181
+ indentOf(lines[j]!) > baseIndent ||
182
+ (!!nextMarker && nextMarker[1]!.length === baseIndent);
183
+ if (!continues) break;
184
+ listLines.push('');
185
+ i++;
186
+ continue;
187
+ }
188
+ const marker = LIST_RE.exec(cur);
189
+ const isSibling = !!marker && marker[1]!.length === baseIndent;
190
+ if (!isSibling && indentOf(cur) <= baseIndent) break;
191
+ listLines.push(cur);
192
+ i++;
193
+ }
194
+ out.push({ t: 'list', ordered, start: ordered ? Number(listStart[3]) : 1, items: parseItems(listLines, baseIndent, depth) });
195
+ continue;
196
+ }
197
+
198
+ // Paragraph: run to the next blank line or block start (hard_wrap
199
+ // parity — the interior newlines become <br>, see parseInline).
200
+ const buf: string[] = [line.trim()];
201
+ i++;
202
+ while (i < lines.length && lines[i]!.trim() && !startsBlock(lines[i]!) && !isTableStart(lines, i)) {
203
+ buf.push(lines[i]!.trim());
204
+ i++;
205
+ }
206
+ out.push({ t: 'para', c: parseInline(buf.join('\n')) });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ const TASK_RE = /^\[([ xX])\]\s+(.*)$/;
212
+
213
+ function parseItems(lines: string[], baseIndent: number, depth: number): MdListItem[] {
214
+ const items: MdListItem[] = [];
215
+ let cur: { body: string[]; strip: number } | null = null;
216
+ const flush = () => {
217
+ if (!cur) return;
218
+ const first = cur.body[0] ?? '';
219
+ const task = TASK_RE.exec(first);
220
+ if (task) cur.body[0] = task[2]!;
221
+ items.push({
222
+ task: task ? task[1]!.toLowerCase() === 'x' : null,
223
+ tight: !cur.body.some((l) => !l.trim()),
224
+ blocks: depth < MAX_DEPTH ? parseBlocks(cur.body, depth + 1) : depthLimitBlocks(cur.body),
225
+ });
226
+ cur = null;
227
+ };
228
+ for (const line of lines) {
229
+ const marker = LIST_RE.exec(line);
230
+ if (marker && marker[1]!.length === baseIndent) {
231
+ flush();
232
+ cur = { body: [marker[4]!], strip: marker[0]!.length - marker[4]!.length };
233
+ continue;
234
+ }
235
+ if (cur) cur.body.push(dedent(line, cur.strip));
236
+ }
237
+ flush();
238
+ return items;
239
+ }
240
+
241
+ // ── Inline grammar ───────────────────────────────────────────────────────
242
+ //
243
+ // Every rule is scanned over the WHOLE string (global regexes + lastIndex)
244
+ // rather than over a shrinking slice: it keeps the scan near-linear, and it
245
+ // keeps lookbehind context intact at token boundaries (`snake_case_word` must
246
+ // not become emphasis).
247
+ interface InlineRule {
248
+ re: RegExp;
249
+ make(m: RegExpExecArray, depth: number): MdInline;
250
+ }
251
+
252
+ const INLINE_RULES: InlineRule[] = [
253
+ // Code first: its contents are literal, so nothing inside it is markup.
254
+ { re: /`([^`\n]+)`/g, make: (m) => ({ t: 'code', v: m[1]! }) },
255
+ // The URL part allows ONE level of balanced parens, so wiki-style links
256
+ // (`.../X_(disambiguation)`) survive.
257
+ { re: /!\[([^\]]*)\]\(\s*((?:[^()\s]|\([^()\s]*\))+)(?:\s+"[^"]*")?\s*\)/g, make: (m) => ({ t: 'img', src: m[2]!, alt: m[1] ?? '' }) },
258
+ { re: /\[([^\]]*)\]\(\s*((?:[^()\s]|\([^()\s]*\))+)(?:\s+"[^"]*")?\s*\)/g, make: (m, d) => ({ t: 'link', href: m[2]!, c: parseInline(m[1]!, d + 1) }) },
259
+ { re: /\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, make: (m, d) => ({ t: 'strong', c: [{ t: 'em', c: parseInline(m[1]!, d + 1) }] }) },
260
+ { re: /\*\*(?=\S)([\s\S]*?\S)\*\*/g, make: (m, d) => ({ t: 'strong', c: parseInline(m[1]!, d + 1) }) },
261
+ { re: /(?<![\w\\])__(?=\S)([\s\S]*?\S)__(?!\w)/g, make: (m, d) => ({ t: 'strong', c: parseInline(m[1]!, d + 1) }) },
262
+ { re: /\*(?=\S)([^*\n]*\S|\S)\*/g, make: (m, d) => ({ t: 'em', c: parseInline(m[1]!, d + 1) }) },
263
+ { re: /(?<![\w\\])_(?=\S)([^_\n]*\S|\S)_(?!\w)/g, make: (m, d) => ({ t: 'em', c: parseInline(m[1]!, d + 1) }) },
264
+ { re: /~~(?=\S)([\s\S]*?\S)~~/g, make: (m, d) => ({ t: 'del', c: parseInline(m[1]!, d + 1) }) },
265
+ { re: /<((?:https?:\/\/|mailto:)[^>\s]+)>/g, make: (m) => ({ t: 'link', href: m[1]!, c: [{ t: 'text', v: m[1]! }] }) },
266
+ // Bare autolink (mistune's `url` plugin). The final character class keeps
267
+ // sentence punctuation OUT of the match, so "see https://x/a." links `a`
268
+ // and leaves the period as text.
269
+ {
270
+ re: /(?<![\w/@.:-])https?:\/\/[^\s<>()"']*[^\s<>()"'.,;:!?]/g,
271
+ make: (m) => ({ t: 'link', href: m[0]!, c: [{ t: 'text', v: m[0]! }] }),
272
+ },
273
+ ];
274
+
275
+ /** Split on newlines so hard_wrap's <br> is part of the AST, not the emitter. */
276
+ function pushText(out: MdInline[], text: string): void {
277
+ const parts = text.split('\n');
278
+ parts.forEach((part, idx) => {
279
+ if (idx > 0) out.push({ t: 'br' });
280
+ if (part) out.push({ t: 'text', v: part });
281
+ });
282
+ }
283
+
284
+ export function parseInline(text: string, depth = 0): MdInline[] {
285
+ const out: MdInline[] = [];
286
+ if (!text) return out;
287
+ if (depth >= MAX_DEPTH) { pushText(out, text); return out; }
288
+
289
+ // One live match per rule, refreshed only once the cursor passes it.
290
+ const found: (RegExpExecArray | null)[] = INLINE_RULES.map(() => null);
291
+ const exhausted: boolean[] = INLINE_RULES.map(() => false);
292
+ let pos = 0;
293
+ while (pos < text.length) {
294
+ let bestIdx = -1;
295
+ let best = -1;
296
+ for (let k = 0; k < INLINE_RULES.length; k++) {
297
+ if (exhausted[k]) continue;
298
+ let m = found[k];
299
+ if (!m || m.index < pos) {
300
+ const re = INLINE_RULES[k]!.re;
301
+ re.lastIndex = pos;
302
+ m = re.exec(text);
303
+ found[k] = m;
304
+ if (!m) { exhausted[k] = true; continue; }
305
+ }
306
+ if (best === -1 || m.index < best) { best = m.index; bestIdx = k; }
307
+ }
308
+ if (bestIdx === -1) { pushText(out, text.slice(pos)); return out; }
309
+ const m = found[bestIdx]!;
310
+ if (m.index > pos) pushText(out, text.slice(pos, m.index));
311
+ out.push(INLINE_RULES[bestIdx]!.make(m, depth));
312
+ pos = m.index + m[0]!.length;
313
+ }
314
+ return out;
315
+ }
316
+
317
+ // ── HTML emitter (mock transport only) ───────────────────────────────────
318
+ const ESCAPES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
319
+
320
+ function esc(s: string): string {
321
+ return s.replace(/[&<>"']/g, (ch) => ESCAPES[ch]!);
322
+ }
323
+
324
+ function inlineHtml(nodes: MdInline[]): string {
325
+ return nodes
326
+ .map((n) => {
327
+ switch (n.t) {
328
+ case 'text': return esc(n.v);
329
+ case 'br': return '<br>';
330
+ case 'code': return `<code>${esc(n.v)}</code>`;
331
+ case 'strong': return `<strong>${inlineHtml(n.c)}</strong>`;
332
+ case 'em': return `<em>${inlineHtml(n.c)}</em>`;
333
+ case 'del': return `<del>${inlineHtml(n.c)}</del>`;
334
+ case 'img': return `<img src="${esc(n.src)}" alt="${esc(n.alt)}">`;
335
+ // href is emitted verbatim (escaped) — vetting it is the
336
+ // SANITIZER's job on the way in, same as for real server HTML.
337
+ case 'link': return `<a href="${esc(n.href)}">${inlineHtml(n.c)}</a>`;
338
+ }
339
+ })
340
+ .join('');
341
+ }
342
+
343
+ function itemHtml(item: MdListItem): string {
344
+ const body = item.tight
345
+ ? item.blocks.map((b) => (b.t === 'para' ? inlineHtml(b.c) : blocksHtml([b]))).join('')
346
+ : blocksHtml(item.blocks);
347
+ const box = item.task === null ? '' : `<input type="checkbox" disabled${item.task ? ' checked' : ''}> `;
348
+ return `<li>${box}${body}</li>`;
349
+ }
350
+
351
+ function blocksHtml(blocks: MdBlock[]): string {
352
+ return blocks
353
+ .map((b) => {
354
+ switch (b.t) {
355
+ case 'heading': return `<h${b.level}>${inlineHtml(b.c)}</h${b.level}>`;
356
+ case 'para': return `<p>${inlineHtml(b.c)}</p>`;
357
+ case 'hr': return '<hr>';
358
+ // `language-x` mirrors what a server renderer emits; the
359
+ // sanitizer strips it (only `md-` classes survive) — see
360
+ // markdown-sanitize.ts.
361
+ case 'code': return `<pre><code${b.lang ? ` class="language-${esc(b.lang)}"` : ''}>${esc(b.v)}</code></pre>`;
362
+ case 'quote': return `<blockquote>${blocksHtml(b.blocks)}</blockquote>`;
363
+ case 'list': {
364
+ const tag = b.ordered ? 'ol' : 'ul';
365
+ const startAttr = b.ordered && b.start !== 1 ? ` start="${b.start}"` : '';
366
+ return `<${tag}${startAttr}>${b.items.map(itemHtml).join('')}</${tag}>`;
367
+ }
368
+ case 'table': {
369
+ const head = `<thead><tr>${b.head.map((c) => `<th>${inlineHtml(c)}</th>`).join('')}</tr></thead>`;
370
+ const body = b.rows.map((r) => `<tr>${r.map((c) => `<td>${inlineHtml(c)}</td>`).join('')}</tr>`).join('');
371
+ return `<table>${head}<tbody>${body}</tbody></table>`;
372
+ }
373
+ }
374
+ })
375
+ .join('\n');
376
+ }
377
+
378
+ /**
379
+ * Markdown → HTML string. The MOCK's stand-in for django-mojo's renderer —
380
+ * production code never calls this: it calls `renderMarkdown` (server) or
381
+ * renders `<MarkdownFallback>` (React elements). Raw HTML in the source is
382
+ * escaped, matching mistune's `render_safe`.
383
+ */
384
+ export function markdownToHtml(source: string): string {
385
+ return blocksHtml(parseMarkdown(source));
386
+ }
@@ -0,0 +1,217 @@
1
+ // The allowlist sanitizer — the ONE place in portal-mojo that produces a
2
+ // string destined for `dangerouslySetInnerHTML` (ui/MarkdownView.tsx).
3
+ //
4
+ // THE TRUST MODEL, in one line: server-rendered HTML is UNTRUSTED. django-mojo
5
+ // escapes raw HTML in the markdown source (mistune `render_safe`), but a
6
+ // portal must not be one backend regression away from stored XSS — so every
7
+ // byte of server HTML is re-derived here from an allowlist before it reaches
8
+ // the DOM. Nothing is trusted because of where it came from.
9
+ //
10
+ // How it works:
11
+ // 1. Parse with DOMParser('text/html') — an INERT document: no script runs,
12
+ // no image loads, no `onerror` fires. (Assigning to a live element's
13
+ // innerHTML would already have fetched `<img src=x onerror=...>`.)
14
+ // 2. Walk the tree. Every element is one of three things:
15
+ // · allowlisted → keep it, then scrub its attributes to an allowlist
16
+ // · dangerous → REMOVE it and its whole subtree (script/style/...)
17
+ // · anything else → UNWRAP it: the element goes, its (already
18
+ // sanitized) children stay. Text is never lost — which is what keeps
19
+ // a server syntax-highlighted `<div class=highlight><span class=k>`
20
+ // block readable after its markup is stripped.
21
+ // 3. Serialize with the browser's own serializer (`body.innerHTML`).
22
+ //
23
+ // Fail-closed everywhere: unknown attribute → dropped; unparseable URL →
24
+ // dropped; no DOM available → throws, and MarkdownView falls back to the
25
+ // React-element renderer, which cannot emit markup at all.
26
+
27
+ /** Elements that survive. Everything else is unwrapped or removed. */
28
+ const ALLOWED_TAGS = new Set([
29
+ 'p', 'br', 'hr',
30
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
31
+ 'ul', 'ol', 'li',
32
+ 'strong', 'em', 'del', 'code', 'pre', 'blockquote',
33
+ 'a', 'img',
34
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td',
35
+ // Task lists (`- [x] ...`); constrained hard below to disabled checkboxes.
36
+ 'input',
37
+ // Beyond the base set, because the backend's mistune plugin list emits
38
+ // them (mark, footnote refs) and all three are inert — no URL-bearing
39
+ // attribute, no scripting surface.
40
+ 'mark', 'sup', 'sub',
41
+ ]);
42
+
43
+ /**
44
+ * Removed WITH their subtree — for these, the text content is itself a payload
45
+ * (CSS, script source) or the element is a script/navigation host.
46
+ */
47
+ const DROP_SUBTREE = new Set([
48
+ 'script', 'style', 'iframe', 'object', 'embed', 'applet',
49
+ 'noscript', 'template', 'link', 'meta', 'base', 'title',
50
+ 'frame', 'frameset', 'form', 'button', 'select', 'textarea',
51
+ // Namespaced content the HTML allowlist above cannot reason about.
52
+ 'svg', 'math',
53
+ ]);
54
+
55
+ /** Per-tag attribute allowlist. `class` is handled separately (md- prefix). */
56
+ const ALLOWED_ATTRS: Record<string, Set<string>> = {
57
+ a: new Set(['href']),
58
+ img: new Set(['src', 'alt']),
59
+ input: new Set(['type', 'checked', 'disabled']),
60
+ };
61
+
62
+ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
63
+ const SCHEME_RE = /^([a-z][a-z0-9+.-]*):/i;
64
+ const LINK_SCHEMES = new Set(['http:', 'https:', 'mailto:']);
65
+ const IMG_SCHEMES = new Set(['http:', 'https:']);
66
+ /** Control + zero-width chars: `java&#9;script:` must not sneak past. */
67
+ const URL_NOISE_RE = /[\u0000-\u0020\u007F-\u009F\u200B-\u200D\u2060\uFEFF]/g;
68
+
69
+ export interface SanitizeDrop {
70
+ /** What the sanitizer did: removed, unwrapped, or stripped a value. */
71
+ kind: 'element' | 'unwrapped' | 'attribute' | 'url';
72
+ detail: string;
73
+ }
74
+
75
+ /**
76
+ * Vet a URL. Returns the cleaned value, or null to drop it.
77
+ *
78
+ * Order matters: NORMALIZE first (strip control/zero-width chars — that is how
79
+ * `java&#9;script:` is smuggled), THEN allowlist the scheme. Anything without a
80
+ * recognized scheme is dropped too: a relative link inside rendered markdown
81
+ * would resolve against the hash router, which is never what the author meant.
82
+ */
83
+ function vetUrl(raw: string | null | undefined, schemes: Set<string>, allowFragment: boolean): string | null {
84
+ if (!raw) return null;
85
+ const value = raw.replace(URL_NOISE_RE, '');
86
+ if (!value) return null;
87
+ // Same-document fragments cannot navigate off the page, and the server
88
+ // renderer's footnote/TOC links are made of them.
89
+ if (value.startsWith('#')) return allowFragment ? value : null;
90
+ const scheme = SCHEME_RE.exec(value);
91
+ if (!scheme) return null;
92
+ return schemes.has(`${scheme[1]!.toLowerCase()}:`) ? value : null;
93
+ }
94
+
95
+ /** http / https / mailto / #fragment — anything else is dropped. */
96
+ export function safeLinkUrl(raw: string | null | undefined): string | null {
97
+ return vetUrl(raw, LINK_SCHEMES, true);
98
+ }
99
+
100
+ /** http / https only — `data:image/svg+xml` is a script vector, not an image. */
101
+ export function safeImgUrl(raw: string | null | undefined): string | null {
102
+ return vetUrl(raw, IMG_SCHEMES, false);
103
+ }
104
+
105
+ /** http/https links leave the portal — they get the noopener treatment. */
106
+ function isExternal(url: string): boolean {
107
+ return /^https?:/i.test(url);
108
+ }
109
+
110
+ function scrubAttributes(el: Element, tag: string, onDrop?: (d: SanitizeDrop) => void): void {
111
+ const allowed = ALLOWED_ATTRS[tag];
112
+ for (const attr of Array.from(el.attributes)) {
113
+ const name = attr.name.toLowerCase();
114
+ if (name === 'class') {
115
+ // Only `md-` classes survive, so a document can never reach for an
116
+ // app class (`sidebar`, `panel`) and repaint the portal around it.
117
+ const kept = attr.value.split(/\s+/).filter((c) => c.startsWith('md-'));
118
+ if (kept.length) {
119
+ el.setAttribute('class', kept.join(' '));
120
+ } else {
121
+ el.removeAttribute(attr.name);
122
+ onDrop?.({ kind: 'attribute', detail: `${tag}[class="${attr.value}"]` });
123
+ }
124
+ continue;
125
+ }
126
+ // Everything not explicitly allowed: on* handlers, style, srcset,
127
+ // formaction, xlink:href, data-*, id, ...
128
+ if (!allowed?.has(name)) {
129
+ el.removeAttribute(attr.name);
130
+ onDrop?.({ kind: 'attribute', detail: `${tag}[${name}]` });
131
+ }
132
+ }
133
+ }
134
+
135
+ /** Per-tag value rules. @returns false when the element itself must go. */
136
+ function applyElementRules(el: Element, tag: string, onDrop?: (d: SanitizeDrop) => void): boolean {
137
+ if (tag === 'a') {
138
+ const raw = el.getAttribute('href');
139
+ const href = safeLinkUrl(raw);
140
+ if (!href) {
141
+ if (raw !== null) onDrop?.({ kind: 'url', detail: `a[href="${raw}"]` });
142
+ // The link TEXT stays — only the navigation goes.
143
+ el.removeAttribute('href');
144
+ return true;
145
+ }
146
+ el.setAttribute('href', href);
147
+ if (isExternal(href)) {
148
+ el.setAttribute('target', '_blank');
149
+ el.setAttribute('rel', 'noopener noreferrer');
150
+ }
151
+ return true;
152
+ }
153
+ if (tag === 'img') {
154
+ const raw = el.getAttribute('src');
155
+ const src = safeImgUrl(raw);
156
+ if (!src) {
157
+ onDrop?.({ kind: 'url', detail: `img[src="${raw}"]` });
158
+ return false; // no usable src → nothing to show
159
+ }
160
+ el.setAttribute('src', src);
161
+ return true;
162
+ }
163
+ if (tag === 'input') {
164
+ // Task-list checkboxes only, and never interactive: any other input in
165
+ // rendered markdown is a phishing surface.
166
+ if (el.getAttribute('type')?.toLowerCase() !== 'checkbox') {
167
+ onDrop?.({ kind: 'element', detail: `input[type=${el.getAttribute('type') ?? 'text'}]` });
168
+ return false;
169
+ }
170
+ el.setAttribute('disabled', '');
171
+ return true;
172
+ }
173
+ return true;
174
+ }
175
+
176
+ function sanitizeChildren(parent: Element, onDrop?: (d: SanitizeDrop) => void): void {
177
+ // Snapshot: the live child list mutates as elements are unwrapped/removed.
178
+ for (const node of Array.from(parent.childNodes)) {
179
+ if (node.nodeType === Node.TEXT_NODE) continue;
180
+ if (node.nodeType !== Node.ELEMENT_NODE) {
181
+ node.parentNode?.removeChild(node); // comments, processing instructions
182
+ continue;
183
+ }
184
+ const el = node as Element;
185
+ const tag = el.tagName.toLowerCase();
186
+
187
+ // Non-HTML namespaces (SVG/MathML) go whole: their attribute and
188
+ // element semantics are not what the allowlist above describes.
189
+ if (DROP_SUBTREE.has(tag) || el.namespaceURI !== XHTML_NS) {
190
+ el.remove();
191
+ onDrop?.({ kind: 'element', detail: `<${tag}>` });
192
+ continue;
193
+ }
194
+ if (!ALLOWED_TAGS.has(tag)) {
195
+ sanitizeChildren(el, onDrop); // clean the subtree...
196
+ el.replaceWith(...Array.from(el.childNodes)); // ...then unwrap it
197
+ onDrop?.({ kind: 'unwrapped', detail: `<${tag}>` });
198
+ continue;
199
+ }
200
+ scrubAttributes(el, tag, onDrop);
201
+ if (!applyElementRules(el, tag, onDrop)) { el.remove(); continue; }
202
+ sanitizeChildren(el, onDrop);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Reduce untrusted HTML to the markdown allowlist. Throws when there is no DOM
208
+ * to parse with — callers must fall back to the React-element renderer rather
209
+ * than ship unsanitized markup.
210
+ */
211
+ export function sanitizeMarkdownHtml(html: string, onDrop?: (d: SanitizeDrop) => void): string {
212
+ if (!html) return '';
213
+ if (typeof DOMParser === 'undefined') throw new Error('sanitizeMarkdownHtml requires a DOM (DOMParser)');
214
+ const doc = new DOMParser().parseFromString(html, 'text/html');
215
+ sanitizeChildren(doc.body, onDrop);
216
+ return doc.body.innerHTML;
217
+ }