@agent-native/core 0.134.0 → 0.135.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/corpus/README.md +3 -3
- package/corpus/core/CHANGELOG.md +113 -0
- package/corpus/core/docs/content/template-content-developers.mdx +1 -1
- package/corpus/core/package.json +3 -2
- package/corpus/core/src/automations/service.ts +41 -4
- package/corpus/core/src/cli/mcp-config-writers.ts +27 -21
- package/corpus/core/src/client/agent-page/AgentJobsTab.tsx +187 -85
- package/corpus/core/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
- package/corpus/core/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
- package/corpus/core/src/client/agent-page/TimezoneSelect.tsx +241 -0
- package/corpus/core/src/client/agent-page/use-jobs.ts +50 -10
- package/corpus/core/src/client/org/OrgSwitcher.tsx +26 -0
- package/corpus/core/src/client/settings/AccountSettingsCard.tsx +35 -31
- package/corpus/core/src/client/settings/SchedulingTimezoneField.tsx +71 -0
- package/corpus/core/src/client/settings/SettingsRow.tsx +113 -0
- package/corpus/core/src/client/settings/agent-settings-search.ts +2 -1
- package/corpus/core/src/client/settings/index.ts +6 -0
- package/corpus/core/src/integrations/identity.ts +2 -12
- package/corpus/core/src/jobs/actions/list-automation-runs.ts +33 -0
- package/corpus/core/src/jobs/actions/list-recurring-jobs.ts +27 -6
- package/corpus/core/src/jobs/actions/manage-recurring-job.ts +41 -9
- package/corpus/core/src/jobs/background-automation-runner.ts +88 -0
- package/corpus/core/src/jobs/cron.ts +52 -6
- package/corpus/core/src/jobs/frontmatter.ts +20 -0
- package/corpus/core/src/jobs/run-history.ts +242 -0
- package/corpus/core/src/jobs/scheduler.ts +73 -18
- package/corpus/core/src/jobs/tools.ts +54 -6
- package/corpus/core/src/localization/actions/get-localization-preference.ts +2 -2
- package/corpus/core/src/localization/actions/set-localization-preference.ts +49 -11
- package/corpus/core/src/localization/default-messages.ts +27 -0
- package/corpus/core/src/localization/shared.ts +39 -6
- package/corpus/core/src/localization/user-timezone.ts +36 -0
- package/corpus/core/src/org/app-roles-handlers.ts +1 -9
- package/corpus/core/src/org/index.ts +2 -0
- package/corpus/core/src/org/membership.ts +18 -0
- package/corpus/core/src/review/actions/create-review-comment.ts +4 -1
- package/corpus/core/src/review/actions/reply-review-comment.ts +4 -1
- package/corpus/core/src/review/index.ts +5 -0
- package/corpus/core/src/review/notifications.ts +163 -0
- package/corpus/core/src/review/types.ts +7 -0
- package/corpus/core/src/server/action-discovery.ts +4 -0
- package/corpus/core/src/server/activity-notifications.ts +174 -0
- package/corpus/core/src/server/app-name.ts +31 -0
- package/corpus/core/src/server/better-auth-instance.ts +4 -4
- package/corpus/core/src/server/email-templates.ts +70 -14
- package/corpus/core/src/server/email.ts +66 -3
- package/corpus/core/src/server/index.ts +10 -0
- package/corpus/core/src/sharing/index.ts +5 -0
- package/corpus/core/src/sharing/recipients.ts +67 -0
- package/corpus/core/src/triggers/actions/list-automations.ts +27 -8
- package/corpus/core/src/triggers/actions/manage-automation.ts +17 -6
- package/corpus/core/src/triggers/actions.ts +16 -4
- package/corpus/core/src/triggers/dispatcher.ts +44 -30
- package/corpus/core/src/triggers/routes.ts +19 -6
- package/corpus/core/src/vitest-config.ts +68 -0
- package/corpus/core/vitest.config.ts +5 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +37 -53
- package/corpus/templates/analytics/changelog/2026-08-01-new-error-alerts-now-reach-you-by-email-and-a-scheduled-dash.md +6 -0
- package/corpus/templates/analytics/server/jobs/dashboard-report.ts +43 -0
- package/corpus/templates/analytics/server/lib/error-capture.ts +5 -1
- package/corpus/templates/analytics/vitest.config.ts +16 -12
- package/corpus/templates/assets/actions/refresh-generation-run.ts +3 -0
- package/corpus/templates/assets/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/assets/app/hooks/use-assets-prefs.ts +55 -0
- package/corpus/templates/assets/app/i18n-data.ts +30 -0
- package/corpus/templates/assets/app/routes/settings.tsx +58 -55
- package/corpus/templates/assets/changelog/2026-08-01-get-an-email-when-a-generation-you-started-finishes-or-fails.md +6 -0
- package/corpus/templates/assets/server/lib/generation-run-notifications.ts +89 -0
- package/corpus/templates/assets/server/lib/video-runs.ts +8 -1
- package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.get.ts +17 -0
- package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.put.ts +36 -0
- package/corpus/templates/assets/shared/assets-user-prefs.ts +10 -0
- package/corpus/templates/assets/vitest.config.ts +16 -12
- package/corpus/templates/brain/app/routes/settings.tsx +15 -15
- package/corpus/templates/brain/vitest.config.ts +16 -12
- package/corpus/templates/calendar/app/pages/Settings.tsx +32 -40
- package/corpus/templates/calendar/changelog/2026-08-01-language-and-appearance-now-sit-together-in-one-preferences-.md +6 -0
- package/corpus/templates/calendar/vitest.config.ts +16 -12
- package/corpus/templates/chat/app/routes/settings.tsx +14 -22
- package/corpus/templates/chat/vitest.config.ts +9 -0
- package/corpus/templates/clips/.agents/skills/video-editing/SKILL.md +31 -0
- package/corpus/templates/clips/AGENTS.md +1 -0
- package/corpus/templates/clips/actions/add-comment.ts +12 -1
- package/corpus/templates/clips/actions/create-meeting.ts +2 -2
- package/corpus/templates/clips/actions/create-recording.ts +2 -2
- package/corpus/templates/clips/actions/finalize-recording.ts +42 -6
- package/corpus/templates/clips/actions/generate-filmstrip.ts +154 -0
- package/corpus/templates/clips/actions/get-recording-player-data.ts +6 -0
- package/corpus/templates/clips/actions/import-loom-recording.ts +2 -2
- package/corpus/templates/clips/actions/lib/ensure-recording-filmstrip.ts +258 -0
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +11 -3
- package/corpus/templates/clips/actions/react-to-comment.ts +13 -0
- package/corpus/templates/clips/actions/react-to-recording.ts +10 -1
- package/corpus/templates/clips/actions/reply-to-comment.ts +12 -1
- package/corpus/templates/clips/actions/stitch-recordings.ts +2 -2
- package/corpus/templates/clips/app/components/editor/editor-layout.tsx +152 -23
- package/corpus/templates/clips/app/components/editor/waveform.tsx +237 -75
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +23 -1
- package/corpus/templates/clips/app/components/settings/ai-setup-section.tsx +441 -0
- package/corpus/templates/clips/app/components/settings/slack-section.tsx +329 -0
- package/corpus/templates/clips/app/components/settings/types.ts +14 -0
- package/corpus/templates/clips/app/components/settings/video-storage-section.tsx +443 -0
- package/corpus/templates/clips/app/hooks/use-secret-status.ts +69 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +9 -1
- package/corpus/templates/clips/app/lib/video-filmstrip.ts +262 -0
- package/corpus/templates/clips/app/routes/_app.settings._index.tsx +165 -1351
- package/corpus/templates/clips/changelog/2026-07-27-saved-clips-now-resume-from-the-last-uploaded-byte-after-an-.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-29-meeting-notes-no-longer-transcribe-the-other-side-twice-when.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-31-editor-timeline-now-shows-video-frames-behind-the-waveform-g.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-31-email-notifications-now-go-out-when-someone-comments-on-or-r.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-31-you-can-now-set-a-default-visibility-for-new-recordings-in-s.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-01-email-notifications-for-comments-and-reactions-now-actually-.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-the-desktop-app-now-opens-directly-beneath-its-tray-icon-on-.md +6 -0
- package/corpus/templates/clips/chrome-extension/package.json +1 -0
- package/corpus/templates/clips/chrome-extension/vitest.config.ts +9 -0
- package/corpus/templates/clips/desktop/package.json +1 -0
- package/corpus/templates/clips/desktop/src/app.tsx +17 -1
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +37 -48
- package/corpus/templates/clips/desktop/src/lib/recorder.ts +331 -36
- package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +7 -5
- package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +182 -106
- package/corpus/templates/clips/desktop/src/lib/upload-recovery.ts +129 -0
- package/corpus/templates/clips/desktop/src/overlays/live-transcript.tsx +43 -14
- package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +3 -3
- package/corpus/templates/clips/desktop/src-tauri/src/echo_guard.rs +443 -0
- package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
- package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +815 -15
- package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +61 -11
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +125 -16
- package/corpus/templates/clips/desktop/vitest.config.ts +9 -0
- package/corpus/templates/clips/server/db/schema.ts +17 -0
- package/corpus/templates/clips/server/jobs/media-verification.ts +4 -0
- package/corpus/templates/clips/server/lib/activity-notifications.ts +185 -0
- package/corpus/templates/clips/server/lib/recording-media-cleanup.ts +2 -0
- package/corpus/templates/clips/server/lib/recording-upload-state.ts +46 -9
- package/corpus/templates/clips/server/lib/recordings.ts +24 -0
- package/corpus/templates/clips/server/lib/resumable-session.ts +10 -4
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +109 -0
- package/corpus/templates/clips/server/lib/upload-interruption.ts +8 -0
- package/corpus/templates/clips/server/lib/upload-lease.ts +50 -4
- package/corpus/templates/clips/server/lib/video-filmstrip-sprite.ts +207 -0
- package/corpus/templates/clips/server/lib/video-remux.ts +13 -4
- package/corpus/templates/clips/server/plugins/db.ts +16 -0
- package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +4 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +123 -36
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +159 -29
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/interrupt.post.ts +197 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +193 -39
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/resume.get.ts +191 -12
- package/corpus/templates/clips/shared/clips-ai-prefs.ts +6 -0
- package/corpus/templates/clips/shared/feature-flags.ts +12 -0
- package/corpus/templates/clips/shared/recording-core.ts +4 -0
- package/corpus/templates/clips/vitest.config.ts +23 -19
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -4
- package/corpus/templates/content/actions/_database-membership-lock.ts +25 -0
- package/corpus/templates/content/actions/_database-row-batch.ts +6 -2
- package/corpus/templates/content/actions/_database-source-utils.ts +289 -129
- package/corpus/templates/content/actions/_database-utils.ts +12 -3
- package/corpus/templates/content/actions/add-comment.ts +15 -1
- package/corpus/templates/content/actions/add-content-database-source-field-property.ts +39 -21
- package/corpus/templates/content/actions/attach-content-database-source.ts +4 -1
- package/corpus/templates/content/actions/change-content-database-source-role.ts +3 -5
- package/corpus/templates/content/actions/duplicate-document-property.ts +59 -46
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +77 -71
- package/corpus/templates/content/actions/list-documents.ts +1 -0
- package/corpus/templates/content/actions/materialize-builder-required-fields.ts +7 -0
- package/corpus/templates/content/actions/remove-database-items.ts +183 -0
- package/corpus/templates/content/actions/set-document-property.ts +57 -39
- package/corpus/templates/content/actions/sync-local-folder-source.ts +7 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +5 -2
- package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +50 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +471 -329
- package/corpus/templates/content/app/components/editor/database/row-access.ts +45 -0
- package/corpus/templates/content/app/components/editor/database/shared.tsx +141 -38
- package/corpus/templates/content/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +2 -2
- package/corpus/templates/content/app/hooks/use-content-prefs.ts +55 -0
- package/corpus/templates/content/app/i18n-data.ts +170 -0
- package/corpus/templates/content/app/routes/_app.settings.tsx +47 -18
- package/corpus/templates/content/changelog/2026-07-30-breadcrumb-menus-now-hide-internal-system-pages.md +6 -0
- package/corpus/templates/content/changelog/2026-07-30-database-rows-can-now-be-removed-without-deleting-their-page.md +6 -0
- package/corpus/templates/content/changelog/2026-08-01-get-an-email-when-someone-comments-on-replies-in-or-mentions.md +6 -0
- package/corpus/templates/content/parity/eval-scenarios.ts +2 -2
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +4 -5
- package/corpus/templates/content/server/lib/comment-notifications.ts +146 -0
- package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.get.ts +17 -0
- package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.put.ts +36 -0
- package/corpus/templates/content/shared/api.ts +4 -0
- package/corpus/templates/content/shared/content-user-prefs.ts +11 -0
- package/corpus/templates/content/vitest.config.ts +20 -16
- package/corpus/templates/crm/app/routes/settings.tsx +14 -22
- package/corpus/templates/crm/vitest.config.ts +8 -4
- package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +26 -11
- package/corpus/templates/design/actions/take-design-screenshot.ts +7 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +11 -3
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +4 -1
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +54 -17
- package/corpus/templates/design/app/components/design/design-canvas/local-runtime.ts +126 -0
- package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +2 -0
- package/corpus/templates/design/app/pages/Index.tsx +3 -1
- package/corpus/templates/design/app/pages/Present.tsx +2 -1
- package/corpus/templates/design/app/routes/settings.tsx +14 -22
- package/corpus/templates/design/changelog/2026-07-30-a-broken-inline-script-in-a-generated-screen-is-now-caught-o.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-design-html-is-now-validated-with-a-spec-grade-html-parser-i.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-designs-now-load-their-tailwind-and-alpine-runtimes-from-the.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-designs-with-a-broken-alpine-expression-are-now-caught-on-sa.md +6 -0
- package/corpus/templates/design/changelog/2026-07-30-editing-a-design-no-longer-strips-its-styling-the-canvas-kep.md +6 -0
- package/corpus/templates/design/changelog/2026-08-01-review-comments-replies-and-mentions-now-send-an-email-to-th.md +6 -0
- package/corpus/templates/design/package.json +4 -0
- package/corpus/templates/design/shared/html-integrity.ts +802 -556
- package/corpus/templates/design/vitest.config.ts +18 -12
- package/corpus/templates/dispatch/app/routes/settings.tsx +26 -40
- package/corpus/templates/dispatch/vitest.config.ts +11 -7
- package/corpus/templates/factory/app/routes/settings.tsx +14 -22
- package/corpus/templates/factory/vitest.config.ts +9 -0
- package/corpus/templates/forms/app/routes/_app.settings.tsx +14 -22
- package/corpus/templates/forms/vitest.config.ts +27 -23
- package/corpus/templates/macros/app/routes/settings.tsx +14 -22
- package/corpus/templates/macros/vitest.config.ts +16 -12
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +14 -16
- package/corpus/templates/mail/vitest.config.ts +16 -12
- package/corpus/templates/plan/app/routes/settings.tsx +30 -44
- package/corpus/templates/plan/vitest.config.ts +24 -20
- package/corpus/templates/slides/actions/add-slide-comment.ts +12 -1
- package/corpus/templates/slides/app/components/ui/switch.tsx +1 -0
- package/corpus/templates/slides/app/hooks/use-slides-prefs.ts +55 -0
- package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
- package/corpus/templates/slides/app/routes/settings.tsx +45 -22
- package/corpus/templates/slides/changelog/2026-08-01-get-an-email-when-someone-comments-on-or-replies-in-your-dec.md +6 -0
- package/corpus/templates/slides/server/lib/comment-notifications.ts +182 -0
- package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.get.ts +16 -0
- package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.put.ts +36 -0
- package/corpus/templates/slides/shared/slides-user-prefs.ts +11 -0
- package/corpus/templates/slides/vitest.config.ts +17 -13
- package/corpus/templates/tasks/app/components/layout/Sidebar.tsx +2 -0
- package/corpus/templates/tasks/app/routes/settings.tsx +14 -22
- package/corpus/templates/tasks/vitest.config.ts +17 -13
- package/corpus/toolkit/CHANGELOG.md +9 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/vitest.config.ts +3 -0
- package/dist/automations/service.d.ts +2 -0
- package/dist/automations/service.d.ts.map +1 -1
- package/dist/automations/service.js +29 -4
- package/dist/automations/service.js.map +1 -1
- package/dist/cli/mcp-config-writers.d.ts.map +1 -1
- package/dist/cli/mcp-config-writers.js +24 -23
- package/dist/cli/mcp-config-writers.js.map +1 -1
- package/dist/client/agent-page/AgentJobsTab.d.ts.map +1 -1
- package/dist/client/agent-page/AgentJobsTab.js +86 -29
- package/dist/client/agent-page/AgentJobsTab.js.map +1 -1
- package/dist/client/agent-page/AutomationDetailsDialog.d.ts +21 -0
- package/dist/client/agent-page/AutomationDetailsDialog.d.ts.map +1 -0
- package/dist/client/agent-page/AutomationDetailsDialog.js +33 -0
- package/dist/client/agent-page/AutomationDetailsDialog.js.map +1 -0
- package/dist/client/agent-page/AutomationScheduleDialog.d.ts +15 -0
- package/dist/client/agent-page/AutomationScheduleDialog.d.ts.map +1 -0
- package/dist/client/agent-page/AutomationScheduleDialog.js +46 -0
- package/dist/client/agent-page/AutomationScheduleDialog.js.map +1 -0
- package/dist/client/agent-page/TimezoneSelect.d.ts +17 -0
- package/dist/client/agent-page/TimezoneSelect.d.ts.map +1 -0
- package/dist/client/agent-page/TimezoneSelect.js +107 -0
- package/dist/client/agent-page/TimezoneSelect.js.map +1 -0
- package/dist/client/agent-page/use-jobs.d.ts +20 -7
- package/dist/client/agent-page/use-jobs.d.ts.map +1 -1
- package/dist/client/agent-page/use-jobs.js +24 -4
- package/dist/client/agent-page/use-jobs.js.map +1 -1
- package/dist/client/org/OrgSwitcher.d.ts +8 -1
- package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
- package/dist/client/org/OrgSwitcher.js +8 -2
- package/dist/client/org/OrgSwitcher.js.map +1 -1
- package/dist/client/settings/AccountSettingsCard.d.ts.map +1 -1
- package/dist/client/settings/AccountSettingsCard.js +10 -9
- package/dist/client/settings/AccountSettingsCard.js.map +1 -1
- package/dist/client/settings/SchedulingTimezoneField.d.ts +10 -0
- package/dist/client/settings/SchedulingTimezoneField.d.ts.map +1 -0
- package/dist/client/settings/SchedulingTimezoneField.js +30 -0
- package/dist/client/settings/SchedulingTimezoneField.js.map +1 -0
- package/dist/client/settings/SettingsRow.d.ts +36 -0
- package/dist/client/settings/SettingsRow.d.ts.map +1 -0
- package/dist/client/settings/SettingsRow.js +15 -0
- package/dist/client/settings/SettingsRow.js.map +1 -0
- package/dist/client/settings/agent-settings-search.d.ts.map +1 -1
- package/dist/client/settings/agent-settings-search.js +1 -1
- package/dist/client/settings/agent-settings-search.js.map +1 -1
- package/dist/client/settings/index.d.ts +1 -0
- package/dist/client/settings/index.d.ts.map +1 -1
- package/dist/client/settings/index.js +1 -0
- package/dist/client/settings/index.js.map +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/integrations/identity.d.ts.map +1 -1
- package/dist/integrations/identity.js +2 -11
- package/dist/integrations/identity.js.map +1 -1
- package/dist/jobs/actions/list-automation-runs.d.ts +8 -0
- package/dist/jobs/actions/list-automation-runs.d.ts.map +1 -0
- package/dist/jobs/actions/list-automation-runs.js +29 -0
- package/dist/jobs/actions/list-automation-runs.js.map +1 -0
- package/dist/jobs/actions/list-recurring-jobs.d.ts +2 -0
- package/dist/jobs/actions/list-recurring-jobs.d.ts.map +1 -1
- package/dist/jobs/actions/list-recurring-jobs.js +20 -7
- package/dist/jobs/actions/list-recurring-jobs.js.map +1 -1
- package/dist/jobs/actions/manage-recurring-job.d.ts +2 -0
- package/dist/jobs/actions/manage-recurring-job.js +30 -9
- package/dist/jobs/actions/manage-recurring-job.js.map +1 -1
- package/dist/jobs/background-automation-runner.d.ts.map +1 -1
- package/dist/jobs/background-automation-runner.js +60 -0
- package/dist/jobs/background-automation-runner.js.map +1 -1
- package/dist/jobs/cron.d.ts +16 -2
- package/dist/jobs/cron.d.ts.map +1 -1
- package/dist/jobs/cron.js +43 -6
- package/dist/jobs/cron.js.map +1 -1
- package/dist/jobs/frontmatter.d.ts +12 -0
- package/dist/jobs/frontmatter.d.ts.map +1 -1
- package/dist/jobs/frontmatter.js +8 -0
- package/dist/jobs/frontmatter.js.map +1 -1
- package/dist/jobs/run-history.d.ts +56 -0
- package/dist/jobs/run-history.d.ts.map +1 -0
- package/dist/jobs/run-history.js +169 -0
- package/dist/jobs/run-history.js.map +1 -0
- package/dist/jobs/scheduler.d.ts.map +1 -1
- package/dist/jobs/scheduler.js +51 -18
- package/dist/jobs/scheduler.js.map +1 -1
- package/dist/jobs/tools.d.ts.map +1 -1
- package/dist/jobs/tools.js +37 -6
- package/dist/jobs/tools.js.map +1 -1
- package/dist/localization/actions/get-localization-preference.d.ts +1 -2
- package/dist/localization/actions/get-localization-preference.d.ts.map +1 -1
- package/dist/localization/actions/get-localization-preference.js.map +1 -1
- package/dist/localization/actions/set-localization-preference.d.ts +3 -3
- package/dist/localization/actions/set-localization-preference.d.ts.map +1 -1
- package/dist/localization/actions/set-localization-preference.js +33 -8
- package/dist/localization/actions/set-localization-preference.js.map +1 -1
- package/dist/localization/default-messages.d.ts +25 -0
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +25 -0
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/localization/shared.d.ts +16 -1
- package/dist/localization/shared.d.ts.map +1 -1
- package/dist/localization/shared.js +22 -4
- package/dist/localization/shared.js.map +1 -1
- package/dist/localization/user-timezone.d.ts +11 -0
- package/dist/localization/user-timezone.d.ts.map +1 -0
- package/dist/localization/user-timezone.js +31 -0
- package/dist/localization/user-timezone.js.map +1 -0
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +1 -1
- package/dist/org/app-roles-handlers.d.ts.map +1 -1
- package/dist/org/app-roles-handlers.js +1 -8
- package/dist/org/app-roles-handlers.js.map +1 -1
- package/dist/org/index.d.ts +1 -0
- package/dist/org/index.d.ts.map +1 -1
- package/dist/org/index.js +1 -0
- package/dist/org/index.js.map +1 -1
- package/dist/org/membership.d.ts +6 -0
- package/dist/org/membership.d.ts.map +1 -0
- package/dist/org/membership.js +16 -0
- package/dist/org/membership.js.map +1 -0
- package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
- package/dist/provider-api/actions/provider-api.d.ts +8 -8
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/review/actions/create-review-comment.d.ts +32 -1
- package/dist/review/actions/create-review-comment.d.ts.map +1 -1
- package/dist/review/actions/create-review-comment.js +3 -1
- package/dist/review/actions/create-review-comment.js.map +1 -1
- package/dist/review/actions/reply-review-comment.d.ts +32 -1
- package/dist/review/actions/reply-review-comment.d.ts.map +1 -1
- package/dist/review/actions/reply-review-comment.js +3 -1
- package/dist/review/actions/reply-review-comment.js.map +1 -1
- package/dist/review/index.d.ts +1 -0
- package/dist/review/index.d.ts.map +1 -1
- package/dist/review/index.js +1 -0
- package/dist/review/index.js.map +1 -1
- package/dist/review/notifications.d.ts +24 -0
- package/dist/review/notifications.d.ts.map +1 -0
- package/dist/review/notifications.js +123 -0
- package/dist/review/notifications.js.map +1 -0
- package/dist/review/types.d.ts +5 -0
- package/dist/review/types.d.ts.map +1 -1
- package/dist/review/types.js.map +1 -1
- package/dist/server/action-discovery.d.ts.map +1 -1
- package/dist/server/action-discovery.js +4 -0
- package/dist/server/action-discovery.js.map +1 -1
- package/dist/server/activity-notifications.d.ts +61 -0
- package/dist/server/activity-notifications.d.ts.map +1 -0
- package/dist/server/activity-notifications.js +103 -0
- package/dist/server/activity-notifications.js.map +1 -0
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/app-name.d.ts +18 -0
- package/dist/server/app-name.d.ts.map +1 -1
- package/dist/server/app-name.js +31 -0
- package/dist/server/app-name.js.map +1 -1
- package/dist/server/better-auth-instance.js +4 -4
- package/dist/server/better-auth-instance.js.map +1 -1
- package/dist/server/email-templates.d.ts +12 -0
- package/dist/server/email-templates.d.ts.map +1 -1
- package/dist/server/email-templates.js +57 -14
- package/dist/server/email-templates.js.map +1 -1
- package/dist/server/email.d.ts +12 -0
- package/dist/server/email.d.ts.map +1 -1
- package/dist/server/email.js +52 -5
- package/dist/server/email.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/sharing/index.d.ts +1 -0
- package/dist/sharing/index.d.ts.map +1 -1
- package/dist/sharing/index.js +1 -0
- package/dist/sharing/index.js.map +1 -1
- package/dist/sharing/recipients.d.ts +31 -0
- package/dist/sharing/recipients.d.ts.map +1 -0
- package/dist/sharing/recipients.js +40 -0
- package/dist/sharing/recipients.js.map +1 -0
- package/dist/templates/chat/app/routes/settings.tsx +14 -22
- package/dist/templates/chat/vitest.config.ts +9 -0
- package/dist/triggers/actions/list-automations.d.ts +2 -0
- package/dist/triggers/actions/list-automations.d.ts.map +1 -1
- package/dist/triggers/actions/list-automations.js +19 -8
- package/dist/triggers/actions/list-automations.js.map +1 -1
- package/dist/triggers/actions/manage-automation.d.ts +6 -0
- package/dist/triggers/actions/manage-automation.js +12 -6
- package/dist/triggers/actions/manage-automation.js.map +1 -1
- package/dist/triggers/actions.d.ts.map +1 -1
- package/dist/triggers/actions.js +15 -4
- package/dist/triggers/actions.js.map +1 -1
- package/dist/triggers/dispatcher.d.ts.map +1 -1
- package/dist/triggers/dispatcher.js +19 -17
- package/dist/triggers/dispatcher.js.map +1 -1
- package/dist/triggers/routes.d.ts.map +1 -1
- package/dist/triggers/routes.js +6 -6
- package/dist/triggers/routes.js.map +1 -1
- package/dist/vitest-config.d.ts +10 -0
- package/dist/vitest-config.d.ts.map +1 -0
- package/dist/vitest-config.js +55 -0
- package/dist/vitest-config.js.map +1 -0
- package/docs/content/template-content-developers.mdx +1 -1
- package/package.json +5 -4
- package/src/automations/service.ts +41 -4
- package/src/cli/mcp-config-writers.ts +27 -21
- package/src/client/agent-page/AgentJobsTab.tsx +187 -85
- package/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
- package/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
- package/src/client/agent-page/TimezoneSelect.tsx +241 -0
- package/src/client/agent-page/use-jobs.ts +50 -10
- package/src/client/org/OrgSwitcher.tsx +26 -0
- package/src/client/settings/AccountSettingsCard.tsx +35 -31
- package/src/client/settings/SchedulingTimezoneField.tsx +71 -0
- package/src/client/settings/SettingsRow.tsx +113 -0
- package/src/client/settings/agent-settings-search.ts +2 -1
- package/src/client/settings/index.ts +6 -0
- package/src/integrations/identity.ts +2 -12
- package/src/jobs/actions/list-automation-runs.ts +33 -0
- package/src/jobs/actions/list-recurring-jobs.ts +27 -6
- package/src/jobs/actions/manage-recurring-job.ts +41 -9
- package/src/jobs/background-automation-runner.ts +88 -0
- package/src/jobs/cron.ts +52 -6
- package/src/jobs/frontmatter.ts +20 -0
- package/src/jobs/run-history.ts +242 -0
- package/src/jobs/scheduler.ts +73 -18
- package/src/jobs/tools.ts +54 -6
- package/src/localization/actions/get-localization-preference.ts +2 -2
- package/src/localization/actions/set-localization-preference.ts +49 -11
- package/src/localization/default-messages.ts +27 -0
- package/src/localization/shared.ts +39 -6
- package/src/localization/user-timezone.ts +36 -0
- package/src/org/app-roles-handlers.ts +1 -9
- package/src/org/index.ts +2 -0
- package/src/org/membership.ts +18 -0
- package/src/review/actions/create-review-comment.ts +4 -1
- package/src/review/actions/reply-review-comment.ts +4 -1
- package/src/review/index.ts +5 -0
- package/src/review/notifications.ts +163 -0
- package/src/review/types.ts +7 -0
- package/src/server/action-discovery.ts +4 -0
- package/src/server/activity-notifications.ts +174 -0
- package/src/server/app-name.ts +31 -0
- package/src/server/better-auth-instance.ts +4 -4
- package/src/server/email-templates.ts +70 -14
- package/src/server/email.ts +66 -3
- package/src/server/index.ts +10 -0
- package/src/sharing/index.ts +5 -0
- package/src/sharing/recipients.ts +67 -0
- package/src/templates/chat/app/routes/settings.tsx +14 -22
- package/src/templates/chat/vitest.config.ts +9 -0
- package/src/triggers/actions/list-automations.ts +27 -8
- package/src/triggers/actions/manage-automation.ts +17 -6
- package/src/triggers/actions.ts +16 -4
- package/src/triggers/dispatcher.ts +44 -30
- package/src/triggers/routes.ts +19 -6
- package/src/vitest-config.ts +68 -0
- package/corpus/templates/content/actions/delete-database-items.ts +0 -89
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { parse as parseJavaScript } from "acorn";
|
|
2
|
+
import { type DefaultTreeAdapterTypes, parse, type ParserError } from "parse5";
|
|
3
|
+
|
|
1
4
|
import { isStandaloneHttpUrl } from "./html-content.js";
|
|
2
5
|
|
|
3
6
|
/**
|
|
@@ -23,6 +26,8 @@ export type DesignHtmlIntegrityIssue =
|
|
|
23
26
|
| "managed-marker-orphaned"
|
|
24
27
|
| "managed-marker-duplicated"
|
|
25
28
|
| "attribute-unterminated"
|
|
29
|
+
| "expression-invalid"
|
|
30
|
+
| "script-invalid"
|
|
26
31
|
| "element-unclosed"
|
|
27
32
|
| "close-tag-orphaned"
|
|
28
33
|
| "content-truncated"
|
|
@@ -44,6 +49,8 @@ export interface DesignHtmlIntegrityIssueDetail {
|
|
|
44
49
|
excerpt: string;
|
|
45
50
|
tag?: string;
|
|
46
51
|
attribute?: string;
|
|
52
|
+
/** The parser's reason, for `expression-invalid`. */
|
|
53
|
+
reason?: string;
|
|
47
54
|
/** The tag that arrived where this element's close belonged, if any. */
|
|
48
55
|
closedBy?: { tag: string; line: number };
|
|
49
56
|
}
|
|
@@ -86,6 +93,21 @@ export function describeDesignHtmlIntegrityIssue(
|
|
|
86
93
|
): string {
|
|
87
94
|
const at = `line ${detail.line} col ${detail.column}`;
|
|
88
95
|
switch (detail.issue) {
|
|
96
|
+
case "expression-invalid":
|
|
97
|
+
return (
|
|
98
|
+
`the ${detail.attribute ? `\`${detail.attribute}\`` : "Alpine"} expression on ` +
|
|
99
|
+
`<${detail.tag ?? "element"}> at ${at} is not valid JavaScript: ` +
|
|
100
|
+
`${detail.reason ?? "it does not parse"}. The HTML attribute itself is ` +
|
|
101
|
+
`well-formed, so the document parses and the element renders — but Alpine ` +
|
|
102
|
+
`compiles this value and throws, which aborts every binding on the component.`
|
|
103
|
+
);
|
|
104
|
+
case "script-invalid":
|
|
105
|
+
return (
|
|
106
|
+
`the inline <script> at ${at} is not valid JavaScript: ` +
|
|
107
|
+
`${detail.reason ?? "it does not parse"}. The document still parses and ` +
|
|
108
|
+
`the page still renders, so nothing visibly fails — the script simply ` +
|
|
109
|
+
`never runs, and everything it was going to wire up stays dead.`
|
|
110
|
+
);
|
|
89
111
|
case "attribute-unterminated":
|
|
90
112
|
return (
|
|
91
113
|
`the ${detail.attribute ? `\`${detail.attribute}\`` : "attribute"} value on ` +
|
|
@@ -155,14 +177,7 @@ export class DesignHtmlIntegrityError extends Error {
|
|
|
155
177
|
}
|
|
156
178
|
}
|
|
157
179
|
|
|
158
|
-
/**
|
|
159
|
-
* Bodies are text, not markup — mis-tokenizing these turns a `'</div>'` string
|
|
160
|
-
* inside Alpine JavaScript into a phantom structural error. Both passes must
|
|
161
|
-
* agree on this set: when only one treated `<title>`/`<textarea>` as raw text,
|
|
162
|
-
* literal `<body>` text inside a title reached the root-tag count as real markup.
|
|
163
|
-
*/
|
|
164
180
|
const RAW_TEXT_TAGS = new Set(["script", "style", "textarea", "title"]);
|
|
165
|
-
type RawTextTag = "script" | "style" | "textarea" | "title";
|
|
166
181
|
|
|
167
182
|
const MANAGED_RAW_TEXT_MARKERS = [
|
|
168
183
|
{ marker: "data-agent-native-breakpoints", tag: "style" },
|
|
@@ -172,232 +187,7 @@ const MANAGED_RAW_TEXT_MARKERS = [
|
|
|
172
187
|
{ marker: "data-agent-native-shader-runtime", tag: "script" },
|
|
173
188
|
] as const;
|
|
174
189
|
|
|
175
|
-
|
|
176
|
-
severity: number;
|
|
177
|
-
bodyRanges: Array<{ start: number; end: number }>;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function matchFallsInsideRanges(
|
|
181
|
-
index: number,
|
|
182
|
-
ranges: RawTextScan["bodyRanges"],
|
|
183
|
-
): boolean {
|
|
184
|
-
return ranges.some((range) => index >= range.start && index < range.end);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function countMatchesOutsideRanges(
|
|
188
|
-
value: string,
|
|
189
|
-
pattern: RegExp,
|
|
190
|
-
ranges: RawTextScan["bodyRanges"],
|
|
191
|
-
requireMarkupStart = false,
|
|
192
|
-
): number {
|
|
193
|
-
return Array.from(value.matchAll(pattern)).filter((match) => {
|
|
194
|
-
// Root-tag-shaped strings inside quoted Alpine attributes and comments
|
|
195
|
-
// are data, not document structure. Counting them here made an otherwise
|
|
196
|
-
// valid document look like it had duplicate <html>/<body>/<head> roots.
|
|
197
|
-
// Every caller of this helper is counting a markup token, so require the
|
|
198
|
-
// tokenizer to agree that the candidate starts outside those contexts.
|
|
199
|
-
return (
|
|
200
|
-
match.index !== undefined &&
|
|
201
|
-
!matchFallsInsideRanges(match.index, ranges) &&
|
|
202
|
-
(!requireMarkupStart ||
|
|
203
|
-
matchStartsMarkupToken(value, match.index, ranges))
|
|
204
|
-
);
|
|
205
|
-
}).length;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
function firstMatchOutsideRanges(
|
|
209
|
-
value: string,
|
|
210
|
-
pattern: RegExp,
|
|
211
|
-
ranges: RawTextScan["bodyRanges"],
|
|
212
|
-
): { index: number; text: string } | null {
|
|
213
|
-
const match = Array.from(value.matchAll(pattern)).find(
|
|
214
|
-
(candidate) =>
|
|
215
|
-
candidate.index !== undefined &&
|
|
216
|
-
!matchFallsInsideRanges(candidate.index, ranges) &&
|
|
217
|
-
matchStartsMarkupToken(value, candidate.index, ranges),
|
|
218
|
-
);
|
|
219
|
-
return match?.index === undefined
|
|
220
|
-
? null
|
|
221
|
-
: { index: match.index, text: match[0] };
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function matchStartsMarkupToken(
|
|
225
|
-
value: string,
|
|
226
|
-
index: number,
|
|
227
|
-
rawTextBodyRanges: RawTextScan["bodyRanges"],
|
|
228
|
-
): boolean {
|
|
229
|
-
if (matchFallsInsideRanges(index, rawTextBodyRanges)) return false;
|
|
230
|
-
|
|
231
|
-
// A bare regex also finds tag-shaped strings in Alpine attributes and HTML
|
|
232
|
-
// comments, for example `x-data="{ sample: '>' + '<html></html>' }"`.
|
|
233
|
-
// Walk the markup tokenizer state up to the candidate so a `>` inside a
|
|
234
|
-
// quoted attribute cannot fool a last-delimiter heuristic into treating the
|
|
235
|
-
// following string as a real root tag.
|
|
236
|
-
let inTag = false;
|
|
237
|
-
let quote: '"' | "'" | null = null;
|
|
238
|
-
let rawRangeIndex = 0;
|
|
239
|
-
for (let cursor = 0; cursor <= index; cursor += 1) {
|
|
240
|
-
while (
|
|
241
|
-
rawRangeIndex < rawTextBodyRanges.length &&
|
|
242
|
-
cursor >= rawTextBodyRanges[rawRangeIndex]!.end
|
|
243
|
-
) {
|
|
244
|
-
rawRangeIndex += 1;
|
|
245
|
-
}
|
|
246
|
-
const rawRange = rawTextBodyRanges[rawRangeIndex];
|
|
247
|
-
if (!inTag && rawRange) {
|
|
248
|
-
if (cursor >= rawRange.start && cursor < rawRange.end) {
|
|
249
|
-
if (index < rawRange.end) return false;
|
|
250
|
-
cursor = rawRange.end - 1;
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
if (!inTag && value.startsWith("<!--", cursor)) {
|
|
256
|
-
const commentEnd = value.indexOf("-->", cursor + 4);
|
|
257
|
-
if (commentEnd === -1 || index < commentEnd + 3) return false;
|
|
258
|
-
cursor = commentEnd + 2;
|
|
259
|
-
continue;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
const character = value[cursor];
|
|
263
|
-
if (!inTag) {
|
|
264
|
-
if (character !== "<") continue;
|
|
265
|
-
if (cursor === index) return true;
|
|
266
|
-
inTag = true;
|
|
267
|
-
quote = null;
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (quote) {
|
|
272
|
-
if (character === quote) quote = null;
|
|
273
|
-
continue;
|
|
274
|
-
}
|
|
275
|
-
if (character === '"' || character === "'") {
|
|
276
|
-
quote = character;
|
|
277
|
-
} else if (character === ">") {
|
|
278
|
-
inTag = false;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return false;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function isDocumentHtml(
|
|
285
|
-
value: string,
|
|
286
|
-
rawTextBodyRanges = scanRawTextTags(value).bodyRanges,
|
|
287
|
-
): boolean {
|
|
288
|
-
return [
|
|
289
|
-
...value.matchAll(/<!doctype\s+html\b/gi),
|
|
290
|
-
...value.matchAll(/<html\b/gi),
|
|
291
|
-
].some(
|
|
292
|
-
(match) =>
|
|
293
|
-
match.index !== undefined &&
|
|
294
|
-
matchStartsMarkupToken(value, match.index, rawTextBodyRanges),
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
function stripBoundaryNoise(value: string): string {
|
|
299
|
-
return value
|
|
300
|
-
.replace(/^\uFEFF/, "")
|
|
301
|
-
.replace(/<!--(?:[\s\S]*?)-->/g, "")
|
|
302
|
-
.trim();
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function tagCount(
|
|
306
|
-
value: string,
|
|
307
|
-
tag: "html" | "head" | "body",
|
|
308
|
-
ranges: RawTextScan["bodyRanges"],
|
|
309
|
-
) {
|
|
310
|
-
return {
|
|
311
|
-
open: countMatchesOutsideRanges(
|
|
312
|
-
value,
|
|
313
|
-
new RegExp(`<${tag}\\b[^>]*>`, "gi"),
|
|
314
|
-
ranges,
|
|
315
|
-
true,
|
|
316
|
-
),
|
|
317
|
-
close: countMatchesOutsideRanges(
|
|
318
|
-
value,
|
|
319
|
-
new RegExp(`<\\s*\\/\\s*${tag}\\s*>`, "gi"),
|
|
320
|
-
ranges,
|
|
321
|
-
true,
|
|
322
|
-
),
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
function scanRawTextTags(value: string): RawTextScan {
|
|
327
|
-
// HTML raw-text bodies may legitimately contain strings such as
|
|
328
|
-
// `<style>...</style>` inside JavaScript. Once a real style/script opener is
|
|
329
|
-
// seen, ignore every tag-like token except that element's own closer. This
|
|
330
|
-
// mirrors browser tokenization closely enough to avoid rejecting code-heavy
|
|
331
|
-
// Alpine documents while still detecting an orphaned closer/missing opener.
|
|
332
|
-
let active: RawTextTag | null = null;
|
|
333
|
-
let bodyStart = -1;
|
|
334
|
-
let severity = 0;
|
|
335
|
-
const bodyRanges: RawTextScan["bodyRanges"] = [];
|
|
336
|
-
let cursor = 0;
|
|
337
|
-
while (cursor < value.length) {
|
|
338
|
-
if (active) {
|
|
339
|
-
// HTML raw-text elements terminate at the first matching end-tag token,
|
|
340
|
-
// even when that text happens to look like a JavaScript/CSS string.
|
|
341
|
-
const closer = new RegExp(`<\\s*\\/\\s*${active}(?=[\\s/>])[^>]*>`, "gi");
|
|
342
|
-
closer.lastIndex = cursor;
|
|
343
|
-
const match = closer.exec(value);
|
|
344
|
-
if (!match) break;
|
|
345
|
-
bodyRanges.push({ start: bodyStart, end: match.index });
|
|
346
|
-
active = null;
|
|
347
|
-
bodyStart = -1;
|
|
348
|
-
cursor = match.index + match[0].length;
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const nextOpen = value.indexOf("<", cursor);
|
|
353
|
-
if (nextOpen === -1) break;
|
|
354
|
-
if (value.startsWith("<!--", nextOpen)) {
|
|
355
|
-
const commentEnd = value.indexOf("-->", nextOpen + 4);
|
|
356
|
-
cursor = commentEnd === -1 ? value.length : commentEnd + 3;
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Consume one complete markup token while respecting quoted attributes.
|
|
361
|
-
// This is the important distinction from the former bare regex: an
|
|
362
|
-
// Alpine value such as x-data="{ sample: '<style></style>' }" must not
|
|
363
|
-
// open raw-text mode halfway through the surrounding start tag.
|
|
364
|
-
let quote: '"' | "'" | null = null;
|
|
365
|
-
let tokenEnd = nextOpen + 1;
|
|
366
|
-
for (; tokenEnd < value.length; tokenEnd += 1) {
|
|
367
|
-
const character = value[tokenEnd];
|
|
368
|
-
if (quote) {
|
|
369
|
-
if (character === quote) quote = null;
|
|
370
|
-
} else if (character === '"' || character === "'") {
|
|
371
|
-
quote = character;
|
|
372
|
-
} else if (character === ">") {
|
|
373
|
-
tokenEnd += 1;
|
|
374
|
-
break;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
const token = value.slice(nextOpen, tokenEnd);
|
|
378
|
-
const match = token.match(/^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9:-]*)\b/i);
|
|
379
|
-
if (match && RAW_TEXT_TAGS.has(match[2]!.toLowerCase())) {
|
|
380
|
-
const closing = match[1] === "/";
|
|
381
|
-
const tag = match[2]!.toLowerCase() as RawTextTag;
|
|
382
|
-
if (closing) severity += 1;
|
|
383
|
-
else {
|
|
384
|
-
active = tag;
|
|
385
|
-
bodyStart = tokenEnd;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
cursor = Math.max(tokenEnd, nextOpen + 1);
|
|
389
|
-
}
|
|
390
|
-
if (active) bodyRanges.push({ start: bodyStart, end: value.length });
|
|
391
|
-
return { severity: severity + (active ? 1 : 0), bodyRanges };
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// ---------------------------------------------------------------------------
|
|
395
|
-
// Structural pass. Counting tokens is blind to order, so an unclosed <div> or
|
|
396
|
-
// a stray </section> leaves every root count above intact — those need a stack
|
|
397
|
-
// walk, and they are the defects browsers recover from most invisibly.
|
|
398
|
-
// ---------------------------------------------------------------------------
|
|
399
|
-
|
|
400
|
-
/** Closing tag is forbidden, so these never reach the stack. */
|
|
190
|
+
/** Closing tag is forbidden, so the tree never records an end tag for these. */
|
|
401
191
|
const VOID_TAGS = new Set([
|
|
402
192
|
"area",
|
|
403
193
|
"base",
|
|
@@ -416,8 +206,9 @@ const VOID_TAGS = new Set([
|
|
|
416
206
|
]);
|
|
417
207
|
|
|
418
208
|
/**
|
|
419
|
-
* Closing tag optional per HTML5, so
|
|
420
|
-
*
|
|
209
|
+
* Closing tag optional per HTML5, so a missing end tag is legal authoring. The
|
|
210
|
+
* parser decides which element an implied close terminates; this set only
|
|
211
|
+
* decides what not to report.
|
|
421
212
|
*/
|
|
422
213
|
const OPTIONAL_CLOSE_TAGS = new Set([
|
|
423
214
|
"body",
|
|
@@ -443,21 +234,6 @@ const OPTIONAL_CLOSE_TAGS = new Set([
|
|
|
443
234
|
"tr",
|
|
444
235
|
]);
|
|
445
236
|
|
|
446
|
-
/** Opening one of these implicitly closes the listed open sibling. */
|
|
447
|
-
const IMPLICIT_SIBLING_CLOSE = new Map<string, Set<string>>([
|
|
448
|
-
["li", new Set(["li"])],
|
|
449
|
-
["p", new Set(["p"])],
|
|
450
|
-
["td", new Set(["td", "th"])],
|
|
451
|
-
["th", new Set(["td", "th"])],
|
|
452
|
-
["tr", new Set(["tr", "td", "th"])],
|
|
453
|
-
["dt", new Set(["dt", "dd"])],
|
|
454
|
-
["dd", new Set(["dt", "dd"])],
|
|
455
|
-
["option", new Set(["option"])],
|
|
456
|
-
["optgroup", new Set(["optgroup", "option"])],
|
|
457
|
-
["tbody", new Set(["thead", "tbody", "tr", "td", "th"])],
|
|
458
|
-
["tfoot", new Set(["thead", "tbody", "tr", "td", "th"])],
|
|
459
|
-
]);
|
|
460
|
-
|
|
461
237
|
const MAX_EXCERPT_CHARS = 120;
|
|
462
238
|
|
|
463
239
|
/**
|
|
@@ -467,6 +243,26 @@ const MAX_EXCERPT_CHARS = 120;
|
|
|
467
243
|
const USES_TAILWIND_UTILITIES =
|
|
468
244
|
/\bclass\s*=\s*["'][^"']*(?:\b(?:flex|grid|hidden|absolute|relative|sticky)\b|\b(?:p|m|px|py|mx|my|pt|pb|pl|pr|gap|w|h|text|bg|border|rounded|shadow|items|justify|font|leading|tracking|space-x|space-y|min-h|max-w|opacity|ring|z)-[a-z0-9[\]./-]+)/i;
|
|
469
245
|
|
|
246
|
+
/**
|
|
247
|
+
* Parse errors that mean the source was cut off or mis-delimited. The rest of
|
|
248
|
+
* what the spec reports is recoverable authoring the browser accepts, and
|
|
249
|
+
* `missing-doctype` fires on legitimate fragments and on real screens.
|
|
250
|
+
*/
|
|
251
|
+
/**
|
|
252
|
+
* Ordered by cause, not by offset: every one of these is reported at EOF, and an
|
|
253
|
+
* unterminated tag absorbs the rest of the document — including the closer of
|
|
254
|
+
* whatever raw-text element it sits in. Reporting the imbalance instead sends
|
|
255
|
+
* the fix to a `</script>` that is present and correct.
|
|
256
|
+
*/
|
|
257
|
+
const FATAL_PARSE_ERRORS = [
|
|
258
|
+
"eof-in-tag",
|
|
259
|
+
"eof-in-comment",
|
|
260
|
+
"eof-in-cdata",
|
|
261
|
+
"eof-in-script-html-comment-like-text",
|
|
262
|
+
"eof-in-element-that-can-contain-only-text",
|
|
263
|
+
"eof-before-tag-name",
|
|
264
|
+
];
|
|
265
|
+
|
|
470
266
|
type Locator = (index: number) => {
|
|
471
267
|
line: number;
|
|
472
268
|
column: number;
|
|
@@ -474,9 +270,9 @@ type Locator = (index: number) => {
|
|
|
474
270
|
};
|
|
475
271
|
|
|
476
272
|
/**
|
|
477
|
-
* Scanning to the offset per call
|
|
478
|
-
* not just malformed ones — a
|
|
479
|
-
*
|
|
273
|
+
* Indexed once, lazily, then binary searched. Scanning to the offset per call
|
|
274
|
+
* made validation quadratic on VALID documents, not just malformed ones — a
|
|
275
|
+
* 117KB screen cost ~700ms on every save.
|
|
480
276
|
*/
|
|
481
277
|
function createLocator(value: string): Locator {
|
|
482
278
|
let starts: number[] | null = null;
|
|
@@ -509,230 +305,655 @@ function createLocator(value: string): Locator {
|
|
|
509
305
|
};
|
|
510
306
|
}
|
|
511
307
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// Parse layer
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
// One parse and one walk feed every check below, so no two can disagree about
|
|
313
|
+
// whether a `<body>` inside a `<title>` was markup.
|
|
314
|
+
|
|
315
|
+
type Parse5Node = DefaultTreeAdapterTypes.Node;
|
|
316
|
+
type Parse5Element = DefaultTreeAdapterTypes.Element;
|
|
317
|
+
type SourceRange = { start: number; end: number };
|
|
318
|
+
type SourceOffsets = { startOffset: number; endOffset: number };
|
|
319
|
+
|
|
320
|
+
interface ParsedDocument {
|
|
321
|
+
source: string;
|
|
322
|
+
document: DefaultTreeAdapterTypes.Document;
|
|
323
|
+
errors: ParserError[];
|
|
324
|
+
/** Source extents to exclude when looking for markup tokens in the source. */
|
|
325
|
+
rawTextBodies: SourceRange[];
|
|
326
|
+
comments: SourceRange[];
|
|
327
|
+
attributeRanges: SourceRange[];
|
|
328
|
+
/** Start offsets of the end tags the parser matched to an element. */
|
|
329
|
+
matchedEndTags: Set<number>;
|
|
330
|
+
elements: Parse5Element[];
|
|
331
|
+
textNodes: DefaultTreeAdapterTypes.TextNode[];
|
|
332
|
+
parents: Map<Parse5Node, Parse5Element>;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function isElement(node: Parse5Node): node is Parse5Element {
|
|
336
|
+
return typeof (node as Parse5Element).tagName === "string";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Only elements carry tag spans; the tree types every node's location as one union. */
|
|
340
|
+
interface ElementSpan extends SourceOffsets {
|
|
341
|
+
startTag?: SourceOffsets;
|
|
342
|
+
endTag?: SourceOffsets;
|
|
343
|
+
attrs?: Record<string, SourceOffsets>;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function locationOf(element: Parse5Element | undefined): ElementSpan | null {
|
|
347
|
+
if (!element) return null;
|
|
348
|
+
return (element.sourceCodeLocation as ElementSpan | null) ?? null;
|
|
520
349
|
}
|
|
521
350
|
|
|
522
351
|
/**
|
|
523
|
-
* A
|
|
524
|
-
*
|
|
525
|
-
*
|
|
352
|
+
* A `<template>`'s children hang off `content`, not `childNodes`. Missing them
|
|
353
|
+
* leaves every element inside an `x-for`/`x-if` template unvisited, so its close
|
|
354
|
+
* tag looks like it belongs to nothing.
|
|
526
355
|
*/
|
|
527
|
-
function
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
356
|
+
function childrenOf(node: Parse5Node): Parse5Node[] {
|
|
357
|
+
const element = node as {
|
|
358
|
+
childNodes?: Parse5Node[];
|
|
359
|
+
content?: { childNodes?: Parse5Node[] };
|
|
360
|
+
};
|
|
361
|
+
return [
|
|
362
|
+
...(element.childNodes ?? []),
|
|
363
|
+
...(element.content?.childNodes ?? []),
|
|
364
|
+
];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseDocument(source: string): ParsedDocument {
|
|
368
|
+
const errors: ParserError[] = [];
|
|
369
|
+
const document = parse(source, {
|
|
370
|
+
sourceCodeLocationInfo: true,
|
|
371
|
+
onParseError: (error) => errors.push(error),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const rawTextBodies: SourceRange[] = [];
|
|
375
|
+
const comments: SourceRange[] = [];
|
|
376
|
+
const attributeRanges: SourceRange[] = [];
|
|
377
|
+
const matchedEndTags = new Set<number>();
|
|
378
|
+
const elements: Parse5Element[] = [];
|
|
379
|
+
const textNodes: DefaultTreeAdapterTypes.TextNode[] = [];
|
|
380
|
+
const parents = new Map<Parse5Node, Parse5Element>();
|
|
381
|
+
|
|
382
|
+
const stack: Parse5Node[] = [document];
|
|
383
|
+
while (stack.length > 0) {
|
|
384
|
+
const node = stack.pop()!;
|
|
385
|
+
const location = node.sourceCodeLocation;
|
|
386
|
+
if (node.nodeName === "#comment" && location) {
|
|
387
|
+
comments.push({ start: location.startOffset, end: location.endOffset });
|
|
550
388
|
}
|
|
551
|
-
if (
|
|
552
|
-
|
|
389
|
+
if (node.nodeName === "#text") {
|
|
390
|
+
textNodes.push(node as DefaultTreeAdapterTypes.TextNode);
|
|
553
391
|
}
|
|
554
|
-
if (
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
392
|
+
if (isElement(node)) {
|
|
393
|
+
elements.push(node);
|
|
394
|
+
const elementAt = locationOf(node);
|
|
395
|
+
for (const attribute of Object.values(elementAt?.attrs ?? {})) {
|
|
396
|
+
attributeRanges.push({
|
|
397
|
+
start: attribute.startOffset,
|
|
398
|
+
end: attribute.endOffset,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (elementAt?.endTag) matchedEndTags.add(elementAt.endTag.startOffset);
|
|
402
|
+
if (RAW_TEXT_TAGS.has(node.tagName) && elementAt?.startTag) {
|
|
403
|
+
rawTextBodies.push({
|
|
404
|
+
start: elementAt.startTag.endOffset,
|
|
405
|
+
end: elementAt.endTag?.startOffset ?? elementAt.endOffset,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
558
408
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
409
|
+
for (const child of childrenOf(node)) {
|
|
410
|
+
if (isElement(node)) parents.set(child, node);
|
|
411
|
+
stack.push(child);
|
|
562
412
|
}
|
|
563
|
-
word += character;
|
|
564
413
|
}
|
|
414
|
+
|
|
565
415
|
return {
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
416
|
+
source,
|
|
417
|
+
document,
|
|
418
|
+
errors,
|
|
419
|
+
rawTextBodies: rawTextBodies.sort(byStart),
|
|
420
|
+
comments: comments.sort(byStart),
|
|
421
|
+
attributeRanges: attributeRanges.sort(byStart),
|
|
422
|
+
matchedEndTags,
|
|
423
|
+
elements,
|
|
424
|
+
textNodes,
|
|
425
|
+
parents,
|
|
570
426
|
};
|
|
571
427
|
}
|
|
572
428
|
|
|
573
429
|
/**
|
|
574
|
-
*
|
|
575
|
-
*
|
|
430
|
+
* Binary search over sorted, non-overlapping ranges. This is called once per
|
|
431
|
+
* markup token, so a linear scan here is what turns a document carrying many
|
|
432
|
+
* <style>/<script> blocks quadratic.
|
|
576
433
|
*/
|
|
577
|
-
function
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
434
|
+
function fallsInside(index: number, ranges: SourceRange[]): boolean {
|
|
435
|
+
let low = 0;
|
|
436
|
+
let high = ranges.length - 1;
|
|
437
|
+
while (low <= high) {
|
|
438
|
+
const mid = (low + high) >> 1;
|
|
439
|
+
const range = ranges[mid]!;
|
|
440
|
+
if (index < range.start) high = mid - 1;
|
|
441
|
+
else if (index >= range.end) low = mid + 1;
|
|
442
|
+
else return true;
|
|
443
|
+
}
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function byStart(left: SourceRange, right: SourceRange): number {
|
|
448
|
+
return left.start - right.start;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function findElements(
|
|
452
|
+
parsed: ParsedDocument,
|
|
453
|
+
tagName: string,
|
|
454
|
+
): Parse5Element[] {
|
|
455
|
+
return parsed.elements.filter((element) => element.tagName === tagName);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function attributeOf(element: Parse5Element, name: string): string | undefined {
|
|
459
|
+
return element.attrs.find((attribute) => attribute.name === name)?.value;
|
|
587
460
|
}
|
|
588
461
|
|
|
589
462
|
/**
|
|
590
|
-
*
|
|
591
|
-
*
|
|
463
|
+
* `eof-in-tag` covers a tag cut off mid-name and one whose quote was never
|
|
464
|
+
* closed, and the fix differs: telling an author to close a quote that is
|
|
465
|
+
* already closed sends them to the wrong character. Only runs on input the
|
|
466
|
+
* parser has already rejected.
|
|
592
467
|
*/
|
|
593
|
-
function
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
468
|
+
function findUnterminatedTag(parsed: ParsedDocument): {
|
|
469
|
+
start: number;
|
|
470
|
+
tag?: string;
|
|
471
|
+
quote: boolean;
|
|
472
|
+
attribute?: string;
|
|
473
|
+
} | null {
|
|
474
|
+
const value = parsed.source;
|
|
599
475
|
let cursor = 0;
|
|
600
|
-
|
|
601
476
|
while (cursor < value.length) {
|
|
602
477
|
const open = value.indexOf("<", cursor);
|
|
603
|
-
if (open === -1)
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
return issues;
|
|
610
|
-
}
|
|
611
|
-
cursor = commentEnd + 3;
|
|
478
|
+
if (open === -1) return null;
|
|
479
|
+
if (
|
|
480
|
+
fallsInside(open, parsed.rawTextBodies) ||
|
|
481
|
+
fallsInside(open, parsed.comments)
|
|
482
|
+
) {
|
|
483
|
+
cursor = open + 1;
|
|
612
484
|
continue;
|
|
613
485
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
cursor =
|
|
486
|
+
if (value.startsWith("<!--", open)) {
|
|
487
|
+
const end = value.indexOf("-->", open + 4);
|
|
488
|
+
cursor = end === -1 ? value.length : end + 3;
|
|
617
489
|
continue;
|
|
618
490
|
}
|
|
619
|
-
|
|
620
|
-
const parsed = tagAt(value, open);
|
|
621
|
-
if (!parsed) {
|
|
622
|
-
// A bare `<` in text content. Not markup, not a defect.
|
|
491
|
+
if (!/^<\s*\/?\s*[a-zA-Z!]/.test(value.slice(open, open + 8))) {
|
|
623
492
|
cursor = open + 1;
|
|
624
493
|
continue;
|
|
625
494
|
}
|
|
626
|
-
const { tag, isClose } = parsed;
|
|
627
|
-
|
|
628
|
-
const scan = scanTag(value, open);
|
|
629
|
-
if (!scan.terminated) {
|
|
630
|
-
// Anything after an unclosed tag would be invented structure. Stop here.
|
|
631
|
-
const located = locate(open);
|
|
632
|
-
issues.push(
|
|
633
|
-
scan.quoteOpen
|
|
634
|
-
? {
|
|
635
|
-
issue: "attribute-unterminated",
|
|
636
|
-
...located,
|
|
637
|
-
tag,
|
|
638
|
-
attribute: scan.unterminatedAttribute,
|
|
639
|
-
}
|
|
640
|
-
: { issue: "content-truncated", ...located, tag },
|
|
641
|
-
);
|
|
642
|
-
return issues;
|
|
643
|
-
}
|
|
644
495
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
496
|
+
let quote: '"' | "'" | null = null;
|
|
497
|
+
let word = "";
|
|
498
|
+
let pending: string | undefined;
|
|
499
|
+
let quoted: string | undefined;
|
|
500
|
+
let terminated = false;
|
|
501
|
+
let scan = open;
|
|
502
|
+
for (; scan < value.length; scan += 1) {
|
|
503
|
+
const character = value[scan]!;
|
|
504
|
+
if (quote) {
|
|
505
|
+
if (character === quote) {
|
|
506
|
+
quote = null;
|
|
507
|
+
quoted = undefined;
|
|
651
508
|
}
|
|
509
|
+
continue;
|
|
652
510
|
}
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
} else {
|
|
658
|
-
// Located lazily: computing this for every balanced close tag is what
|
|
659
|
-
// made a valid document quadratic.
|
|
660
|
-
let closeLine: number | null = null;
|
|
661
|
-
for (let index = stack.length - 1; index > matched; index -= 1) {
|
|
662
|
-
const abandoned = stack[index]!;
|
|
663
|
-
if (OPTIONAL_CLOSE_TAGS.has(abandoned.tag)) continue;
|
|
664
|
-
closeLine ??= locate(open).line;
|
|
665
|
-
issues.push({
|
|
666
|
-
issue: "element-unclosed",
|
|
667
|
-
...locate(abandoned.start),
|
|
668
|
-
tag: abandoned.tag,
|
|
669
|
-
closedBy: { tag, line: closeLine },
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
stack.length = matched;
|
|
511
|
+
if (character === '"' || character === "'") {
|
|
512
|
+
quote = character;
|
|
513
|
+
quoted = pending;
|
|
514
|
+
continue;
|
|
673
515
|
}
|
|
674
|
-
|
|
675
|
-
|
|
516
|
+
if (character === ">") {
|
|
517
|
+
terminated = true;
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
if (character === "=") {
|
|
521
|
+
if (word) pending = word;
|
|
522
|
+
word = "";
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
if (character === "<" || character === "/" || /\s/.test(character)) {
|
|
526
|
+
word = "";
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
word += character;
|
|
530
|
+
}
|
|
531
|
+
if (!terminated) {
|
|
532
|
+
return {
|
|
533
|
+
start: open,
|
|
534
|
+
tag: tagNameAtOffset(value, open),
|
|
535
|
+
quote: quote !== null,
|
|
536
|
+
attribute: quote !== null ? quoted : undefined,
|
|
537
|
+
};
|
|
676
538
|
}
|
|
539
|
+
cursor = scan + 1;
|
|
540
|
+
}
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
677
543
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
544
|
+
function stripBoundaryNoise(value: string): string {
|
|
545
|
+
return value
|
|
546
|
+
.replace(/^/, "")
|
|
547
|
+
.replace(/<!--(?:[\s\S]*?)-->/g, "")
|
|
548
|
+
.trim();
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Structural checks
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
function collectParseErrorIssues(
|
|
556
|
+
parsed: ParsedDocument,
|
|
557
|
+
locate: Locator,
|
|
558
|
+
): DesignHtmlIntegrityIssueDetail[] {
|
|
559
|
+
const fatal = FATAL_PARSE_ERRORS.flatMap((code) =>
|
|
560
|
+
parsed.errors.filter((error) => error.code === code),
|
|
561
|
+
)[0];
|
|
562
|
+
if (!fatal) return [];
|
|
563
|
+
|
|
564
|
+
if (fatal.code === "eof-in-element-that-can-contain-only-text") {
|
|
565
|
+
const unterminated = parsed.elements.find(
|
|
566
|
+
(element) =>
|
|
567
|
+
RAW_TEXT_TAGS.has(element.tagName) &&
|
|
568
|
+
element.sourceCodeLocation &&
|
|
569
|
+
!element.sourceCodeLocation.endTag,
|
|
570
|
+
);
|
|
571
|
+
const anchor = unterminated?.sourceCodeLocation?.startOffset ?? 0;
|
|
572
|
+
return [
|
|
573
|
+
{
|
|
574
|
+
issue: "raw-text-balance",
|
|
575
|
+
...locate(anchor),
|
|
576
|
+
...(unterminated ? { tag: unterminated.tagName } : {}),
|
|
577
|
+
},
|
|
578
|
+
];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
return [{ issue: "content-truncated", ...locate(fatal.startOffset) }];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function tagNameAtOffset(value: string, offset: number): string | undefined {
|
|
585
|
+
return /^<\s*\/?\s*([a-zA-Z][a-zA-Z0-9:-]*)/
|
|
586
|
+
.exec(value.slice(offset, offset + 64))?.[1]
|
|
587
|
+
?.toLowerCase();
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const END_TAG_PATTERN = /<\s*\/\s*([a-zA-Z][a-zA-Z0-9:-]*)\s*>/g;
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* The one structural fact the tree cannot supply: the parser drops an end tag
|
|
594
|
+
* that closes nothing without reporting it. Every exclusion range still comes
|
|
595
|
+
* from the tree rather than a second hand-rolled tokenizer.
|
|
596
|
+
*/
|
|
597
|
+
function collectOrphanEndTags(
|
|
598
|
+
parsed: ParsedDocument,
|
|
599
|
+
locate: Locator,
|
|
600
|
+
): DesignHtmlIntegrityIssueDetail[] {
|
|
601
|
+
const issues: DesignHtmlIntegrityIssueDetail[] = [];
|
|
602
|
+
END_TAG_PATTERN.lastIndex = 0;
|
|
603
|
+
let match = END_TAG_PATTERN.exec(parsed.source);
|
|
604
|
+
while (match) {
|
|
605
|
+
const offset = match.index;
|
|
606
|
+
const tag = match[1]!.toLowerCase();
|
|
607
|
+
if (
|
|
608
|
+
!parsed.matchedEndTags.has(offset) &&
|
|
609
|
+
!OPTIONAL_CLOSE_TAGS.has(tag) &&
|
|
610
|
+
!VOID_TAGS.has(tag) &&
|
|
611
|
+
!fallsInside(offset, parsed.rawTextBodies) &&
|
|
612
|
+
!fallsInside(offset, parsed.comments) &&
|
|
613
|
+
!fallsInside(offset, parsed.attributeRanges)
|
|
614
|
+
) {
|
|
615
|
+
issues.push({ issue: "close-tag-orphaned", ...locate(offset), tag });
|
|
701
616
|
}
|
|
617
|
+
match = END_TAG_PATTERN.exec(parsed.source);
|
|
618
|
+
}
|
|
619
|
+
return issues;
|
|
620
|
+
}
|
|
702
621
|
|
|
703
|
-
|
|
704
|
-
|
|
622
|
+
/**
|
|
623
|
+
* An element the parser closed for you carries no end-tag location. The nearest
|
|
624
|
+
* ancestor that does have one names the tag that arrived where this element's
|
|
625
|
+
* close belonged.
|
|
626
|
+
*/
|
|
627
|
+
function collectUnclosedElements(
|
|
628
|
+
parsed: ParsedDocument,
|
|
629
|
+
locate: Locator,
|
|
630
|
+
): DesignHtmlIntegrityIssueDetail[] {
|
|
631
|
+
const issues: DesignHtmlIntegrityIssueDetail[] = [];
|
|
632
|
+
for (const element of parsed.elements) {
|
|
633
|
+
const location = element.sourceCodeLocation;
|
|
634
|
+
// A missing location means the parser invented the element; the document
|
|
635
|
+
// shape checks own that case, not this one.
|
|
636
|
+
if (!location || location.endTag) continue;
|
|
637
|
+
const tag = element.tagName;
|
|
638
|
+
if (VOID_TAGS.has(tag) || OPTIONAL_CLOSE_TAGS.has(tag)) continue;
|
|
639
|
+
const startTag = location.startTag;
|
|
640
|
+
if (
|
|
641
|
+
startTag &&
|
|
642
|
+
/\/\s*>$/.test(
|
|
643
|
+
parsed.source.slice(startTag.startOffset, startTag.endOffset),
|
|
644
|
+
)
|
|
645
|
+
) {
|
|
705
646
|
continue;
|
|
706
647
|
}
|
|
707
648
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
649
|
+
let ancestor = parsed.parents.get(element);
|
|
650
|
+
let closedBy: { tag: string; line: number } | undefined;
|
|
651
|
+
let carriedByImpliedClose = false;
|
|
652
|
+
while (ancestor) {
|
|
653
|
+
const ancestorEnd = ancestor.sourceCodeLocation?.endTag;
|
|
654
|
+
if (ancestorEnd) {
|
|
655
|
+
closedBy = {
|
|
656
|
+
tag: ancestor.tagName,
|
|
657
|
+
line: locate(ancestorEnd.startOffset).line,
|
|
658
|
+
};
|
|
659
|
+
break;
|
|
660
|
+
}
|
|
661
|
+
// The ancestor's own close was legally omitted, which closes this element
|
|
662
|
+
// with it — `<li><span>one<li>` leaves no defect for the span. The
|
|
663
|
+
// ancestor must be authored: the parser invents <body> for every fragment,
|
|
664
|
+
// and accepting that as the carrier excuses every unclosed element there.
|
|
665
|
+
if (
|
|
666
|
+
ancestor.sourceCodeLocation &&
|
|
667
|
+
OPTIONAL_CLOSE_TAGS.has(ancestor.tagName)
|
|
668
|
+
) {
|
|
669
|
+
carriedByImpliedClose = true;
|
|
716
670
|
break;
|
|
717
671
|
}
|
|
672
|
+
ancestor = parsed.parents.get(ancestor);
|
|
673
|
+
}
|
|
674
|
+
if (carriedByImpliedClose) continue;
|
|
675
|
+
issues.push({
|
|
676
|
+
issue: "element-unclosed",
|
|
677
|
+
...locate(location.startOffset),
|
|
678
|
+
tag,
|
|
679
|
+
...(closedBy ? { closedBy } : {}),
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
return issues;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Alpine compiles these attribute values as JavaScript. Nothing else may join
|
|
687
|
+
* this set on a hunch: `x-for` holds `item in items`, `x-transition:enter` holds
|
|
688
|
+
* a class list, and `x-ref`/`x-teleport` hold a name and a selector — reading
|
|
689
|
+
* any of those as an expression reports working markup as broken.
|
|
690
|
+
*/
|
|
691
|
+
const ALPINE_EXPRESSION_DIRECTIVES = new Set([
|
|
692
|
+
"x-bind",
|
|
693
|
+
"x-data",
|
|
694
|
+
"x-effect",
|
|
695
|
+
"x-html",
|
|
696
|
+
"x-id",
|
|
697
|
+
"x-if",
|
|
698
|
+
"x-init",
|
|
699
|
+
"x-modelable",
|
|
700
|
+
"x-model",
|
|
701
|
+
"x-show",
|
|
702
|
+
"x-text",
|
|
703
|
+
]);
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Runs for every attribute in the document, so `class`/`style`/`href` must fall
|
|
707
|
+
* out before anything allocates.
|
|
708
|
+
*/
|
|
709
|
+
function isAlpineExpressionAttribute(name: string): boolean {
|
|
710
|
+
const first = name[0];
|
|
711
|
+
if (first === ":" || first === "@") return name.length > 1;
|
|
712
|
+
if (name.length < 3 || (first !== "x" && first !== "X") || name[1] !== "-") {
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
const lower = name.toLowerCase();
|
|
716
|
+
if (lower.startsWith("x-on:") || lower.startsWith("x-bind:")) return true;
|
|
717
|
+
let end = 2;
|
|
718
|
+
while (end < lower.length && lower[end] !== "." && lower[end] !== ":") {
|
|
719
|
+
end += 1;
|
|
720
|
+
}
|
|
721
|
+
return ALPINE_EXPRESSION_DIRECTIVES.has(lower.slice(0, end));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** The shapes Alpine wraps in an async IIFE instead of assigning. */
|
|
725
|
+
const ALPINE_STATEMENT_SHAPED = /^\s*(?:if\s*\(|let\s|const\s|var\s)/;
|
|
726
|
+
const ASSIGNED_PREFIX = "__an_probe = ";
|
|
727
|
+
|
|
728
|
+
interface ExpressionDefect {
|
|
729
|
+
/** Offset within the expression where the parser gave up. */
|
|
730
|
+
offset: number;
|
|
731
|
+
reason: string;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Parses the source Alpine generates, not the raw value: the assignment keeps
|
|
736
|
+
* `{ open: false }` an object literal rather than a block, and rejects trailing
|
|
737
|
+
* garbage that `parseExpressionAt` stops short of. Not `new Function` — this runs
|
|
738
|
+
* on the browser write path, where a CSP may forbid it.
|
|
739
|
+
*/
|
|
740
|
+
function findExpressionDefect(expression: string): ExpressionDefect | null {
|
|
741
|
+
if (!expression.trim()) return null;
|
|
742
|
+
const wrapped = ALPINE_STATEMENT_SHAPED.test(expression);
|
|
743
|
+
const source = wrapped
|
|
744
|
+
? `(async()=>{ ${expression} })()`
|
|
745
|
+
: `${ASSIGNED_PREFIX}${expression}`;
|
|
746
|
+
try {
|
|
747
|
+
parseJavaScript(source, {
|
|
748
|
+
ecmaVersion: "latest",
|
|
749
|
+
allowAwaitOutsideFunction: true,
|
|
750
|
+
allowReturnOutsideFunction: true,
|
|
751
|
+
});
|
|
752
|
+
return null;
|
|
753
|
+
} catch (error) {
|
|
754
|
+
const failure = error as { message?: unknown; pos?: unknown };
|
|
755
|
+
const prefix = wrapped ? "(async()=>{ ".length : ASSIGNED_PREFIX.length;
|
|
756
|
+
return {
|
|
757
|
+
offset:
|
|
758
|
+
typeof failure.pos === "number"
|
|
759
|
+
? Math.min(Math.max(failure.pos - prefix, 0), expression.length)
|
|
760
|
+
: 0,
|
|
761
|
+
reason:
|
|
762
|
+
typeof failure.message === "string"
|
|
763
|
+
? failure.message.replace(/\s*\(\d+:\d+\)\s*$/, "")
|
|
764
|
+
: "the expression is not valid JavaScript",
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* The attribute location spans `name="value"`, and acorn's offset is relative to
|
|
771
|
+
* the value, so the name and opening quote have to be skipped or every reported
|
|
772
|
+
* column lands early.
|
|
773
|
+
*/
|
|
774
|
+
function attributeValueStart(
|
|
775
|
+
parsed: ParsedDocument,
|
|
776
|
+
location: SourceRange,
|
|
777
|
+
): number {
|
|
778
|
+
const source = parsed.source.slice(location.start, location.end);
|
|
779
|
+
const equals = source.indexOf("=");
|
|
780
|
+
if (equals === -1) return location.start;
|
|
781
|
+
let cursor = equals + 1;
|
|
782
|
+
while (cursor < source.length && /\s/.test(source[cursor]!)) cursor += 1;
|
|
783
|
+
const quote = source[cursor];
|
|
784
|
+
return location.start + cursor + (quote === '"' || quote === "'" ? 1 : 0);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function collectExpressionIssues(
|
|
788
|
+
parsed: ParsedDocument,
|
|
789
|
+
locate: Locator,
|
|
790
|
+
): DesignHtmlIntegrityIssueDetail[] {
|
|
791
|
+
const issues: DesignHtmlIntegrityIssueDetail[] = [];
|
|
792
|
+
for (const element of parsed.elements) {
|
|
793
|
+
for (const attribute of element.attrs) {
|
|
794
|
+
if (!isAlpineExpressionAttribute(attribute.name)) continue;
|
|
795
|
+
// The tree hands back the decoded value, which is what Alpine compiles.
|
|
796
|
+
const defect = findExpressionDefect(attribute.value);
|
|
797
|
+
if (!defect) continue;
|
|
798
|
+
const location = element.sourceCodeLocation?.attrs?.[attribute.name];
|
|
799
|
+
const valueStart = location
|
|
800
|
+
? attributeValueStart(parsed, {
|
|
801
|
+
start: location.startOffset,
|
|
802
|
+
end: location.endOffset,
|
|
803
|
+
})
|
|
804
|
+
: (element.sourceCodeLocation?.startOffset ?? 0);
|
|
805
|
+
issues.push({
|
|
806
|
+
issue: "expression-invalid",
|
|
807
|
+
...locate(valueStart + defect.offset),
|
|
808
|
+
tag: element.tagName,
|
|
809
|
+
attribute: attribute.name,
|
|
810
|
+
reason: defect.reason,
|
|
811
|
+
});
|
|
718
812
|
}
|
|
719
|
-
stack.push({ tag, start: open });
|
|
720
|
-
cursor = scan.end;
|
|
721
813
|
}
|
|
814
|
+
return issues;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* The JavaScript MIME types a browser will execute, per the HTML spec. Anything
|
|
819
|
+
* else — `importmap`, `application/json`, an `x-template`, a bespoke
|
|
820
|
+
* `application/vnd.*` block — is inert data the browser never parses, so parsing
|
|
821
|
+
* it here reports working markup as broken.
|
|
822
|
+
*/
|
|
823
|
+
const EXECUTABLE_SCRIPT_TYPES = new Set([
|
|
824
|
+
"application/ecmascript",
|
|
825
|
+
"application/javascript",
|
|
826
|
+
"application/x-ecmascript",
|
|
827
|
+
"application/x-javascript",
|
|
828
|
+
"text/ecmascript",
|
|
829
|
+
"text/javascript",
|
|
830
|
+
"text/javascript1.0",
|
|
831
|
+
"text/javascript1.1",
|
|
832
|
+
"text/javascript1.2",
|
|
833
|
+
"text/javascript1.3",
|
|
834
|
+
"text/javascript1.4",
|
|
835
|
+
"text/javascript1.5",
|
|
836
|
+
"text/jscript",
|
|
837
|
+
"text/livescript",
|
|
838
|
+
"text/x-ecmascript",
|
|
839
|
+
"text/x-javascript",
|
|
840
|
+
]);
|
|
841
|
+
|
|
842
|
+
/** `null` when the browser treats the element as data rather than code. */
|
|
843
|
+
function scriptGrammar(type: string): "script" | "module" | null {
|
|
844
|
+
const normalized = type.trim().toLowerCase();
|
|
845
|
+
if (normalized === "") return "script";
|
|
846
|
+
if (normalized === "module") return "module";
|
|
847
|
+
const base = normalized.split(";")[0]!.trim();
|
|
848
|
+
return EXECUTABLE_SCRIPT_TYPES.has(base) ? "script" : null;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Parsed with the grammar the browser will actually use. Retrying a classic
|
|
853
|
+
* script as a module accepts `import` and top-level `await` in an element the
|
|
854
|
+
* browser rejects outright.
|
|
855
|
+
*/
|
|
856
|
+
function findScriptDefect(
|
|
857
|
+
source: string,
|
|
858
|
+
sourceType: "script" | "module",
|
|
859
|
+
): ExpressionDefect | null {
|
|
860
|
+
try {
|
|
861
|
+
parseJavaScript(source, {
|
|
862
|
+
ecmaVersion: "latest",
|
|
863
|
+
sourceType,
|
|
864
|
+
// A <script> body is a Program, not a function body: a browser rejects a
|
|
865
|
+
// top-level `return` with "Illegal return statement" and never runs the
|
|
866
|
+
// element. Alpine expressions are the opposite case — Alpine compiles them
|
|
867
|
+
// inside a function — which is why the expression parser allows it.
|
|
868
|
+
allowReturnOutsideFunction: false,
|
|
869
|
+
allowAwaitOutsideFunction: sourceType === "module",
|
|
870
|
+
allowHashBang: true,
|
|
871
|
+
});
|
|
872
|
+
return null;
|
|
873
|
+
} catch (error) {
|
|
874
|
+
const failure = error as { message?: unknown; pos?: unknown };
|
|
875
|
+
return {
|
|
876
|
+
offset: typeof failure.pos === "number" ? failure.pos : 0,
|
|
877
|
+
reason:
|
|
878
|
+
typeof failure.message === "string"
|
|
879
|
+
? failure.message.replace(/\s*\(\d+:\d+\)\s*$/, "")
|
|
880
|
+
: "the script is not valid JavaScript",
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
}
|
|
722
884
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
885
|
+
/**
|
|
886
|
+
* A truncated string in an inline <script> is the same defect as one in an
|
|
887
|
+
* Alpine attribute and just as invisible: the document parses, the element
|
|
888
|
+
* renders, and the script silently never runs.
|
|
889
|
+
*/
|
|
890
|
+
function collectScriptBodyIssues(
|
|
891
|
+
parsed: ParsedDocument,
|
|
892
|
+
locate: Locator,
|
|
893
|
+
): DesignHtmlIntegrityIssueDetail[] {
|
|
894
|
+
const issues: DesignHtmlIntegrityIssueDetail[] = [];
|
|
895
|
+
for (const element of parsed.elements) {
|
|
896
|
+
if (element.tagName !== "script") continue;
|
|
897
|
+
if (element.attrs.some((attribute) => attribute.name === "src")) continue;
|
|
898
|
+
const grammar = scriptGrammar(attributeOf(element, "type") ?? "");
|
|
899
|
+
if (!grammar) continue;
|
|
900
|
+
const body = element.childNodes.find((node) => node.nodeName === "#text");
|
|
901
|
+
if (!body) continue;
|
|
902
|
+
const text = (body as DefaultTreeAdapterTypes.TextNode).value;
|
|
903
|
+
if (!text.trim()) continue;
|
|
904
|
+
const defect = findScriptDefect(text, grammar);
|
|
905
|
+
if (!defect) continue;
|
|
906
|
+
const start =
|
|
907
|
+
body.sourceCodeLocation?.startOffset ??
|
|
908
|
+
locationOf(element)?.startOffset ??
|
|
909
|
+
0;
|
|
728
910
|
issues.push({
|
|
729
|
-
issue: "
|
|
730
|
-
...locate(
|
|
731
|
-
tag:
|
|
911
|
+
issue: "script-invalid",
|
|
912
|
+
...locate(start + defect.offset),
|
|
913
|
+
tag: "script",
|
|
914
|
+
reason: defect.reason,
|
|
732
915
|
});
|
|
733
916
|
}
|
|
917
|
+
return issues;
|
|
918
|
+
}
|
|
734
919
|
|
|
735
|
-
|
|
920
|
+
/**
|
|
921
|
+
* Runs on fragments as well as documents — an unterminated quote is as
|
|
922
|
+
* destructive in a `<template>` snippet as in a full page.
|
|
923
|
+
*/
|
|
924
|
+
function collectStructuralIssues(
|
|
925
|
+
value: string,
|
|
926
|
+
parsed = parseDocument(value),
|
|
927
|
+
): DesignHtmlIntegrityIssueDetail[] {
|
|
928
|
+
const locate = createLocator(value);
|
|
929
|
+
// Reported alone: everything after an unterminated tag is invented structure.
|
|
930
|
+
// Cannot be driven off the parser's errors — a runaway attribute quote
|
|
931
|
+
// swallows markup until a later quote resyncs the tokenizer, which then
|
|
932
|
+
// finishes the document without complaining.
|
|
933
|
+
const unterminated = findUnterminatedTag(parsed);
|
|
934
|
+
if (unterminated) {
|
|
935
|
+
return [
|
|
936
|
+
{
|
|
937
|
+
issue: unterminated.quote
|
|
938
|
+
? "attribute-unterminated"
|
|
939
|
+
: "content-truncated",
|
|
940
|
+
...locate(unterminated.start),
|
|
941
|
+
...(unterminated.tag ? { tag: unterminated.tag } : {}),
|
|
942
|
+
...(unterminated.attribute
|
|
943
|
+
? { attribute: unterminated.attribute }
|
|
944
|
+
: {}),
|
|
945
|
+
},
|
|
946
|
+
];
|
|
947
|
+
}
|
|
948
|
+
const truncation = collectParseErrorIssues(parsed, locate);
|
|
949
|
+
if (truncation.length > 0) return truncation;
|
|
950
|
+
|
|
951
|
+
return [
|
|
952
|
+
...collectUnclosedElements(parsed, locate),
|
|
953
|
+
...collectOrphanEndTags(parsed, locate),
|
|
954
|
+
...collectExpressionIssues(parsed, locate),
|
|
955
|
+
...collectScriptBodyIssues(parsed, locate),
|
|
956
|
+
]
|
|
736
957
|
.sort((left, right) =>
|
|
737
958
|
left.line === right.line
|
|
738
959
|
? left.column - right.column
|
|
@@ -741,162 +962,187 @@ function collectStructuralIssues(
|
|
|
741
962
|
.slice(0, MAX_REPORTED_ISSUES);
|
|
742
963
|
}
|
|
743
964
|
|
|
965
|
+
// ---------------------------------------------------------------------------
|
|
966
|
+
// Document shape
|
|
967
|
+
// ---------------------------------------------------------------------------
|
|
968
|
+
|
|
969
|
+
const ROOT_TAG_PATTERN = /<\s*(\/?)\s*(html|head|body)\b/gi;
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* The parser merges a second `<html>` into the first and reports nothing, so two
|
|
973
|
+
* full documents concatenated by a bad write are invisible in the tree.
|
|
974
|
+
*/
|
|
975
|
+
function countRootTags(
|
|
976
|
+
parsed: ParsedDocument,
|
|
977
|
+
): Record<"html" | "head" | "body", { open: number; close: number }> {
|
|
978
|
+
const counts = {
|
|
979
|
+
html: { open: 0, close: 0 },
|
|
980
|
+
head: { open: 0, close: 0 },
|
|
981
|
+
body: { open: 0, close: 0 },
|
|
982
|
+
};
|
|
983
|
+
ROOT_TAG_PATTERN.lastIndex = 0;
|
|
984
|
+
let match = ROOT_TAG_PATTERN.exec(parsed.source);
|
|
985
|
+
while (match) {
|
|
986
|
+
const offset = match.index;
|
|
987
|
+
if (
|
|
988
|
+
!fallsInside(offset, parsed.rawTextBodies) &&
|
|
989
|
+
!fallsInside(offset, parsed.comments) &&
|
|
990
|
+
!fallsInside(offset, parsed.attributeRanges)
|
|
991
|
+
) {
|
|
992
|
+
const tag = match[2]!.toLowerCase() as "html" | "head" | "body";
|
|
993
|
+
if (match[1] === "/") counts[tag].close += 1;
|
|
994
|
+
else counts[tag].open += 1;
|
|
995
|
+
}
|
|
996
|
+
match = ROOT_TAG_PATTERN.exec(parsed.source);
|
|
997
|
+
}
|
|
998
|
+
return counts;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** An authored root, as opposed to one the parser supplied for a fragment. */
|
|
1002
|
+
function authoredRoot(
|
|
1003
|
+
parsed: ParsedDocument,
|
|
1004
|
+
tagName: "html" | "head" | "body",
|
|
1005
|
+
): Parse5Element | undefined {
|
|
1006
|
+
return findElements(parsed, tagName).find(
|
|
1007
|
+
(element) =>
|
|
1008
|
+
element.sourceCodeLocation !== null &&
|
|
1009
|
+
element.sourceCodeLocation !== undefined,
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function hasDoctype(parsed: ParsedDocument): boolean {
|
|
1014
|
+
return childrenOf(parsed.document).some(
|
|
1015
|
+
(node) => node.nodeName === "#documentType" && node.sourceCodeLocation,
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
function isDocumentHtml(value: string, parsed = parseDocument(value)): boolean {
|
|
1020
|
+
return hasDoctype(parsed) || authoredRoot(parsed, "html") !== undefined;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function collectDocumentShapeIssue(
|
|
1024
|
+
parsed: ParsedDocument,
|
|
1025
|
+
): DesignHtmlIntegrityIssue | null {
|
|
1026
|
+
const counts = countRootTags(parsed);
|
|
1027
|
+
if (counts.html.open !== 1 || counts.html.close !== 1) return "document-root";
|
|
1028
|
+
if (counts.body.open !== 1 || counts.body.close !== 1) return "document-body";
|
|
1029
|
+
if (counts.head.open !== counts.head.close || counts.head.open > 1) {
|
|
1030
|
+
return "document-head";
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const htmlAt = locationOf(authoredRoot(parsed, "html"));
|
|
1034
|
+
const htmlEnd = htmlAt?.endTag;
|
|
1035
|
+
if (!htmlAt || !htmlEnd) return "document-root";
|
|
1036
|
+
const bodyAt = locationOf(authoredRoot(parsed, "body"));
|
|
1037
|
+
const bodyEnd = bodyAt?.endTag;
|
|
1038
|
+
if (!bodyAt || !bodyEnd) return "document-body";
|
|
1039
|
+
|
|
1040
|
+
if (
|
|
1041
|
+
bodyAt.startOffset <= htmlAt.startOffset ||
|
|
1042
|
+
bodyEnd.startOffset >= htmlEnd.startOffset
|
|
1043
|
+
) {
|
|
1044
|
+
return "document-boundary";
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
const prefix = stripBoundaryNoise(
|
|
1048
|
+
parsed.source.slice(0, htmlAt.startOffset),
|
|
1049
|
+
).replace(/<!doctype\s+html\b[^>]*>/i, "");
|
|
1050
|
+
const suffix = stripBoundaryNoise(parsed.source.slice(htmlEnd.endOffset));
|
|
1051
|
+
if (prefix.trim() || suffix.trim()) return "document-boundary";
|
|
1052
|
+
|
|
1053
|
+
return null;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* A marker that lost its element is not missing — it is sitting in the document
|
|
1058
|
+
* as text, which is exactly how a partial edit leaves it.
|
|
1059
|
+
*/
|
|
1060
|
+
function collectManagedMarkerIssue(
|
|
1061
|
+
parsed: ParsedDocument,
|
|
1062
|
+
): DesignHtmlIntegrityIssue | null {
|
|
1063
|
+
for (const { marker, tag } of MANAGED_RAW_TEXT_MARKERS) {
|
|
1064
|
+
const attached = parsed.elements.filter(
|
|
1065
|
+
(element) =>
|
|
1066
|
+
element.tagName === tag &&
|
|
1067
|
+
element.attrs.some((attribute) => attribute.name === marker),
|
|
1068
|
+
).length;
|
|
1069
|
+
const loose = parsed.textNodes.some(
|
|
1070
|
+
(node) =>
|
|
1071
|
+
node.value.includes(marker) &&
|
|
1072
|
+
!RAW_TEXT_TAGS.has(parsed.parents.get(node)?.tagName ?? ""),
|
|
1073
|
+
);
|
|
1074
|
+
if (loose) return "managed-marker-orphaned";
|
|
1075
|
+
if (attached > 1) return "managed-marker-duplicated";
|
|
1076
|
+
}
|
|
1077
|
+
return null;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
744
1080
|
/**
|
|
745
1081
|
* Reported, never enforced: legitimate fragments and token-only screens carry no
|
|
746
1082
|
* runtime of their own, so blocking here would reject valid work.
|
|
747
1083
|
*/
|
|
748
1084
|
function collectAdvisoryIssues(
|
|
749
|
-
|
|
750
|
-
|
|
1085
|
+
parsed: ParsedDocument,
|
|
1086
|
+
locate: Locator,
|
|
751
1087
|
): DesignHtmlIntegrityIssueDetail[] {
|
|
752
|
-
if (!isDocumentHtml(value, rawTextBodyRanges)) return [];
|
|
753
1088
|
// Only documents that actually depend on utility classes can be broken by a
|
|
754
1089
|
// missing runtime. A screen styled entirely through its own CSS needs no
|
|
755
1090
|
// Tailwind, and flagging it would train authors to ignore this warning.
|
|
756
|
-
if (!USES_TAILWIND_UTILITIES.test(
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1091
|
+
if (!USES_TAILWIND_UTILITIES.test(parsed.source)) return [];
|
|
1092
|
+
const hasRuntime = parsed.elements.some((element) => {
|
|
1093
|
+
if (element.tagName === "script") {
|
|
1094
|
+
return /tailwind/i.test(attributeOf(element, "src") ?? "");
|
|
1095
|
+
}
|
|
1096
|
+
if (element.tagName === "link") {
|
|
1097
|
+
return /tailwind/i.test(attributeOf(element, "href") ?? "");
|
|
1098
|
+
}
|
|
1099
|
+
if (element.tagName === "style") {
|
|
1100
|
+
return (
|
|
1101
|
+
(attributeOf(element, "type") ?? "").toLowerCase() ===
|
|
1102
|
+
"text/tailwindcss"
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
return false;
|
|
1106
|
+
});
|
|
1107
|
+
if (hasRuntime) return [];
|
|
1108
|
+
const head = authoredRoot(parsed, "head");
|
|
771
1109
|
return [
|
|
772
1110
|
{
|
|
773
1111
|
issue: "runtime-missing",
|
|
774
|
-
...
|
|
1112
|
+
...locate(head?.sourceCodeLocation?.startOffset ?? 0),
|
|
775
1113
|
},
|
|
776
1114
|
];
|
|
777
1115
|
}
|
|
778
1116
|
|
|
779
|
-
function markerCounts(
|
|
780
|
-
value: string,
|
|
781
|
-
marker: string,
|
|
782
|
-
tag: "style" | "script",
|
|
783
|
-
ranges: RawTextScan["bodyRanges"],
|
|
784
|
-
): { raw: number; attached: number } {
|
|
785
|
-
const escapedMarker = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
786
|
-
return {
|
|
787
|
-
raw: countMatchesOutsideRanges(
|
|
788
|
-
value,
|
|
789
|
-
new RegExp(`\\b${escapedMarker}\\b`, "gi"),
|
|
790
|
-
ranges,
|
|
791
|
-
),
|
|
792
|
-
attached: countMatchesOutsideRanges(
|
|
793
|
-
value,
|
|
794
|
-
new RegExp(`<${tag}\\b[^>]*\\b${escapedMarker}\\b[^>]*>`, "gi"),
|
|
795
|
-
ranges,
|
|
796
|
-
true,
|
|
797
|
-
),
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
|
|
801
1117
|
/**
|
|
802
|
-
* Validate one complete Design HTML document
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
* function below instead of being rejected here.
|
|
1118
|
+
* Validate one complete Design HTML document. The HTML5 parser is the tokenizer,
|
|
1119
|
+
* but never the verdict: it repairs the missing roots and unbalanced elements
|
|
1120
|
+
* this guard exists to catch, so the checks read its tree and its parse errors
|
|
1121
|
+
* rather than trusting that it produced a document.
|
|
807
1122
|
*/
|
|
808
1123
|
export function inspectDesignHtmlDocumentIntegrity(
|
|
809
1124
|
value: string,
|
|
810
1125
|
): DesignHtmlIntegrityResult {
|
|
811
|
-
const
|
|
1126
|
+
const parsed = parseDocument(value);
|
|
1127
|
+
const locate = createLocator(value);
|
|
812
1128
|
|
|
813
|
-
// Structure first
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
const structural = collectStructuralIssues(value);
|
|
1129
|
+
// Structure first. An unterminated quote swallows the root tags, so a
|
|
1130
|
+
// shape-first order would report `document-root` — sending the fix to an
|
|
1131
|
+
// `<html>` tag that is present and correct instead of to the quote.
|
|
1132
|
+
const structural = collectStructuralIssues(value, parsed);
|
|
817
1133
|
if (structural.length > 0) {
|
|
818
|
-
return {
|
|
819
|
-
valid: false,
|
|
820
|
-
issue: structural[0]!.issue,
|
|
821
|
-
detail: structural,
|
|
822
|
-
};
|
|
1134
|
+
return { valid: false, issue: structural[0]!.issue, detail: structural };
|
|
823
1135
|
}
|
|
824
1136
|
|
|
825
|
-
if (!isDocumentHtml(value,
|
|
1137
|
+
if (!isDocumentHtml(value, parsed)) return { valid: true };
|
|
826
1138
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
// the cause or its effect.
|
|
830
|
-
if (rawText.severity > 0) {
|
|
831
|
-
return { valid: false, issue: "raw-text-balance" };
|
|
832
|
-
}
|
|
1139
|
+
const shape = collectDocumentShapeIssue(parsed);
|
|
1140
|
+
if (shape) return { valid: false, issue: shape };
|
|
833
1141
|
|
|
834
|
-
const
|
|
835
|
-
if (
|
|
836
|
-
return { valid: false, issue: "document-root" };
|
|
837
|
-
}
|
|
838
|
-
const body = tagCount(value, "body", rawText.bodyRanges);
|
|
839
|
-
if (body.open !== 1 || body.close !== 1) {
|
|
840
|
-
return { valid: false, issue: "document-body" };
|
|
841
|
-
}
|
|
842
|
-
const head = tagCount(value, "head", rawText.bodyRanges);
|
|
843
|
-
if (head.open !== head.close || head.open > 1) {
|
|
844
|
-
return { valid: false, issue: "document-head" };
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
const htmlOpen = firstMatchOutsideRanges(
|
|
848
|
-
value,
|
|
849
|
-
/<html\b[^>]*>/gi,
|
|
850
|
-
rawText.bodyRanges,
|
|
851
|
-
);
|
|
852
|
-
const htmlClose = firstMatchOutsideRanges(
|
|
853
|
-
value,
|
|
854
|
-
/<\s*\/\s*html\s*>/gi,
|
|
855
|
-
rawText.bodyRanges,
|
|
856
|
-
);
|
|
857
|
-
const bodyOpen = firstMatchOutsideRanges(
|
|
858
|
-
value,
|
|
859
|
-
/<body\b[^>]*>/gi,
|
|
860
|
-
rawText.bodyRanges,
|
|
861
|
-
);
|
|
862
|
-
const bodyClose = firstMatchOutsideRanges(
|
|
863
|
-
value,
|
|
864
|
-
/<\s*\/\s*body\s*>/gi,
|
|
865
|
-
rawText.bodyRanges,
|
|
866
|
-
);
|
|
867
|
-
if (!htmlOpen || !htmlClose || !bodyOpen || !bodyClose) {
|
|
868
|
-
return { valid: false, issue: "document-root" };
|
|
869
|
-
}
|
|
870
|
-
if (
|
|
871
|
-
htmlOpen.index >= bodyOpen.index ||
|
|
872
|
-
bodyOpen.index >= bodyClose.index ||
|
|
873
|
-
bodyClose.index >= htmlClose.index
|
|
874
|
-
) {
|
|
875
|
-
return { valid: false, issue: "document-boundary" };
|
|
876
|
-
}
|
|
877
|
-
|
|
878
|
-
const prefix = stripBoundaryNoise(value.slice(0, htmlOpen.index)).replace(
|
|
879
|
-
/<!doctype\s+html\b[^>]*>/i,
|
|
880
|
-
"",
|
|
881
|
-
);
|
|
882
|
-
const suffix = stripBoundaryNoise(
|
|
883
|
-
value.slice(htmlClose.index + htmlClose.text.length),
|
|
884
|
-
);
|
|
885
|
-
if (prefix.trim() || suffix.trim()) {
|
|
886
|
-
return { valid: false, issue: "document-boundary" };
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
for (const { marker, tag } of MANAGED_RAW_TEXT_MARKERS) {
|
|
890
|
-
const counts = markerCounts(value, marker, tag, rawText.bodyRanges);
|
|
891
|
-
if (counts.raw !== counts.attached) {
|
|
892
|
-
return { valid: false, issue: "managed-marker-orphaned" };
|
|
893
|
-
}
|
|
894
|
-
if (counts.attached > 1) {
|
|
895
|
-
return { valid: false, issue: "managed-marker-duplicated" };
|
|
896
|
-
}
|
|
897
|
-
}
|
|
1142
|
+
const marker = collectManagedMarkerIssue(parsed);
|
|
1143
|
+
if (marker) return { valid: false, issue: marker };
|
|
898
1144
|
|
|
899
|
-
const advisory = collectAdvisoryIssues(
|
|
1145
|
+
const advisory = collectAdvisoryIssues(parsed, locate);
|
|
900
1146
|
return advisory.length > 0 ? { valid: true, advisory } : { valid: true };
|
|
901
1147
|
}
|
|
902
1148
|
|