@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.
Files changed (493) hide show
  1. package/corpus/README.md +3 -3
  2. package/corpus/core/CHANGELOG.md +113 -0
  3. package/corpus/core/docs/content/template-content-developers.mdx +1 -1
  4. package/corpus/core/package.json +3 -2
  5. package/corpus/core/src/automations/service.ts +41 -4
  6. package/corpus/core/src/cli/mcp-config-writers.ts +27 -21
  7. package/corpus/core/src/client/agent-page/AgentJobsTab.tsx +187 -85
  8. package/corpus/core/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
  9. package/corpus/core/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
  10. package/corpus/core/src/client/agent-page/TimezoneSelect.tsx +241 -0
  11. package/corpus/core/src/client/agent-page/use-jobs.ts +50 -10
  12. package/corpus/core/src/client/org/OrgSwitcher.tsx +26 -0
  13. package/corpus/core/src/client/settings/AccountSettingsCard.tsx +35 -31
  14. package/corpus/core/src/client/settings/SchedulingTimezoneField.tsx +71 -0
  15. package/corpus/core/src/client/settings/SettingsRow.tsx +113 -0
  16. package/corpus/core/src/client/settings/agent-settings-search.ts +2 -1
  17. package/corpus/core/src/client/settings/index.ts +6 -0
  18. package/corpus/core/src/integrations/identity.ts +2 -12
  19. package/corpus/core/src/jobs/actions/list-automation-runs.ts +33 -0
  20. package/corpus/core/src/jobs/actions/list-recurring-jobs.ts +27 -6
  21. package/corpus/core/src/jobs/actions/manage-recurring-job.ts +41 -9
  22. package/corpus/core/src/jobs/background-automation-runner.ts +88 -0
  23. package/corpus/core/src/jobs/cron.ts +52 -6
  24. package/corpus/core/src/jobs/frontmatter.ts +20 -0
  25. package/corpus/core/src/jobs/run-history.ts +242 -0
  26. package/corpus/core/src/jobs/scheduler.ts +73 -18
  27. package/corpus/core/src/jobs/tools.ts +54 -6
  28. package/corpus/core/src/localization/actions/get-localization-preference.ts +2 -2
  29. package/corpus/core/src/localization/actions/set-localization-preference.ts +49 -11
  30. package/corpus/core/src/localization/default-messages.ts +27 -0
  31. package/corpus/core/src/localization/shared.ts +39 -6
  32. package/corpus/core/src/localization/user-timezone.ts +36 -0
  33. package/corpus/core/src/org/app-roles-handlers.ts +1 -9
  34. package/corpus/core/src/org/index.ts +2 -0
  35. package/corpus/core/src/org/membership.ts +18 -0
  36. package/corpus/core/src/review/actions/create-review-comment.ts +4 -1
  37. package/corpus/core/src/review/actions/reply-review-comment.ts +4 -1
  38. package/corpus/core/src/review/index.ts +5 -0
  39. package/corpus/core/src/review/notifications.ts +163 -0
  40. package/corpus/core/src/review/types.ts +7 -0
  41. package/corpus/core/src/server/action-discovery.ts +4 -0
  42. package/corpus/core/src/server/activity-notifications.ts +174 -0
  43. package/corpus/core/src/server/app-name.ts +31 -0
  44. package/corpus/core/src/server/better-auth-instance.ts +4 -4
  45. package/corpus/core/src/server/email-templates.ts +70 -14
  46. package/corpus/core/src/server/email.ts +66 -3
  47. package/corpus/core/src/server/index.ts +10 -0
  48. package/corpus/core/src/sharing/index.ts +5 -0
  49. package/corpus/core/src/sharing/recipients.ts +67 -0
  50. package/corpus/core/src/triggers/actions/list-automations.ts +27 -8
  51. package/corpus/core/src/triggers/actions/manage-automation.ts +17 -6
  52. package/corpus/core/src/triggers/actions.ts +16 -4
  53. package/corpus/core/src/triggers/dispatcher.ts +44 -30
  54. package/corpus/core/src/triggers/routes.ts +19 -6
  55. package/corpus/core/src/vitest-config.ts +68 -0
  56. package/corpus/core/vitest.config.ts +5 -0
  57. package/corpus/templates/analytics/app/pages/Settings.tsx +37 -53
  58. package/corpus/templates/analytics/changelog/2026-08-01-new-error-alerts-now-reach-you-by-email-and-a-scheduled-dash.md +6 -0
  59. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +43 -0
  60. package/corpus/templates/analytics/server/lib/error-capture.ts +5 -1
  61. package/corpus/templates/analytics/vitest.config.ts +16 -12
  62. package/corpus/templates/assets/actions/refresh-generation-run.ts +3 -0
  63. package/corpus/templates/assets/app/components/ui/switch.tsx +1 -0
  64. package/corpus/templates/assets/app/hooks/use-assets-prefs.ts +55 -0
  65. package/corpus/templates/assets/app/i18n-data.ts +30 -0
  66. package/corpus/templates/assets/app/routes/settings.tsx +58 -55
  67. package/corpus/templates/assets/changelog/2026-08-01-get-an-email-when-a-generation-you-started-finishes-or-fails.md +6 -0
  68. package/corpus/templates/assets/server/lib/generation-run-notifications.ts +89 -0
  69. package/corpus/templates/assets/server/lib/video-runs.ts +8 -1
  70. package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.get.ts +17 -0
  71. package/corpus/templates/assets/server/routes/_agent-native/assets/user-prefs.put.ts +36 -0
  72. package/corpus/templates/assets/shared/assets-user-prefs.ts +10 -0
  73. package/corpus/templates/assets/vitest.config.ts +16 -12
  74. package/corpus/templates/brain/app/routes/settings.tsx +15 -15
  75. package/corpus/templates/brain/vitest.config.ts +16 -12
  76. package/corpus/templates/calendar/app/pages/Settings.tsx +32 -40
  77. package/corpus/templates/calendar/changelog/2026-08-01-language-and-appearance-now-sit-together-in-one-preferences-.md +6 -0
  78. package/corpus/templates/calendar/vitest.config.ts +16 -12
  79. package/corpus/templates/chat/app/routes/settings.tsx +14 -22
  80. package/corpus/templates/chat/vitest.config.ts +9 -0
  81. package/corpus/templates/clips/.agents/skills/video-editing/SKILL.md +31 -0
  82. package/corpus/templates/clips/AGENTS.md +1 -0
  83. package/corpus/templates/clips/actions/add-comment.ts +12 -1
  84. package/corpus/templates/clips/actions/create-meeting.ts +2 -2
  85. package/corpus/templates/clips/actions/create-recording.ts +2 -2
  86. package/corpus/templates/clips/actions/finalize-recording.ts +42 -6
  87. package/corpus/templates/clips/actions/generate-filmstrip.ts +154 -0
  88. package/corpus/templates/clips/actions/get-recording-player-data.ts +6 -0
  89. package/corpus/templates/clips/actions/import-loom-recording.ts +2 -2
  90. package/corpus/templates/clips/actions/lib/ensure-recording-filmstrip.ts +258 -0
  91. package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +11 -3
  92. package/corpus/templates/clips/actions/react-to-comment.ts +13 -0
  93. package/corpus/templates/clips/actions/react-to-recording.ts +10 -1
  94. package/corpus/templates/clips/actions/reply-to-comment.ts +12 -1
  95. package/corpus/templates/clips/actions/stitch-recordings.ts +2 -2
  96. package/corpus/templates/clips/app/components/editor/editor-layout.tsx +152 -23
  97. package/corpus/templates/clips/app/components/editor/waveform.tsx +237 -75
  98. package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +23 -1
  99. package/corpus/templates/clips/app/components/settings/ai-setup-section.tsx +441 -0
  100. package/corpus/templates/clips/app/components/settings/slack-section.tsx +329 -0
  101. package/corpus/templates/clips/app/components/settings/types.ts +14 -0
  102. package/corpus/templates/clips/app/components/settings/video-storage-section.tsx +443 -0
  103. package/corpus/templates/clips/app/hooks/use-secret-status.ts +69 -0
  104. package/corpus/templates/clips/app/i18n/en-US.ts +9 -1
  105. package/corpus/templates/clips/app/lib/video-filmstrip.ts +262 -0
  106. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +165 -1351
  107. package/corpus/templates/clips/changelog/2026-07-27-saved-clips-now-resume-from-the-last-uploaded-byte-after-an-.md +6 -0
  108. package/corpus/templates/clips/changelog/2026-07-29-meeting-notes-no-longer-transcribe-the-other-side-twice-when.md +6 -0
  109. package/corpus/templates/clips/changelog/2026-07-31-editor-timeline-now-shows-video-frames-behind-the-waveform-g.md +6 -0
  110. package/corpus/templates/clips/changelog/2026-07-31-email-notifications-now-go-out-when-someone-comments-on-or-r.md +6 -0
  111. package/corpus/templates/clips/changelog/2026-07-31-you-can-now-set-a-default-visibility-for-new-recordings-in-s.md +6 -0
  112. package/corpus/templates/clips/changelog/2026-08-01-email-notifications-for-comments-and-reactions-now-actually-.md +6 -0
  113. package/corpus/templates/clips/changelog/2026-08-03-the-desktop-app-now-opens-directly-beneath-its-tray-icon-on-.md +6 -0
  114. package/corpus/templates/clips/chrome-extension/package.json +1 -0
  115. package/corpus/templates/clips/chrome-extension/vitest.config.ts +9 -0
  116. package/corpus/templates/clips/desktop/package.json +1 -0
  117. package/corpus/templates/clips/desktop/src/app.tsx +17 -1
  118. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +37 -48
  119. package/corpus/templates/clips/desktop/src/lib/recorder.ts +331 -36
  120. package/corpus/templates/clips/desktop/src/lib/transcription-capture.ts +7 -5
  121. package/corpus/templates/clips/desktop/src/lib/transcription-engine.ts +182 -106
  122. package/corpus/templates/clips/desktop/src/lib/upload-recovery.ts +129 -0
  123. package/corpus/templates/clips/desktop/src/overlays/live-transcript.tsx +43 -14
  124. package/corpus/templates/clips/desktop/src/overlays/recording-pill.tsx +3 -3
  125. package/corpus/templates/clips/desktop/src-tauri/src/echo_guard.rs +443 -0
  126. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
  127. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +815 -15
  128. package/corpus/templates/clips/desktop/src-tauri/src/tray.rs +61 -11
  129. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +125 -16
  130. package/corpus/templates/clips/desktop/vitest.config.ts +9 -0
  131. package/corpus/templates/clips/server/db/schema.ts +17 -0
  132. package/corpus/templates/clips/server/jobs/media-verification.ts +4 -0
  133. package/corpus/templates/clips/server/lib/activity-notifications.ts +185 -0
  134. package/corpus/templates/clips/server/lib/recording-media-cleanup.ts +2 -0
  135. package/corpus/templates/clips/server/lib/recording-upload-state.ts +46 -9
  136. package/corpus/templates/clips/server/lib/recordings.ts +24 -0
  137. package/corpus/templates/clips/server/lib/resumable-session.ts +10 -4
  138. package/corpus/templates/clips/server/lib/transactional-email-templates.ts +109 -0
  139. package/corpus/templates/clips/server/lib/upload-interruption.ts +8 -0
  140. package/corpus/templates/clips/server/lib/upload-lease.ts +50 -4
  141. package/corpus/templates/clips/server/lib/video-filmstrip-sprite.ts +207 -0
  142. package/corpus/templates/clips/server/lib/video-remux.ts +13 -4
  143. package/corpus/templates/clips/server/plugins/db.ts +16 -0
  144. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +4 -0
  145. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +123 -36
  146. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +159 -29
  147. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/interrupt.post.ts +197 -0
  148. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +193 -39
  149. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/resume.get.ts +191 -12
  150. package/corpus/templates/clips/shared/clips-ai-prefs.ts +6 -0
  151. package/corpus/templates/clips/shared/feature-flags.ts +12 -0
  152. package/corpus/templates/clips/shared/recording-core.ts +4 -0
  153. package/corpus/templates/clips/vitest.config.ts +23 -19
  154. package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +6 -4
  155. package/corpus/templates/content/actions/_database-membership-lock.ts +25 -0
  156. package/corpus/templates/content/actions/_database-row-batch.ts +6 -2
  157. package/corpus/templates/content/actions/_database-source-utils.ts +289 -129
  158. package/corpus/templates/content/actions/_database-utils.ts +12 -3
  159. package/corpus/templates/content/actions/add-comment.ts +15 -1
  160. package/corpus/templates/content/actions/add-content-database-source-field-property.ts +39 -21
  161. package/corpus/templates/content/actions/attach-content-database-source.ts +4 -1
  162. package/corpus/templates/content/actions/change-content-database-source-role.ts +3 -5
  163. package/corpus/templates/content/actions/duplicate-document-property.ts +59 -46
  164. package/corpus/templates/content/actions/execute-builder-source-execution.ts +77 -71
  165. package/corpus/templates/content/actions/list-documents.ts +1 -0
  166. package/corpus/templates/content/actions/materialize-builder-required-fields.ts +7 -0
  167. package/corpus/templates/content/actions/remove-database-items.ts +183 -0
  168. package/corpus/templates/content/actions/set-document-property.ts +57 -39
  169. package/corpus/templates/content/actions/sync-local-folder-source.ts +7 -0
  170. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +5 -2
  171. package/corpus/templates/content/app/components/editor/DocumentToolbar.tsx +50 -1
  172. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +471 -329
  173. package/corpus/templates/content/app/components/editor/database/row-access.ts +45 -0
  174. package/corpus/templates/content/app/components/editor/database/shared.tsx +141 -38
  175. package/corpus/templates/content/app/components/ui/switch.tsx +1 -0
  176. package/corpus/templates/content/app/hooks/use-content-database.ts +2 -2
  177. package/corpus/templates/content/app/hooks/use-content-prefs.ts +55 -0
  178. package/corpus/templates/content/app/i18n-data.ts +170 -0
  179. package/corpus/templates/content/app/routes/_app.settings.tsx +47 -18
  180. package/corpus/templates/content/changelog/2026-07-30-breadcrumb-menus-now-hide-internal-system-pages.md +6 -0
  181. package/corpus/templates/content/changelog/2026-07-30-database-rows-can-now-be-removed-without-deleting-their-page.md +6 -0
  182. package/corpus/templates/content/changelog/2026-08-01-get-an-email-when-someone-comments-on-replies-in-or-mentions.md +6 -0
  183. package/corpus/templates/content/parity/eval-scenarios.ts +2 -2
  184. package/corpus/templates/content/parity/matrix.md +1 -1
  185. package/corpus/templates/content/parity/matrix.ts +4 -5
  186. package/corpus/templates/content/server/lib/comment-notifications.ts +146 -0
  187. package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.get.ts +17 -0
  188. package/corpus/templates/content/server/routes/_agent-native/content/user-prefs.put.ts +36 -0
  189. package/corpus/templates/content/shared/api.ts +4 -0
  190. package/corpus/templates/content/shared/content-user-prefs.ts +11 -0
  191. package/corpus/templates/content/vitest.config.ts +20 -16
  192. package/corpus/templates/crm/app/routes/settings.tsx +14 -22
  193. package/corpus/templates/crm/vitest.config.ts +8 -4
  194. package/corpus/templates/design/.generated/bridge/editor-chrome.generated.ts +26 -11
  195. package/corpus/templates/design/actions/take-design-screenshot.ts +7 -0
  196. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +11 -3
  197. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +4 -1
  198. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +54 -17
  199. package/corpus/templates/design/app/components/design/design-canvas/local-runtime.ts +126 -0
  200. package/corpus/templates/design/app/components/templates/TemplatePreview.tsx +2 -0
  201. package/corpus/templates/design/app/pages/Index.tsx +3 -1
  202. package/corpus/templates/design/app/pages/Present.tsx +2 -1
  203. package/corpus/templates/design/app/routes/settings.tsx +14 -22
  204. package/corpus/templates/design/changelog/2026-07-30-a-broken-inline-script-in-a-generated-screen-is-now-caught-o.md +6 -0
  205. package/corpus/templates/design/changelog/2026-07-30-design-html-is-now-validated-with-a-spec-grade-html-parser-i.md +6 -0
  206. package/corpus/templates/design/changelog/2026-07-30-designs-now-load-their-tailwind-and-alpine-runtimes-from-the.md +6 -0
  207. package/corpus/templates/design/changelog/2026-07-30-designs-with-a-broken-alpine-expression-are-now-caught-on-sa.md +6 -0
  208. package/corpus/templates/design/changelog/2026-07-30-editing-a-design-no-longer-strips-its-styling-the-canvas-kep.md +6 -0
  209. package/corpus/templates/design/changelog/2026-08-01-review-comments-replies-and-mentions-now-send-an-email-to-th.md +6 -0
  210. package/corpus/templates/design/package.json +4 -0
  211. package/corpus/templates/design/shared/html-integrity.ts +802 -556
  212. package/corpus/templates/design/vitest.config.ts +18 -12
  213. package/corpus/templates/dispatch/app/routes/settings.tsx +26 -40
  214. package/corpus/templates/dispatch/vitest.config.ts +11 -7
  215. package/corpus/templates/factory/app/routes/settings.tsx +14 -22
  216. package/corpus/templates/factory/vitest.config.ts +9 -0
  217. package/corpus/templates/forms/app/routes/_app.settings.tsx +14 -22
  218. package/corpus/templates/forms/vitest.config.ts +27 -23
  219. package/corpus/templates/macros/app/routes/settings.tsx +14 -22
  220. package/corpus/templates/macros/vitest.config.ts +16 -12
  221. package/corpus/templates/mail/app/pages/SettingsPage.tsx +14 -16
  222. package/corpus/templates/mail/vitest.config.ts +16 -12
  223. package/corpus/templates/plan/app/routes/settings.tsx +30 -44
  224. package/corpus/templates/plan/vitest.config.ts +24 -20
  225. package/corpus/templates/slides/actions/add-slide-comment.ts +12 -1
  226. package/corpus/templates/slides/app/components/ui/switch.tsx +1 -0
  227. package/corpus/templates/slides/app/hooks/use-slides-prefs.ts +55 -0
  228. package/corpus/templates/slides/app/i18n/en-US.ts +4 -0
  229. package/corpus/templates/slides/app/routes/settings.tsx +45 -22
  230. package/corpus/templates/slides/changelog/2026-08-01-get-an-email-when-someone-comments-on-or-replies-in-your-dec.md +6 -0
  231. package/corpus/templates/slides/server/lib/comment-notifications.ts +182 -0
  232. package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.get.ts +16 -0
  233. package/corpus/templates/slides/server/routes/_agent-native/slides/user-prefs.put.ts +36 -0
  234. package/corpus/templates/slides/shared/slides-user-prefs.ts +11 -0
  235. package/corpus/templates/slides/vitest.config.ts +17 -13
  236. package/corpus/templates/tasks/app/components/layout/Sidebar.tsx +2 -0
  237. package/corpus/templates/tasks/app/routes/settings.tsx +14 -22
  238. package/corpus/templates/tasks/vitest.config.ts +17 -13
  239. package/corpus/toolkit/CHANGELOG.md +9 -0
  240. package/corpus/toolkit/package.json +1 -1
  241. package/corpus/toolkit/vitest.config.ts +3 -0
  242. package/dist/automations/service.d.ts +2 -0
  243. package/dist/automations/service.d.ts.map +1 -1
  244. package/dist/automations/service.js +29 -4
  245. package/dist/automations/service.js.map +1 -1
  246. package/dist/cli/mcp-config-writers.d.ts.map +1 -1
  247. package/dist/cli/mcp-config-writers.js +24 -23
  248. package/dist/cli/mcp-config-writers.js.map +1 -1
  249. package/dist/client/agent-page/AgentJobsTab.d.ts.map +1 -1
  250. package/dist/client/agent-page/AgentJobsTab.js +86 -29
  251. package/dist/client/agent-page/AgentJobsTab.js.map +1 -1
  252. package/dist/client/agent-page/AutomationDetailsDialog.d.ts +21 -0
  253. package/dist/client/agent-page/AutomationDetailsDialog.d.ts.map +1 -0
  254. package/dist/client/agent-page/AutomationDetailsDialog.js +33 -0
  255. package/dist/client/agent-page/AutomationDetailsDialog.js.map +1 -0
  256. package/dist/client/agent-page/AutomationScheduleDialog.d.ts +15 -0
  257. package/dist/client/agent-page/AutomationScheduleDialog.d.ts.map +1 -0
  258. package/dist/client/agent-page/AutomationScheduleDialog.js +46 -0
  259. package/dist/client/agent-page/AutomationScheduleDialog.js.map +1 -0
  260. package/dist/client/agent-page/TimezoneSelect.d.ts +17 -0
  261. package/dist/client/agent-page/TimezoneSelect.d.ts.map +1 -0
  262. package/dist/client/agent-page/TimezoneSelect.js +107 -0
  263. package/dist/client/agent-page/TimezoneSelect.js.map +1 -0
  264. package/dist/client/agent-page/use-jobs.d.ts +20 -7
  265. package/dist/client/agent-page/use-jobs.d.ts.map +1 -1
  266. package/dist/client/agent-page/use-jobs.js +24 -4
  267. package/dist/client/agent-page/use-jobs.js.map +1 -1
  268. package/dist/client/org/OrgSwitcher.d.ts +8 -1
  269. package/dist/client/org/OrgSwitcher.d.ts.map +1 -1
  270. package/dist/client/org/OrgSwitcher.js +8 -2
  271. package/dist/client/org/OrgSwitcher.js.map +1 -1
  272. package/dist/client/settings/AccountSettingsCard.d.ts.map +1 -1
  273. package/dist/client/settings/AccountSettingsCard.js +10 -9
  274. package/dist/client/settings/AccountSettingsCard.js.map +1 -1
  275. package/dist/client/settings/SchedulingTimezoneField.d.ts +10 -0
  276. package/dist/client/settings/SchedulingTimezoneField.d.ts.map +1 -0
  277. package/dist/client/settings/SchedulingTimezoneField.js +30 -0
  278. package/dist/client/settings/SchedulingTimezoneField.js.map +1 -0
  279. package/dist/client/settings/SettingsRow.d.ts +36 -0
  280. package/dist/client/settings/SettingsRow.d.ts.map +1 -0
  281. package/dist/client/settings/SettingsRow.js +15 -0
  282. package/dist/client/settings/SettingsRow.js.map +1 -0
  283. package/dist/client/settings/agent-settings-search.d.ts.map +1 -1
  284. package/dist/client/settings/agent-settings-search.js +1 -1
  285. package/dist/client/settings/agent-settings-search.js.map +1 -1
  286. package/dist/client/settings/index.d.ts +1 -0
  287. package/dist/client/settings/index.d.ts.map +1 -1
  288. package/dist/client/settings/index.js +1 -0
  289. package/dist/client/settings/index.js.map +1 -1
  290. package/dist/collab/struct-routes.d.ts +1 -1
  291. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  292. package/dist/integrations/identity.d.ts.map +1 -1
  293. package/dist/integrations/identity.js +2 -11
  294. package/dist/integrations/identity.js.map +1 -1
  295. package/dist/jobs/actions/list-automation-runs.d.ts +8 -0
  296. package/dist/jobs/actions/list-automation-runs.d.ts.map +1 -0
  297. package/dist/jobs/actions/list-automation-runs.js +29 -0
  298. package/dist/jobs/actions/list-automation-runs.js.map +1 -0
  299. package/dist/jobs/actions/list-recurring-jobs.d.ts +2 -0
  300. package/dist/jobs/actions/list-recurring-jobs.d.ts.map +1 -1
  301. package/dist/jobs/actions/list-recurring-jobs.js +20 -7
  302. package/dist/jobs/actions/list-recurring-jobs.js.map +1 -1
  303. package/dist/jobs/actions/manage-recurring-job.d.ts +2 -0
  304. package/dist/jobs/actions/manage-recurring-job.js +30 -9
  305. package/dist/jobs/actions/manage-recurring-job.js.map +1 -1
  306. package/dist/jobs/background-automation-runner.d.ts.map +1 -1
  307. package/dist/jobs/background-automation-runner.js +60 -0
  308. package/dist/jobs/background-automation-runner.js.map +1 -1
  309. package/dist/jobs/cron.d.ts +16 -2
  310. package/dist/jobs/cron.d.ts.map +1 -1
  311. package/dist/jobs/cron.js +43 -6
  312. package/dist/jobs/cron.js.map +1 -1
  313. package/dist/jobs/frontmatter.d.ts +12 -0
  314. package/dist/jobs/frontmatter.d.ts.map +1 -1
  315. package/dist/jobs/frontmatter.js +8 -0
  316. package/dist/jobs/frontmatter.js.map +1 -1
  317. package/dist/jobs/run-history.d.ts +56 -0
  318. package/dist/jobs/run-history.d.ts.map +1 -0
  319. package/dist/jobs/run-history.js +169 -0
  320. package/dist/jobs/run-history.js.map +1 -0
  321. package/dist/jobs/scheduler.d.ts.map +1 -1
  322. package/dist/jobs/scheduler.js +51 -18
  323. package/dist/jobs/scheduler.js.map +1 -1
  324. package/dist/jobs/tools.d.ts.map +1 -1
  325. package/dist/jobs/tools.js +37 -6
  326. package/dist/jobs/tools.js.map +1 -1
  327. package/dist/localization/actions/get-localization-preference.d.ts +1 -2
  328. package/dist/localization/actions/get-localization-preference.d.ts.map +1 -1
  329. package/dist/localization/actions/get-localization-preference.js.map +1 -1
  330. package/dist/localization/actions/set-localization-preference.d.ts +3 -3
  331. package/dist/localization/actions/set-localization-preference.d.ts.map +1 -1
  332. package/dist/localization/actions/set-localization-preference.js +33 -8
  333. package/dist/localization/actions/set-localization-preference.js.map +1 -1
  334. package/dist/localization/default-messages.d.ts +25 -0
  335. package/dist/localization/default-messages.d.ts.map +1 -1
  336. package/dist/localization/default-messages.js +25 -0
  337. package/dist/localization/default-messages.js.map +1 -1
  338. package/dist/localization/shared.d.ts +16 -1
  339. package/dist/localization/shared.d.ts.map +1 -1
  340. package/dist/localization/shared.js +22 -4
  341. package/dist/localization/shared.js.map +1 -1
  342. package/dist/localization/user-timezone.d.ts +11 -0
  343. package/dist/localization/user-timezone.d.ts.map +1 -0
  344. package/dist/localization/user-timezone.js +31 -0
  345. package/dist/localization/user-timezone.js.map +1 -0
  346. package/dist/notifications/routes.d.ts +2 -2
  347. package/dist/observability/routes.d.ts +1 -1
  348. package/dist/org/app-roles-handlers.d.ts.map +1 -1
  349. package/dist/org/app-roles-handlers.js +1 -8
  350. package/dist/org/app-roles-handlers.js.map +1 -1
  351. package/dist/org/index.d.ts +1 -0
  352. package/dist/org/index.d.ts.map +1 -1
  353. package/dist/org/index.js +1 -0
  354. package/dist/org/index.js.map +1 -1
  355. package/dist/org/membership.d.ts +6 -0
  356. package/dist/org/membership.d.ts.map +1 -0
  357. package/dist/org/membership.js +16 -0
  358. package/dist/org/membership.js.map +1 -0
  359. package/dist/provider-api/actions/custom-provider-registration.d.ts +10 -10
  360. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  361. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  362. package/dist/resources/handlers.d.ts +1 -1
  363. package/dist/review/actions/create-review-comment.d.ts +32 -1
  364. package/dist/review/actions/create-review-comment.d.ts.map +1 -1
  365. package/dist/review/actions/create-review-comment.js +3 -1
  366. package/dist/review/actions/create-review-comment.js.map +1 -1
  367. package/dist/review/actions/reply-review-comment.d.ts +32 -1
  368. package/dist/review/actions/reply-review-comment.d.ts.map +1 -1
  369. package/dist/review/actions/reply-review-comment.js +3 -1
  370. package/dist/review/actions/reply-review-comment.js.map +1 -1
  371. package/dist/review/index.d.ts +1 -0
  372. package/dist/review/index.d.ts.map +1 -1
  373. package/dist/review/index.js +1 -0
  374. package/dist/review/index.js.map +1 -1
  375. package/dist/review/notifications.d.ts +24 -0
  376. package/dist/review/notifications.d.ts.map +1 -0
  377. package/dist/review/notifications.js +123 -0
  378. package/dist/review/notifications.js.map +1 -0
  379. package/dist/review/types.d.ts +5 -0
  380. package/dist/review/types.d.ts.map +1 -1
  381. package/dist/review/types.js.map +1 -1
  382. package/dist/server/action-discovery.d.ts.map +1 -1
  383. package/dist/server/action-discovery.js +4 -0
  384. package/dist/server/action-discovery.js.map +1 -1
  385. package/dist/server/activity-notifications.d.ts +61 -0
  386. package/dist/server/activity-notifications.d.ts.map +1 -0
  387. package/dist/server/activity-notifications.js +103 -0
  388. package/dist/server/activity-notifications.js.map +1 -0
  389. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  390. package/dist/server/app-name.d.ts +18 -0
  391. package/dist/server/app-name.d.ts.map +1 -1
  392. package/dist/server/app-name.js +31 -0
  393. package/dist/server/app-name.js.map +1 -1
  394. package/dist/server/better-auth-instance.js +4 -4
  395. package/dist/server/better-auth-instance.js.map +1 -1
  396. package/dist/server/email-templates.d.ts +12 -0
  397. package/dist/server/email-templates.d.ts.map +1 -1
  398. package/dist/server/email-templates.js +57 -14
  399. package/dist/server/email-templates.js.map +1 -1
  400. package/dist/server/email.d.ts +12 -0
  401. package/dist/server/email.d.ts.map +1 -1
  402. package/dist/server/email.js +52 -5
  403. package/dist/server/email.js.map +1 -1
  404. package/dist/server/index.d.ts +1 -0
  405. package/dist/server/index.d.ts.map +1 -1
  406. package/dist/server/index.js +1 -0
  407. package/dist/server/index.js.map +1 -1
  408. package/dist/sharing/index.d.ts +1 -0
  409. package/dist/sharing/index.d.ts.map +1 -1
  410. package/dist/sharing/index.js +1 -0
  411. package/dist/sharing/index.js.map +1 -1
  412. package/dist/sharing/recipients.d.ts +31 -0
  413. package/dist/sharing/recipients.d.ts.map +1 -0
  414. package/dist/sharing/recipients.js +40 -0
  415. package/dist/sharing/recipients.js.map +1 -0
  416. package/dist/templates/chat/app/routes/settings.tsx +14 -22
  417. package/dist/templates/chat/vitest.config.ts +9 -0
  418. package/dist/triggers/actions/list-automations.d.ts +2 -0
  419. package/dist/triggers/actions/list-automations.d.ts.map +1 -1
  420. package/dist/triggers/actions/list-automations.js +19 -8
  421. package/dist/triggers/actions/list-automations.js.map +1 -1
  422. package/dist/triggers/actions/manage-automation.d.ts +6 -0
  423. package/dist/triggers/actions/manage-automation.js +12 -6
  424. package/dist/triggers/actions/manage-automation.js.map +1 -1
  425. package/dist/triggers/actions.d.ts.map +1 -1
  426. package/dist/triggers/actions.js +15 -4
  427. package/dist/triggers/actions.js.map +1 -1
  428. package/dist/triggers/dispatcher.d.ts.map +1 -1
  429. package/dist/triggers/dispatcher.js +19 -17
  430. package/dist/triggers/dispatcher.js.map +1 -1
  431. package/dist/triggers/routes.d.ts.map +1 -1
  432. package/dist/triggers/routes.js +6 -6
  433. package/dist/triggers/routes.js.map +1 -1
  434. package/dist/vitest-config.d.ts +10 -0
  435. package/dist/vitest-config.d.ts.map +1 -0
  436. package/dist/vitest-config.js +55 -0
  437. package/dist/vitest-config.js.map +1 -0
  438. package/docs/content/template-content-developers.mdx +1 -1
  439. package/package.json +5 -4
  440. package/src/automations/service.ts +41 -4
  441. package/src/cli/mcp-config-writers.ts +27 -21
  442. package/src/client/agent-page/AgentJobsTab.tsx +187 -85
  443. package/src/client/agent-page/AutomationDetailsDialog.tsx +208 -0
  444. package/src/client/agent-page/AutomationScheduleDialog.tsx +180 -0
  445. package/src/client/agent-page/TimezoneSelect.tsx +241 -0
  446. package/src/client/agent-page/use-jobs.ts +50 -10
  447. package/src/client/org/OrgSwitcher.tsx +26 -0
  448. package/src/client/settings/AccountSettingsCard.tsx +35 -31
  449. package/src/client/settings/SchedulingTimezoneField.tsx +71 -0
  450. package/src/client/settings/SettingsRow.tsx +113 -0
  451. package/src/client/settings/agent-settings-search.ts +2 -1
  452. package/src/client/settings/index.ts +6 -0
  453. package/src/integrations/identity.ts +2 -12
  454. package/src/jobs/actions/list-automation-runs.ts +33 -0
  455. package/src/jobs/actions/list-recurring-jobs.ts +27 -6
  456. package/src/jobs/actions/manage-recurring-job.ts +41 -9
  457. package/src/jobs/background-automation-runner.ts +88 -0
  458. package/src/jobs/cron.ts +52 -6
  459. package/src/jobs/frontmatter.ts +20 -0
  460. package/src/jobs/run-history.ts +242 -0
  461. package/src/jobs/scheduler.ts +73 -18
  462. package/src/jobs/tools.ts +54 -6
  463. package/src/localization/actions/get-localization-preference.ts +2 -2
  464. package/src/localization/actions/set-localization-preference.ts +49 -11
  465. package/src/localization/default-messages.ts +27 -0
  466. package/src/localization/shared.ts +39 -6
  467. package/src/localization/user-timezone.ts +36 -0
  468. package/src/org/app-roles-handlers.ts +1 -9
  469. package/src/org/index.ts +2 -0
  470. package/src/org/membership.ts +18 -0
  471. package/src/review/actions/create-review-comment.ts +4 -1
  472. package/src/review/actions/reply-review-comment.ts +4 -1
  473. package/src/review/index.ts +5 -0
  474. package/src/review/notifications.ts +163 -0
  475. package/src/review/types.ts +7 -0
  476. package/src/server/action-discovery.ts +4 -0
  477. package/src/server/activity-notifications.ts +174 -0
  478. package/src/server/app-name.ts +31 -0
  479. package/src/server/better-auth-instance.ts +4 -4
  480. package/src/server/email-templates.ts +70 -14
  481. package/src/server/email.ts +66 -3
  482. package/src/server/index.ts +10 -0
  483. package/src/sharing/index.ts +5 -0
  484. package/src/sharing/recipients.ts +67 -0
  485. package/src/templates/chat/app/routes/settings.tsx +14 -22
  486. package/src/templates/chat/vitest.config.ts +9 -0
  487. package/src/triggers/actions/list-automations.ts +27 -8
  488. package/src/triggers/actions/manage-automation.ts +17 -6
  489. package/src/triggers/actions.ts +16 -4
  490. package/src/triggers/dispatcher.ts +44 -30
  491. package/src/triggers/routes.ts +19 -6
  492. package/src/vitest-config.ts +68 -0
  493. 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
- interface RawTextScan {
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 omitting one is legal authoring.
420
- * `html`/`head`/`body` belong here because the counting pass already owns them.
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 made validation quadratic on VALID documents,
478
- * not just malformed ones — a 117KB screen cost ~700ms on every save. Index the
479
- * line starts once, lazily, and binary search.
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
- interface TagScan {
513
- end: number;
514
- /** False when EOF arrived before the tag's `>`. */
515
- terminated: boolean;
516
- /** Whether EOF arrived inside a quoted value the truncation cause. */
517
- quoteOpen: boolean;
518
- /** The attribute that quote belonged to, when one was named. */
519
- unterminatedAttribute?: string;
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 raw-text end tag only closes the element when the name is followed by
524
- * whitespace, `/`, or `>`. Matching on a word boundary instead treats script
525
- * text like `"</script=template>"` as the closer, which orphans the real one.
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 rawTextCloser(tag: string): RegExp {
528
- return new RegExp(`<\\s*/\\s*${tag}(?=[\\s/>])`, "gi");
529
- }
530
-
531
- /** Consume one markup token starting at `start` (the `<`), honoring quotes. */
532
- function scanTag(value: string, start: number): TagScan {
533
- let quote: '"' | "'" | null = null;
534
- let pendingAttribute: string | undefined;
535
- let quotedAttribute: string | undefined;
536
- let word = "";
537
- for (let cursor = start; cursor < value.length; cursor += 1) {
538
- const character = value[cursor]!;
539
- if (quote) {
540
- if (character === quote) {
541
- quote = null;
542
- quotedAttribute = undefined;
543
- }
544
- continue;
545
- }
546
- if (character === '"' || character === "'") {
547
- quote = character;
548
- quotedAttribute = pendingAttribute;
549
- continue;
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 (character === ">") {
552
- return { end: cursor + 1, terminated: true, quoteOpen: false };
389
+ if (node.nodeName === "#text") {
390
+ textNodes.push(node as DefaultTreeAdapterTypes.TextNode);
553
391
  }
554
- if (character === "=") {
555
- if (word) pendingAttribute = word;
556
- word = "";
557
- continue;
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
- if (character === "<" || character === "/" || /\s/.test(character)) {
560
- word = "";
561
- continue;
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
- end: value.length,
567
- terminated: false,
568
- quoteOpen: quote !== null,
569
- unterminatedAttribute: quotedAttribute,
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
- * One parse for both facts. Deriving `isClose` separately let `< /div>` be read
575
- * as a close tag by one check and an open tag by the other.
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 tagAt(
578
- value: string,
579
- index: number,
580
- ): { tag: string; isClose: boolean } | null {
581
- const match = /^<(\s*\/)?\s*([a-zA-Z][a-zA-Z0-9:-]*)/.exec(
582
- value.slice(index, index + 64),
583
- );
584
- return match
585
- ? { tag: match[2]!.toLowerCase(), isClose: match[1] !== undefined }
586
- : null;
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
- * Runs on fragments as well as documents an unterminated quote is as
591
- * destructive in a `<template>` snippet as in a full page.
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 collectStructuralIssues(
594
- value: string,
595
- ): DesignHtmlIntegrityIssueDetail[] {
596
- const issues: DesignHtmlIntegrityIssueDetail[] = [];
597
- const stack: Array<{ tag: string; start: number }> = [];
598
- const locate = createLocator(value);
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) break;
604
-
605
- if (value.startsWith("<!--", open)) {
606
- const commentEnd = value.indexOf("-->", open + 4);
607
- if (commentEnd === -1) {
608
- issues.push({ issue: "content-truncated", ...locate(open) });
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
- if (value.startsWith("<!", open)) {
616
- cursor = Math.max(scanTag(value, open).end, open + 1);
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
- if (isClose) {
646
- let matched = -1;
647
- for (let index = stack.length - 1; index >= 0; index -= 1) {
648
- if (stack[index]!.tag === tag) {
649
- matched = index;
650
- break;
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 (matched === -1) {
654
- if (!OPTIONAL_CLOSE_TAGS.has(tag) && !VOID_TAGS.has(tag)) {
655
- issues.push({ issue: "close-tag-orphaned", ...locate(open), tag });
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
- cursor = scan.end;
675
- continue;
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
- const selfClosing = /\/\s*>$/.test(value.slice(open, scan.end));
679
-
680
- if (RAW_TEXT_TAGS.has(tag) && !selfClosing) {
681
- // Resume AT the closing tag so the close branch pops this element,
682
- // rather than duplicating that logic here. An unterminated body is left
683
- // to the raw-text-balance check, which names that cause correctly.
684
- const closer = rawTextCloser(tag);
685
- closer.lastIndex = scan.end;
686
- const found = closer.exec(value);
687
- if (!found) {
688
- // Report here rather than deferring to the document-only raw-text
689
- // balance check: fragments never reach that check, so an unclosed
690
- // <script> would pass the well-formedness gate entirely.
691
- issues.push({
692
- issue: "raw-text-balance",
693
- ...locate(open),
694
- tag,
695
- });
696
- return issues;
697
- }
698
- stack.push({ tag, start: open });
699
- cursor = found.index;
700
- continue;
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
- if (VOID_TAGS.has(tag) || selfClosing) {
704
- cursor = scan.end;
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
- // An implied close discards the target AND everything opened inside it —
709
- // the browser closes those too, so popping only the stack top reports a
710
- // still-open inline descendant (`<li><span>one<li>`) as unclosed.
711
- const impliedClose = IMPLICIT_SIBLING_CLOSE.get(tag);
712
- if (impliedClose) {
713
- for (let index = stack.length - 1; index >= 0; index -= 1) {
714
- if (!impliedClose.has(stack[index]!.tag)) continue;
715
- stack.length = index;
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
- // Stack order is document order, so stopping at the cap keeps the earliest
724
- // (outermost) unclosed elements without locating every one of them.
725
- for (const abandoned of stack) {
726
- if (issues.length >= MAX_REPORTED_ISSUES) break;
727
- if (OPTIONAL_CLOSE_TAGS.has(abandoned.tag)) continue;
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: "element-unclosed",
730
- ...locate(abandoned.start),
731
- tag: abandoned.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
- return issues
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
- value: string,
750
- rawTextBodyRanges: RawTextScan["bodyRanges"],
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(value)) return [];
757
- // Comments are not markup: a commented-out runtime tag is not a runtime.
758
- const value_ = value.replace(/<!--[\s\S]*?-->/g, "");
759
- const hasTailwindRuntime =
760
- /<script\b[^>]*\bsrc\s*=\s*(?:"[^"]*tailwind[^"]*"|'[^']*tailwind[^']*')/i.test(
761
- value_,
762
- ) ||
763
- /<style\b[^>]*\btype\s*=\s*(?:"text\/tailwindcss"|'text\/tailwindcss')/i.test(
764
- value_,
765
- ) ||
766
- /<link\b[^>]*\bhref\s*=\s*(?:"[^"]*tailwind[^"]*"|'[^']*tailwind[^']*')/i.test(
767
- value_,
768
- );
769
- if (hasTailwindRuntime) return [];
770
- const headIndex = value.search(/<head\b/i);
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
- ...createLocator(value)(headIndex === -1 ? 0 : headIndex),
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 without parsing/serializing it.
803
- * DOMParser is intentionally not used: it repairs exactly the missing
804
- * `<style>`/root boundaries this guard must detect. Alpine fragments and
805
- * `<template>` snippets are not documents and are handled by the transition
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 rawText = scanRawTextTags(value);
1126
+ const parsed = parseDocument(value);
1127
+ const locate = createLocator(value);
812
1128
 
813
- // Structure first, counting second. An unterminated quote swallows the root
814
- // tags, so the counting pass would report `document-root` — sending the fix to
815
- // a `<html>` tag that is present and correct instead of to the quote.
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, rawText.bodyRanges)) return { valid: true };
1137
+ if (!isDocumentHtml(value, parsed)) return { valid: true };
826
1138
 
827
- // Before the root counts: an unbalanced raw-text element swallows the tags
828
- // those counts look for, so checking order decides whether the report names
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 html = tagCount(value, "html", rawText.bodyRanges);
835
- if (html.open !== 1 || html.close !== 1) {
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(value, rawText.bodyRanges);
1145
+ const advisory = collectAdvisoryIssues(parsed, locate);
900
1146
  return advisory.length > 0 ? { valid: true, advisory } : { valid: true };
901
1147
  }
902
1148