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.
- package/LICENSE +201 -0
- package/README.md +55 -0
- package/docs/DetailView.md +173 -0
- package/docs/ModelTable.md +128 -0
- package/docs/README.md +98 -0
- package/docs/admin-assistant.md +72 -0
- package/docs/admin-bouncer.md +120 -0
- package/docs/admin-cloudwatch.md +38 -0
- package/docs/admin-credentials.md +116 -0
- package/docs/admin-dashboard.md +15 -0
- package/docs/admin-devices-geoip.md +284 -0
- package/docs/admin-dns-certificates.md +122 -0
- package/docs/admin-dns-records.md +65 -0
- package/docs/admin-dns-registrar.md +33 -0
- package/docs/admin-dns.md +80 -0
- package/docs/admin-identity-users.md +103 -0
- package/docs/admin-incidents.md +71 -0
- package/docs/admin-jobs.md +426 -0
- package/docs/admin-members.md +112 -0
- package/docs/admin-messaging.md +56 -0
- package/docs/admin-metrics-explorer.md +63 -0
- package/docs/admin-monitoring.md +99 -0
- package/docs/admin-network-security.md +393 -0
- package/docs/admin-phonehub.md +27 -0
- package/docs/admin-push.md +36 -0
- package/docs/admin-rules.md +87 -0
- package/docs/admin-security-tickets.md +40 -0
- package/docs/admin-settings.md +78 -0
- package/docs/admin-shortlinks.md +32 -0
- package/docs/admin-storage.md +141 -0
- package/docs/auth-pages.md +212 -0
- package/docs/auth.md +58 -0
- package/docs/calendar.md +122 -0
- package/docs/charts.md +303 -0
- package/docs/client.md +206 -0
- package/docs/collection-multiselect.md +117 -0
- package/docs/collection-select.md +132 -0
- package/docs/combobox.md +105 -0
- package/docs/dataview.md +226 -0
- package/docs/datepicker.md +151 -0
- package/docs/daterange.md +170 -0
- package/docs/datetimepicker.md +249 -0
- package/docs/defineModel.md +72 -0
- package/docs/detail-primitives.md +174 -0
- package/docs/feedback.md +85 -0
- package/docs/file-upload-ux.md +146 -0
- package/docs/forms.md +435 -0
- package/docs/grouping-and-fmt.md +98 -0
- package/docs/idioms.md +112 -0
- package/docs/image-editor.md +101 -0
- package/docs/loading.md +242 -0
- package/docs/location-address.md +76 -0
- package/docs/markdown.md +203 -0
- package/docs/menus-and-access.md +89 -0
- package/docs/multiselect-dropdown.md +153 -0
- package/docs/params.md +73 -0
- package/docs/popover.md +130 -0
- package/docs/realtime.md +61 -0
- package/docs/record-feed.md +238 -0
- package/docs/right-panel.md +42 -0
- package/docs/tabs-and-form-wizard.md +60 -0
- package/docs/taginput.md +120 -0
- package/docs/timepicker.md +173 -0
- package/docs/timezone-select.md +147 -0
- package/docs/worldmap.md +302 -0
- package/package.json +55 -0
- package/src/admin/assistant/AssistantFeed.tsx +29 -0
- package/src/admin/assistant/AssistantPanel.tsx +65 -0
- package/src/admin/assistant/api.ts +17 -0
- package/src/admin/assistant/data.ts +63 -0
- package/src/admin/assistant/index.ts +21 -0
- package/src/admin/assistant/launchers.tsx +33 -0
- package/src/admin/assistant/pages.tsx +78 -0
- package/src/admin/assistant/streaming.ts +238 -0
- package/src/admin/assistant/types.ts +30 -0
- package/src/admin/bouncer/devices.tsx +230 -0
- package/src/admin/bouncer/index.ts +34 -0
- package/src/admin/bouncer/models.ts +199 -0
- package/src/admin/bouncer/signals.tsx +182 -0
- package/src/admin/bouncer/signatures.tsx +220 -0
- package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
- package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
- package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
- package/src/admin/cloudwatch/data.ts +163 -0
- package/src/admin/cloudwatch/index.ts +16 -0
- package/src/admin/credentials/group-api-keys.tsx +433 -0
- package/src/admin/credentials/index.ts +4 -0
- package/src/admin/credentials/models.ts +244 -0
- package/src/admin/credentials/secret-dialog.tsx +77 -0
- package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
- package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
- package/src/admin/dashboard/data.ts +60 -0
- package/src/admin/dashboard/index.ts +2 -0
- package/src/admin/dns/CertificateDetail.tsx +115 -0
- package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
- package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
- package/src/admin/dns/CertificatesPage.tsx +85 -0
- package/src/admin/dns/DnsRecordEditor.tsx +110 -0
- package/src/admin/dns/DnsRecordsPage.tsx +36 -0
- package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
- package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
- package/src/admin/dns/DomainDetail.tsx +41 -0
- package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
- package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
- package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
- package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
- package/src/admin/dns/DomainsPage.tsx +27 -0
- package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
- package/src/admin/dns/RegistrantContactPage.tsx +42 -0
- package/src/admin/dns/api.ts +515 -0
- package/src/admin/dns/certificate-data.ts +221 -0
- package/src/admin/dns/data.ts +32 -0
- package/src/admin/dns/dns-data.ts +266 -0
- package/src/admin/dns/dns-integration.ts +24 -0
- package/src/admin/dns/domain-links.ts +37 -0
- package/src/admin/dns/index.ts +86 -0
- package/src/admin/dns/models.ts +418 -0
- package/src/admin/dns/purchase-data.ts +67 -0
- package/src/admin/dns/registrant-data.ts +52 -0
- package/src/admin/identity/index.ts +5 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
- package/src/admin/identity/members/MemberDetail.tsx +226 -0
- package/src/admin/identity/members/MembersPage.tsx +144 -0
- package/src/admin/identity/members/index.ts +24 -0
- package/src/admin/identity/members/member-flows.tsx +132 -0
- package/src/admin/identity/members/models.ts +204 -0
- package/src/admin/identity/users/UserDetail.tsx +257 -0
- package/src/admin/identity/users/UsersPage.tsx +163 -0
- package/src/admin/identity/users/index.ts +27 -0
- package/src/admin/identity/users/models.ts +232 -0
- package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
- package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
- package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
- package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
- package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
- package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
- package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
- package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
- package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
- package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
- package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
- package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
- package/src/admin/identity/users/sections/actions.tsx +752 -0
- package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
- package/src/admin/identity/users/sections/queries.ts +74 -0
- package/src/admin/identity/users/sections/shared.tsx +299 -0
- package/src/admin/incidents/EventDetail.tsx +43 -0
- package/src/admin/incidents/EventsPage.tsx +52 -0
- package/src/admin/incidents/IncidentDetail.tsx +68 -0
- package/src/admin/incidents/IncidentsPage.tsx +102 -0
- package/src/admin/incidents/forensics.tsx +73 -0
- package/src/admin/incidents/index.ts +16 -0
- package/src/admin/incidents/models.ts +113 -0
- package/src/admin/incidents/sanitize.ts +71 -0
- package/src/admin/index.ts +383 -0
- package/src/admin/jobs/JobDashboardPage.tsx +111 -0
- package/src/admin/jobs/JobDetail.tsx +721 -0
- package/src/admin/jobs/JobRunnersPage.tsx +216 -0
- package/src/admin/jobs/JobsTablePage.tsx +122 -0
- package/src/admin/jobs/RunnerDetail.tsx +576 -0
- package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
- package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
- package/src/admin/jobs/columns.tsx +297 -0
- package/src/admin/jobs/control.ts +488 -0
- package/src/admin/jobs/index.ts +85 -0
- package/src/admin/jobs/models.ts +599 -0
- package/src/admin/jobs/queries.ts +272 -0
- package/src/admin/jobs/scheduled-task-form.tsx +493 -0
- package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
- package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
- package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
- package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
- package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
- package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
- package/src/admin/messaging/MailboxesPage.tsx +15 -0
- package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
- package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
- package/src/admin/messaging/SentMessagesPage.tsx +11 -0
- package/src/admin/messaging/api.ts +17 -0
- package/src/admin/messaging/data.ts +10 -0
- package/src/admin/messaging/index.ts +23 -0
- package/src/admin/messaging/models.ts +76 -0
- package/src/admin/messaging/push/PushPage.tsx +44 -0
- package/src/admin/messaging/push/api.ts +17 -0
- package/src/admin/messaging/push/index.ts +9 -0
- package/src/admin/messaging/push/models.ts +43 -0
- package/src/admin/messaging/sanitize.ts +44 -0
- package/src/admin/monitoring/LogInspector.tsx +160 -0
- package/src/admin/monitoring/LogsPage.tsx +135 -0
- package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
- package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
- package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
- package/src/admin/monitoring/index.ts +55 -0
- package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
- package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
- package/src/admin/monitoring/models.ts +155 -0
- package/src/admin/network/BlockedIPsPage.tsx +194 -0
- package/src/admin/network/BlockedIpDetail.tsx +262 -0
- package/src/admin/network/FirewallLogPage.tsx +115 -0
- package/src/admin/network/IPSetDetail.tsx +280 -0
- package/src/admin/network/IPSetEditor.tsx +191 -0
- package/src/admin/network/IPSetsPage.tsx +177 -0
- package/src/admin/network/geofence/BlocksTab.tsx +289 -0
- package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
- package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
- package/src/admin/network/geofence/PostureHeader.tsx +184 -0
- package/src/admin/network/geofence/RuleEditor.tsx +176 -0
- package/src/admin/network/geofence/RulesTab.tsx +254 -0
- package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
- package/src/admin/network/geofence/geofence-data.ts +674 -0
- package/src/admin/network/index.ts +68 -0
- package/src/admin/network/models.ts +607 -0
- package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
- package/src/admin/phonehub/api.ts +47 -0
- package/src/admin/phonehub/data.ts +6 -0
- package/src/admin/phonehub/index.ts +10 -0
- package/src/admin/phonehub/models.ts +68 -0
- package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
- package/src/admin/rules/RuleSetsPage.tsx +33 -0
- package/src/admin/rules/editors.tsx +54 -0
- package/src/admin/rules/handler-dsl.ts +213 -0
- package/src/admin/rules/index.ts +13 -0
- package/src/admin/rules/models.ts +112 -0
- package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
- package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
- package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
- package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
- package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
- package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
- package/src/admin/security/devices/index.ts +40 -0
- package/src/admin/security/devices/models.ts +470 -0
- package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
- package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
- package/src/admin/security/geoip/geoip-forms.ts +102 -0
- package/src/admin/security/geoip/index.ts +27 -0
- package/src/admin/security/geoip/models.ts +397 -0
- package/src/admin/security/index.ts +31 -0
- package/src/admin/security/models.ts +137 -0
- package/src/admin/security/tickets.tsx +562 -0
- package/src/admin/security-permissions.ts +5 -0
- package/src/admin/settings/SettingDetail.tsx +57 -0
- package/src/admin/settings/SettingEditor.tsx +157 -0
- package/src/admin/settings/SettingsPage.tsx +72 -0
- package/src/admin/settings/index.ts +19 -0
- package/src/admin/settings/model.ts +143 -0
- package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
- package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
- package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
- package/src/admin/shortlinks/index.ts +18 -0
- package/src/admin/shortlinks/models.ts +246 -0
- package/src/admin/storage/BackendsPage.tsx +43 -0
- package/src/admin/storage/BucketDetail.tsx +77 -0
- package/src/admin/storage/BucketsPage.tsx +64 -0
- package/src/admin/storage/FileManagerDetail.tsx +36 -0
- package/src/admin/storage/FilePreview.tsx +54 -0
- package/src/admin/storage/FileUploadSurface.tsx +250 -0
- package/src/admin/storage/FileView.tsx +109 -0
- package/src/admin/storage/FilesPage.tsx +53 -0
- package/src/admin/storage/file-renditions.ts +99 -0
- package/src/admin/storage/index.ts +31 -0
- package/src/admin/storage/models.ts +585 -0
- package/src/admin/storage/storage-dialogs.tsx +80 -0
- package/src/charts/CircularProgress.tsx +281 -0
- package/src/charts/KPIStrip.tsx +162 -0
- package/src/charts/KPITile.tsx +125 -0
- package/src/charts/MetricsChart.tsx +459 -0
- package/src/charts/MetricsMiniWidget.tsx +588 -0
- package/src/charts/MiniChart.tsx +283 -0
- package/src/charts/PieChart.tsx +354 -0
- package/src/charts/SeriesChart.tsx +378 -0
- package/src/charts/chart-dialogs.tsx +149 -0
- package/src/charts/exportChart.ts +144 -0
- package/src/charts/index.ts +28 -0
- package/src/charts/pie-math.ts +127 -0
- package/src/charts/stats.ts +265 -0
- package/src/charts/worldmap/WorldMap.tsx +649 -0
- package/src/charts/worldmap/countryCentroids.ts +304 -0
- package/src/charts/worldmap/geo.ts +293 -0
- package/src/charts/worldmap/use-world-land.ts +47 -0
- package/src/charts/worldmap/world-land.ts +18 -0
- package/src/charts/worldmap/worldmap-data.ts +285 -0
- package/src/client/auth.ts +737 -0
- package/src/client/client.ts +268 -0
- package/src/client/duid.ts +31 -0
- package/src/client/errors.ts +33 -0
- package/src/client/group-context.ts +28 -0
- package/src/client/group.tsx +126 -0
- package/src/client/hooks.ts +50 -0
- package/src/client/index.ts +37 -0
- package/src/client/jwt.ts +75 -0
- package/src/client/location.ts +285 -0
- package/src/client/lookups.ts +41 -0
- package/src/client/markdown-fallback.tsx +115 -0
- package/src/client/markdown-parse.ts +386 -0
- package/src/client/markdown-sanitize.ts +217 -0
- package/src/client/markdown.ts +122 -0
- package/src/client/me.ts +188 -0
- package/src/client/mock.ts +9984 -0
- package/src/client/model.ts +227 -0
- package/src/client/params.ts +292 -0
- package/src/client/realtime-mock.ts +221 -0
- package/src/client/realtime.ts +561 -0
- package/src/client/record-feed.ts +329 -0
- package/src/client/safe-export.ts +71 -0
- package/src/client/types.ts +278 -0
- package/src/client/upload.ts +421 -0
- package/src/ui/AddressField.tsx +273 -0
- package/src/ui/AttachmentQueue.tsx +92 -0
- package/src/ui/CollectionMultiSelect.tsx +622 -0
- package/src/ui/CollectionSelect.tsx +517 -0
- package/src/ui/ComboBox.tsx +336 -0
- package/src/ui/DetailView.tsx +438 -0
- package/src/ui/FileDrop.tsx +194 -0
- package/src/ui/FileField.tsx +369 -0
- package/src/ui/FilterBar.tsx +269 -0
- package/src/ui/FormFields.tsx +77 -0
- package/src/ui/FormView.tsx +360 -0
- package/src/ui/FormWizard.tsx +336 -0
- package/src/ui/GroupSwitcher.tsx +202 -0
- package/src/ui/Guarded.tsx +16 -0
- package/src/ui/MarkdownView.tsx +89 -0
- package/src/ui/ModelTable.tsx +848 -0
- package/src/ui/MultiSelectDropdown.tsx +390 -0
- package/src/ui/Popover.tsx +204 -0
- package/src/ui/RecordFeed.tsx +627 -0
- package/src/ui/RequiresGroup.tsx +32 -0
- package/src/ui/RightPanel.tsx +107 -0
- package/src/ui/SidebarNav.tsx +330 -0
- package/src/ui/Tabs.tsx +161 -0
- package/src/ui/TagInput.tsx +381 -0
- package/src/ui/ThemeProvider.tsx +52 -0
- package/src/ui/UploadQueue.tsx +603 -0
- package/src/ui/armed-button.tsx +91 -0
- package/src/ui/date/Calendar.tsx +498 -0
- package/src/ui/date/DatePicker.tsx +289 -0
- package/src/ui/date/DateRangePicker.tsx +280 -0
- package/src/ui/date/DateTimePicker.tsx +587 -0
- package/src/ui/date/PresetRail.tsx +165 -0
- package/src/ui/date/TimePicker.tsx +624 -0
- package/src/ui/date/TimezoneSelect.tsx +267 -0
- package/src/ui/date/fns.ts +679 -0
- package/src/ui/detail/DataView.tsx +692 -0
- package/src/ui/detail/FlowStrip.tsx +80 -0
- package/src/ui/detail/KnownFieldsCard.tsx +121 -0
- package/src/ui/detail/MetadataSection.tsx +219 -0
- package/src/ui/detail/StackTraceView.tsx +150 -0
- package/src/ui/detail/StatusPanel.tsx +73 -0
- package/src/ui/detail/Timeline.tsx +52 -0
- package/src/ui/detail/index.ts +12 -0
- package/src/ui/field-registry.tsx +428 -0
- package/src/ui/field-wire.ts +322 -0
- package/src/ui/form-autosave.ts +583 -0
- package/src/ui/format.ts +444 -0
- package/src/ui/grouping.ts +150 -0
- package/src/ui/image-editor/ImageEditor.tsx +484 -0
- package/src/ui/image-editor/canvas.ts +254 -0
- package/src/ui/image-editor/index.ts +3 -0
- package/src/ui/image-editor/math.ts +416 -0
- package/src/ui/index.ts +58 -0
- package/src/ui/loading.tsx +604 -0
- package/src/ui/menu-registry.ts +213 -0
- package/src/ui/modal.tsx +297 -0
- package/src/ui/password.tsx +302 -0
- package/src/ui/schema-form-core.tsx +315 -0
- package/src/ui/toast.tsx +219 -0
- 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> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
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	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	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
|
+
}
|