Просмотр исходного кода

Use Hunspell for client-side spellchecking (#20286)

GitOrigin-RevId: c4d0d9e06fe0cc9d7cb7a058fd0768eb024e44f5
Alf Eaton 1 год назад
Родитель
Сommit
24c8629cd4
40 измененных файлов с 5390 добавлено и 513 удалено
  1. 11 0
      package-lock.json
  2. 1 0
      services/web/.eslintignore
  3. 1 0
      services/web/.prettierignore
  4. 1 0
      services/web/app/src/Features/Editor/EditorHttpController.js
  5. 1 0
      services/web/app/src/Features/Project/ProjectController.js
  6. 1 1
      services/web/app/src/Features/Spelling/SpellingController.js
  7. 1 0
      services/web/app/src/infrastructure/ExpressLocals.js
  8. 1 0
      services/web/app/src/infrastructure/PackageVersions.js
  9. 1 0
      services/web/app/views/layout-base.pug
  10. 1 2
      services/web/bin/sentry_upload
  11. 98 41
      services/web/config/settings.defaults.js
  12. 6 0
      services/web/cypress/support/webpack.cypress.ts
  13. 1 0
      services/web/frontend/extracted-translations.json
  14. 4 4
      services/web/frontend/js/features/dictionary/ignored-words.ts
  15. 24 10
      services/web/frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language.tsx
  16. 0 327
      services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts
  17. 224 0
      services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx
  18. 6 0
      services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts
  19. 64 24
      services/web/frontend/js/features/source-editor/extensions/spelling/index.ts
  20. 9 18
      services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts
  21. 85 23
      services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts
  22. 189 0
      services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx
  23. 8 3
      services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts
  24. 35 0
      services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts
  25. 9 0
      services/web/frontend/js/features/source-editor/hunspell/Dockerfile
  26. 111 0
      services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts
  27. 13 0
      services/web/frontend/js/features/source-editor/hunspell/build.sh
  28. 24 0
      services/web/frontend/js/features/source-editor/hunspell/compile.sh
  29. 237 0
      services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts
  30. 66 0
      services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.d.ts
  31. 3940 0
      services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs
  32. BIN
      services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.wasm
  33. 2 0
      services/web/frontend/js/utils/meta.ts
  34. 1 0
      services/web/locales/en.json
  35. 1 0
      services/web/package.json
  36. 100 0
      services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx
  37. 0 60
      services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.jsx
  38. 85 0
      services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx
  39. 2 0
      services/web/types/project-settings.ts
  40. 26 0
      services/web/webpack.config.js

+ 11 - 0
package-lock.json

@@ -8371,6 +8371,11 @@
       "resolved": "services/contacts",
       "link": true
     },
+    "node_modules/@overleaf/dictionaries": {
+      "version": "0.0.2",
+      "resolved": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
+      "integrity": "sha512-p8QJhmQcZ33PEPe0Lqi7NfFkA9IbZNOvlPk3URKFTeb3qAHW+s5+U9qEnHp1s3xr7ZOgu6D5YPK0fRNOE9+zmg=="
+    },
     "node_modules/@overleaf/docstore": {
       "resolved": "services/docstore",
       "link": true
@@ -43751,6 +43756,7 @@
         "@node-oauth/oauth2-server": "^5.1.0",
         "@node-saml/passport-saml": "^4.0.4",
         "@overleaf/access-token-encryptor": "*",
+        "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
         "@overleaf/fetch-utils": "*",
         "@overleaf/logger": "*",
         "@overleaf/metrics": "*",
@@ -51353,6 +51359,10 @@
         }
       }
     },
+    "@overleaf/dictionaries": {
+      "version": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
+      "integrity": "sha512-p8QJhmQcZ33PEPe0Lqi7NfFkA9IbZNOvlPk3URKFTeb3qAHW+s5+U9qEnHp1s3xr7ZOgu6D5YPK0fRNOE9+zmg=="
+    },
     "@overleaf/docstore": {
       "version": "file:services/docstore",
       "requires": {
@@ -52361,6 +52371,7 @@
         "@opentelemetry/semantic-conventions": "^1.15.2",
         "@overleaf/access-token-encryptor": "*",
         "@overleaf/codemirror-tree-view": "^0.1.3",
+        "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
         "@overleaf/fetch-utils": "*",
         "@overleaf/logger": "*",
         "@overleaf/metrics": "*",

+ 1 - 0
services/web/.eslintignore

@@ -9,3 +9,4 @@ frontend/js/features/source-editor/lezer-latex/latex.mjs
 frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
 frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
 frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
+frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs

+ 1 - 0
services/web/.prettierignore

@@ -11,3 +11,4 @@ frontend/js/features/source-editor/lezer-latex/latex.mjs
 frontend/js/features/source-editor/lezer-latex/latex.terms.mjs
 frontend/js/features/source-editor/lezer-bibtex/bibtex.mjs
 frontend/js/features/source-editor/lezer-bibtex/bibtex.terms.mjs
+frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs

+ 1 - 0
services/web/app/src/Features/Editor/EditorHttpController.js

@@ -79,6 +79,7 @@ async function joinProject(req, res, next) {
   // disable spellchecking for currently unsupported spell check languages
   // preserve the value in the db so they can use it again once we add back
   // support.
+  // TODO: allow these if in client-side spell check split test
   if (
     unsupportedSpellcheckLanguages.indexOf(project.spellCheckLanguage) !== -1
   ) {

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -340,6 +340,7 @@ const _ProjectController = {
       'ieee-stylesheet',
       'write-and-cite',
       'default-visual-for-beginners',
+      'spell-check-client',
     ].filter(Boolean)
 
     const getUserValues = async userId =>

+ 1 - 1
services/web/app/src/Features/Spelling/SpellingController.js

@@ -7,7 +7,7 @@ const LearnedWordsManager = require('./LearnedWordsManager')
 const TEN_SECONDS = 1000 * 10
 
 const languageCodeIsSupported = code =>
-  Settings.languages.some(lang => lang.code === code)
+  Settings.languages.some(lang => lang.code === code && lang.server !== false)
 
 module.exports = {
   learn(req, res, next) {

+ 1 - 0
services/web/app/src/infrastructure/ExpressLocals.js

@@ -174,6 +174,7 @@ module.exports = function (webRouter, privateApiRouter, publicApiRouter) {
     }
 
     res.locals.mathJaxPath = `/js/libs/mathjax-${PackageVersions.version.mathjax}/es5/tex-svg-full.js`
+    res.locals.dictionariesRoot = `/js/dictionaries/${PackageVersions.version.dictionaries}/`
 
     res.locals.lib = PackageVersions.lib
 

+ 1 - 0
services/web/app/src/infrastructure/PackageVersions.js

@@ -1,5 +1,6 @@
 const version = {
   mathjax: '3.2.2',
+  dictionaries: '0.0.2',
 }
 
 module.exports = {

+ 1 - 0
services/web/app/views/layout-base.pug

@@ -47,6 +47,7 @@ html(
 			//- See: https://webpack.js.org/guides/public-path/#on-the-fly
 			meta(name="ol-baseAssetPath" content=buildBaseAssetPath())
 			meta(name="ol-mathJaxPath" content=mathJaxPath)
+			meta(name="ol-dictionariesRoot" content=dictionariesRoot)
 
 			meta(name="ol-usersEmail" content=getUserEmail())
 			meta(name="ol-ab" data-type="json" content={})

+ 1 - 2
services/web/bin/sentry_upload

@@ -8,10 +8,9 @@ if [[ "$BRANCH_NAME" == "master" || "$BRANCH_NAME" == "main" ]]; then
   cd sentry_upload/public
 
   SENTRY_RELEASE=${COMMIT_SHA}
-  OPTS="--no-rewrite --url-prefix ~"
   sentry-cli releases new "$SENTRY_RELEASE"
   sentry-cli releases set-commits --auto "$SENTRY_RELEASE"
-  sentry-cli releases files "$SENTRY_RELEASE" upload-sourcemaps ${OPTS} .
+  sentry-cli releases files "$SENTRY_RELEASE" upload-sourcemaps --ignore '*/dictionaries/*' --no-rewrite --url-prefix ~ .
   sentry-cli releases finalize "$SENTRY_RELEASE"
 
   rm -rf sentry_upload

+ 98 - 41
services/web/config/settings.defaults.js

@@ -428,58 +428,115 @@ module.exports = {
   },
 
   // Spelling languages
+  // dic = available in client
+  // server: false = not available on server
   // ------------------
-  //
-  // You must have the corresponding aspell package installed to
-  // be able to use a language.
   languages: [
     { code: 'en', name: 'English' },
-    { code: 'en_US', name: 'English (American)' },
-    { code: 'en_GB', name: 'English (British)' },
-    { code: 'en_CA', name: 'English (Canadian)' },
-    { code: 'af', name: 'Afrikaans' },
-    { code: 'ar', name: 'Arabic' },
-    { code: 'gl', name: 'Galician' },
-    { code: 'eu', name: 'Basque' },
-    { code: 'br', name: 'Breton' },
-    { code: 'bg', name: 'Bulgarian' },
-    { code: 'ca', name: 'Catalan' },
-    { code: 'hr', name: 'Croatian' },
-    { code: 'cs', name: 'Czech' },
-    { code: 'da', name: 'Danish' },
-    { code: 'nl', name: 'Dutch' },
-    { code: 'eo', name: 'Esperanto' },
-    { code: 'et', name: 'Estonian' },
-    { code: 'fo', name: 'Faroese' },
-    { code: 'fr', name: 'French' },
-    { code: 'de', name: 'German' },
-    { code: 'el', name: 'Greek' },
-    { code: 'id', name: 'Indonesian' },
-    { code: 'ga', name: 'Irish' },
-    { code: 'it', name: 'Italian' },
-    { code: 'kk', name: 'Kazakh' },
+    { code: 'en_US', dic: 'en_US', name: 'English (American)' },
+    { code: 'en_GB', dic: 'en_GB', name: 'English (British)' },
+    { code: 'en_CA', dic: 'en_CA', name: 'English (Canadian)' },
+    {
+      code: 'en_AU',
+      dic: 'en_AU',
+      name: 'English (Australian)',
+      server: false,
+    },
+    {
+      code: 'en_ZA',
+      dic: 'en_ZA',
+      name: 'English (South African)',
+      server: false,
+    },
+    { code: 'af', dic: 'af_ZA', name: 'Afrikaans' },
+    { code: 'an', dic: 'an_ES', name: 'Aragonese', server: false },
+    { code: 'ar', dic: 'ar', name: 'Arabic' },
+    { code: 'be_BY', dic: 'be_BY', name: 'Belarusian', server: false },
+    { code: 'gl', dic: 'gl_ES', name: 'Galician' },
+    { code: 'eu', dic: 'eu', name: 'Basque' },
+    { code: 'bn_BD', dic: 'bn_BD', name: 'Bengali', server: false },
+    { code: 'bs_BA', dic: 'bs_BA', name: 'Bosnian', server: false },
+    { code: 'br', dic: 'br_FR', name: 'Breton' },
+    { code: 'bg', dic: 'bg_BG', name: 'Bulgarian' },
+    { code: 'ca', dic: 'ca', name: 'Catalan' },
+    { code: 'hr', dic: 'hr_HR', name: 'Croatian' },
+    { code: 'cs', dic: 'cs_CZ', name: 'Czech' },
+    {
+      code: 'da',
+      // dic: 'da_DK', TODO: re-enable client spell check
+      name: 'Danish',
+    },
+    { code: 'nl', dic: 'nl', name: 'Dutch' },
+    { code: 'dz', dic: 'dz', name: 'Dzongkha', server: false },
+    { code: 'eo', dic: 'eo', name: 'Esperanto' },
+    { code: 'et', dic: 'et_EE', name: 'Estonian' },
+    { code: 'fo', dic: 'fo', name: 'Faroese' },
+    { code: 'fr', dic: 'fr', name: 'French' },
+    { code: 'gl_ES', dic: 'gl_ES', name: 'Galician', server: false },
+    { code: 'de', dic: 'de_DE', name: 'German' },
+    { code: 'de_AT', dic: 'de_AT', name: 'German (Austria)', server: false },
+    {
+      code: 'de_CH',
+      dic: 'de_CH',
+      name: 'German (Switzerland)',
+      server: false,
+    },
+    { code: 'el', dic: 'el_GR', name: 'Greek' },
+    { code: 'gug_PY', dic: 'gug_PY', name: 'Guarani', server: false },
+    { code: 'gu_IN', dic: 'gu_IN', name: 'Gujarati', server: false },
+    { code: 'he_IL', dic: 'he_IL', name: 'Hebrew', server: false },
+    { code: 'hi_IN', dic: 'hi_IN', name: 'Hindi', server: false },
+    { code: 'hu_HU', dic: 'hu_HU', name: 'Hungarian', server: false },
+    { code: 'is_IS', dic: 'is_IS', name: 'Icelandic', server: false },
+    { code: 'id', dic: 'id_ID', name: 'Indonesian' },
+    { code: 'ga', dic: 'ga_IE', name: 'Irish' },
+    { code: 'it', dic: 'it_IT', name: 'Italian' },
+    { code: 'kk', dic: 'kk_KZ', name: 'Kazakh' },
+    { code: 'ko', dic: 'ko', name: 'Korean', server: false },
     { code: 'ku', name: 'Kurdish' },
-    { code: 'lv', name: 'Latvian' },
-    { code: 'lt', name: 'Lithuanian' },
+    { code: 'kmr', dic: 'kmr_Latn', name: 'Kurmanji', server: false },
+    { code: 'lv', dic: 'lv_LV', name: 'Latvian' },
+    { code: 'lt', dic: 'lt_LT', name: 'Lithuanian' },
+    { code: 'lo_LA', dic: 'lo_LA', name: 'Laotian', server: false },
+    { code: 'ml_IN', dic: 'ml_IN', name: 'Malayalam', server: false },
+    { code: 'mn_MN', dic: 'mn_MN', name: 'Mongolian', server: false },
     { code: 'nr', name: 'Ndebele' },
+    { code: 'ne_NP', dic: 'ne_NP', name: 'Nepali', server: false },
     { code: 'ns', name: 'Northern Sotho' },
-    { code: 'no', name: 'Norwegian' },
-    { code: 'fa', name: 'Persian' },
-    { code: 'pl', name: 'Polish' },
-    { code: 'pt_BR', name: 'Portuguese (Brazilian)' },
-    { code: 'pt_PT', name: 'Portuguese (European)' },
+    { code: 'no', dic: 'nn_NO', name: 'Norwegian' },
+    { code: 'oc_FR', dic: 'oc_FR', name: 'Occitan', server: false },
+    { code: 'fa', dic: 'fa_IR', name: 'Persian' },
+    { code: 'pl', dic: 'pl_PL', name: 'Polish' },
+    { code: 'pt_BR', dic: 'pt_BR', name: 'Portuguese (Brazilian)' },
+    {
+      code: 'pt_PT',
+      dic: 'pt_PT',
+      name: 'Portuguese (European)',
+      server: true,
+    },
     { code: 'pa', name: 'Punjabi' },
-    { code: 'ro', name: 'Romanian' },
-    { code: 'ru', name: 'Russian' },
-    { code: 'sk', name: 'Slovak' },
-    { code: 'sl', name: 'Slovenian' },
+    { code: 'ro', dic: 'ro_RO', name: 'Romanian' },
+    { code: 'ru', dic: 'ru_RU', name: 'Russian' },
+    { code: 'gd_GB', dic: 'gd_GB', name: 'Scottish Gaelic', server: false },
+    { code: 'sr_RS', dic: 'sr_RS', name: 'Serbian', server: false },
+    { code: 'si_LK', dic: 'si_LK', name: 'Sinhala', server: false },
+    { code: 'sk', dic: 'sk_SK', name: 'Slovak' },
+    { code: 'sl', dic: 'sl_SI', name: 'Slovenian' },
     { code: 'st', name: 'Southern Sotho' },
-    { code: 'es', name: 'Spanish' },
-    { code: 'sv', name: 'Swedish' },
-    { code: 'tl', name: 'Tagalog' },
+    { code: 'es', dic: 'es_ES', name: 'Spanish' },
+    { code: 'sw_TZ', dic: 'sw_TZ', name: 'Swahili', server: false },
+    { code: 'sv', dic: 'sv_SE', name: 'Swedish' },
+    { code: 'tl', dic: 'tl', name: 'Tagalog' },
+    { code: 'te_IN', dic: 'te_IN', name: 'Telugu', server: false },
+    { code: 'th_TH', dic: 'th_TH', name: 'Thai', server: false },
+    { code: 'bo', dic: 'bo', name: 'Tibetan', server: false },
     { code: 'ts', name: 'Tsonga' },
     { code: 'tn', name: 'Tswana' },
+    { code: 'tr_TR', dic: 'tr_TR', name: 'Turkish', server: false },
+    { code: 'uk_UA', dic: 'uk_UA', name: 'Ukrainian', server: false },
     { code: 'hsb', name: 'Upper Sorbian' },
+    { code: 'uz_UZ', dic: 'uz_UZ', name: 'Uzbek', server: false },
+    { code: 'vi_VN', dic: 'vi_VN', name: 'Vietnamese', server: false },
     { code: 'cy', name: 'Welsh' },
     { code: 'xh', name: 'Xhosa' },
   ],

+ 6 - 0
services/web/cypress/support/webpack.cypress.ts

@@ -34,6 +34,12 @@ const buildConfig = () => {
     '../../frontend/js/features/source-editor/languages/latex/linter/latex-linter.worker'
   )
 
+  // add entrypoint under '/' for hunspell worker
+  addWorker(
+    'hunspell-worker',
+    '../../frontend/js/features/source-editor/hunspell/hunspell.worker'
+  )
+
   // add entrypoints under '/' for pdfjs workers
   addWorker('pdfjs-dist213', 'pdfjs-dist213/legacy/build/pdf.worker.js')
   addWorker('pdfjs-dist401', 'pdfjs-dist401/legacy/build/pdf.worker.mjs')

+ 1 - 0
services/web/frontend/extracted-translations.json

@@ -65,6 +65,7 @@
   "add_or_remove_project_from_tag": "",
   "add_people": "",
   "add_role_and_department": "",
+  "add_to_dictionary": "",
   "add_to_tag": "",
   "add_your_comment_here": "",
   "add_your_first_group_member_now": "",

+ 4 - 4
services/web/frontend/js/features/dictionary/ignored-words.ts

@@ -1,6 +1,6 @@
 import getMeta from '../../utils/meta'
 
-const IGNORED_MISSPELLINGS = [
+export const globalLearnedWords = new Set([
   'Overleaf',
   'overleaf',
   'ShareLaTeX',
@@ -21,15 +21,15 @@ const IGNORED_MISSPELLINGS = [
   'lockdown',
   'Coronavirus',
   'coronavirus',
-]
+])
 
 export class IgnoredWords {
   public learnedWords!: Set<string>
-  private ignoredMisspellings: Set<string>
+  private readonly ignoredMisspellings: Set<string>
 
   constructor() {
     this.reset()
-    this.ignoredMisspellings = new Set(IGNORED_MISSPELLINGS)
+    this.ignoredMisspellings = globalLearnedWords
     window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests
   }
 

+ 24 - 10
services/web/frontend/js/features/editor-left-menu/components/settings/settings-spell-check-language.tsx

@@ -4,25 +4,39 @@ import getMeta from '../../../../utils/meta'
 import { useProjectSettingsContext } from '../../context/project-settings-context'
 import SettingsMenuSelect from './settings-menu-select'
 import type { Optgroup } from './settings-menu-select'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+
+// TODO: set to true when ready to show new languages that are only available in the client
+const showClientOnlyLanguages = false
 
 export default function SettingsSpellCheckLanguage() {
   const { t } = useTranslation()
   const languages = getMeta('ol-languages')
 
+  const spellCheckClientEnabled = useFeatureFlag('spell-check-client')
+
   const { spellCheckLanguage, setSpellCheckLanguage } =
     useProjectSettingsContext()
 
-  const optgroup: Optgroup = useMemo(
-    () => ({
+  const optgroup: Optgroup = useMemo(() => {
+    const options = (languages ?? []).filter(lang => {
+      const clientOnly = lang.server === false
+
+      if (clientOnly && !showClientOnlyLanguages) {
+        return false
+      }
+
+      return spellCheckClientEnabled || !clientOnly
+    })
+
+    return {
       label: 'Language',
-      options:
-        languages?.map(language => ({
-          value: language.code,
-          label: language.name,
-        })) ?? [],
-    }),
-    [languages]
-  )
+      options: options.map(language => ({
+        value: language.code,
+        label: language.name,
+      })),
+    }
+  }, [languages, spellCheckClientEnabled])
 
   return (
     <SettingsMenuSelect

+ 0 - 327
services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.ts

@@ -1,327 +0,0 @@
-import {
-  StateField,
-  StateEffect,
-  EditorSelection,
-  Prec,
-} from '@codemirror/state'
-import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
-import { addIgnoredWord } from './ignored-words'
-import { learnWordRequest } from './backend'
-import { Word, Mark, getMarkAtPosition } from './spellchecker'
-import { debugConsole } from '@/utils/debugging'
-
-const ITEMS_TO_SHOW = 8
-
-/*
- * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
- * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
- */
-let openingUntil = 0
-
-/*
- * Hide the spelling menu on click
- */
-const handleClickEvent = (event: MouseEvent, view: EditorView) => {
-  if (Date.now() < openingUntil) {
-    return
-  }
-
-  if (view.state.field(spellingMenuField, false)) {
-    view.dispatch({
-      effects: hideSpellingMenu.of(null),
-    })
-  }
-}
-
-/*
- * Detect when the user right-clicks on a misspelled word,
- * and show a menu of suggestions
- */
-const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
-  const position = view.posAtCoords(
-    {
-      x: event.pageX,
-      y: event.pageY,
-    },
-    false
-  )
-  const targetMark = getMarkAtPosition(view, position)
-
-  if (!targetMark) {
-    return
-  }
-
-  const { from, to, value } = targetMark
-
-  const targetWord = value.spec.word
-  if (!targetWord) {
-    debugConsole.debug(
-      '>> spelling no word associated with decorated range, stopping'
-    )
-    return
-  }
-
-  event.preventDefault()
-
-  openingUntil = Date.now() + 100
-
-  view.dispatch({
-    selection: EditorSelection.range(from, to),
-    effects: showSpellingMenu.of({
-      mark: targetMark,
-      word: targetWord,
-    }),
-  })
-}
-
-const handleShortcutEvent = (view: EditorView) => {
-  const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
-
-  if (!targetMark || !targetMark.value) {
-    return false
-  }
-
-  view.dispatch({
-    selection: EditorSelection.range(targetMark.from, targetMark.to),
-    effects: showSpellingMenu.of({
-      mark: targetMark,
-      word: targetMark.value.spec.word,
-    }),
-  })
-
-  return true
-}
-
-/*
- * Spelling menu "tooltip" field.
- * Manages the menu of suggestions shown on right-click
- */
-export const spellingMenuField = StateField.define<Tooltip | null>({
-  create() {
-    return null
-  },
-  update(value, transaction) {
-    if (value) {
-      value = {
-        ...value,
-        pos: transaction.changes.mapPos(value.pos),
-        end: value.end ? transaction.changes.mapPos(value.end) : undefined,
-      }
-    }
-
-    for (const effect of transaction.effects) {
-      if (effect.is(hideSpellingMenu)) {
-        value = null
-      } else if (effect.is(showSpellingMenu)) {
-        const { mark, word } = effect.value
-        // Build a "Tooltip" showing the suggestions
-        value = {
-          pos: mark.from,
-          end: mark.to,
-          above: false,
-          strictSide: false,
-          create: view => {
-            return createSpellingSuggestionList(word, view)
-          },
-        }
-      }
-    }
-    return value
-  },
-  provide: field => {
-    return [
-      showTooltip.from(field),
-      EditorView.domEventHandlers({
-        contextmenu: handleContextMenuEvent,
-        click: handleClickEvent,
-      }),
-      Prec.highest(
-        keymap.of([
-          { key: 'Ctrl-Space', run: handleShortcutEvent },
-          { key: 'Alt-Space', run: handleShortcutEvent },
-        ])
-      ),
-    ]
-  },
-})
-
-const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
-
-const hideSpellingMenu = StateEffect.define()
-
-/*
- * Creates the suggestion menu dom, to be displayed in the
- * spelling menu "tooltip"
- * */
-const createSpellingSuggestionList = (word: Word, view: EditorView) => {
-  // Wrapper div.
-  // Note, CM6 doesn't like showing complex elements
-  // 'inside' its Tooltip element, so we style this
-  // wrapper div to be basically invisible, and allow
-  // the dropdown list to hang off of it, giving the illusion that
-  // the list _is_ the tooltip.
-  // See the theme in spelling/index for styling that makes this work.
-  const dom = document.createElement('div')
-  dom.classList.add('ol-cm-spelling-context-menu-tooltip')
-
-  // List
-  const list = document.createElement('ul')
-  list.setAttribute('tabindex', '0')
-  list.setAttribute('role', 'menu')
-  list.addEventListener('keydown', event => {
-    if (event.code === 'Tab') {
-      // preventing selecting next element
-      event.preventDefault()
-    }
-  })
-  list.addEventListener('keyup', event => {
-    switch (event.code) {
-      case 'ArrowDown': {
-        // get currently selected option
-        const selectedButton =
-          list.querySelector<HTMLButtonElement>('li button:focus')
-
-        if (!selectedButton) {
-          return list
-            .querySelector<HTMLButtonElement>('li[role="option"] button')
-            ?.focus()
-        }
-
-        // get next option
-        let nextElement = selectedButton.parentElement?.nextElementSibling
-        if (nextElement?.getAttribute('role') !== 'option') {
-          nextElement = nextElement?.nextElementSibling
-        }
-        nextElement?.querySelector('button')?.focus()
-        break
-      }
-      case 'ArrowUp': {
-        // get currently selected option
-        const selectedButton =
-          list.querySelector<HTMLButtonElement>('li button:focus')
-
-        if (!selectedButton) {
-          return list
-            .querySelector<HTMLButtonElement>(
-              'li[role="option"]:last-child button'
-            )
-            ?.focus()
-        }
-
-        // get previous option
-        let previousElement =
-          selectedButton.parentElement?.previousElementSibling
-        if (previousElement?.getAttribute('role') !== 'option') {
-          previousElement = previousElement?.previousElementSibling
-        }
-        previousElement?.querySelector('button')?.focus()
-        break
-      }
-      case 'Escape':
-      case 'Tab': {
-        view.dispatch({
-          effects: hideSpellingMenu.of(null),
-        })
-        view.focus()
-        break
-      }
-    }
-  })
-
-  list.classList.add('dropdown-menu', 'dropdown-menu-unpositioned')
-
-  // List items, with links inside
-  if (Array.isArray(word.suggestions)) {
-    for (const suggestion of word.suggestions.slice(0, ITEMS_TO_SHOW)) {
-      const li = makeLinkItem(suggestion, event => {
-        const text = (event.target as HTMLElement).innerText
-        handleCorrectWord(word, text, view)
-        event.preventDefault()
-      })
-      list.appendChild(li)
-    }
-  }
-
-  setTimeout(() => {
-    list.querySelector<HTMLButtonElement>('li:first-child button')?.focus()
-  }, 0)
-
-  // Divider
-  const divider = document.createElement('li')
-  divider.classList.add('divider')
-  list.append(divider)
-
-  // Add to Dictionary
-  const addToDictionary = makeLinkItem(
-    'Add to Dictionary',
-    async function (event: Event) {
-      await handleLearnWord(word, view)
-      event.preventDefault()
-    }
-  )
-  list.append(addToDictionary)
-
-  dom.appendChild(list)
-  return { dom }
-}
-
-const makeLinkItem = (suggestion: string, handler: EventListener) => {
-  const li = document.createElement('li')
-  const button = document.createElement('button')
-  li.setAttribute('role', 'option')
-  button.classList.add('btn-link', 'text-left', 'dropdown-menu-button')
-  button.onclick = handler
-  button.textContent = suggestion
-  li.appendChild(button)
-  return li
-}
-
-/*
- * Learn a word, adding it to the local cache
- * and sending it to the spelling backend
- */
-const handleLearnWord = async function (word: Word, view: EditorView) {
-  try {
-    await learnWordRequest(word)
-    view.dispatch({
-      effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
-    })
-  } catch (err) {
-    debugConsole.error(err)
-  }
-}
-
-/*
- * Correct a word, removing the marked range
- * and replacing it with the chosen text
- */
-const handleCorrectWord = (word: Word, text: string, view: EditorView) => {
-  const tooltip = view.state.field(spellingMenuField)
-  if (!tooltip) {
-    throw new Error('No active tooltip')
-  }
-  const existingText = view.state.doc.sliceString(tooltip.pos, tooltip.end)
-  // Defend against erroneous replacement, if the word at this
-  // position is not actually what we think it is
-  if (existingText !== word.text) {
-    debugConsole.debug(
-      '>> spelling word-to-correct does not match, stopping',
-      tooltip.pos,
-      tooltip.end,
-      existingText,
-      word
-    )
-    return
-  }
-  view.dispatch({
-    changes: [
-      {
-        from: tooltip.pos,
-        to: tooltip.end,
-        insert: text,
-      },
-    ],
-    effects: [hideSpellingMenu.of(null)],
-  })
-  view.focus()
-}

+ 224 - 0
services/web/frontend/js/features/source-editor/extensions/spelling/context-menu.tsx

@@ -0,0 +1,224 @@
+import {
+  StateField,
+  StateEffect,
+  EditorSelection,
+  Prec,
+} from '@codemirror/state'
+import { EditorView, showTooltip, Tooltip, keymap } from '@codemirror/view'
+import { addIgnoredWord } from './ignored-words'
+import { learnWordRequest } from './backend'
+import { Word, Mark, getMarkAtPosition } from './spellchecker'
+import { debugConsole } from '@/utils/debugging'
+import {
+  getSpellChecker,
+  getSpellCheckLanguage,
+} from '@/features/source-editor/extensions/spelling/index'
+import { sendMB } from '@/infrastructure/event-tracking'
+import ReactDOM from 'react-dom'
+import { SpellingSuggestions } from '@/features/source-editor/extensions/spelling/spelling-suggestions'
+import { SplitTestProvider } from '@/shared/context/split-test-context'
+
+/*
+ * The time until which a click event will be ignored, so it doesn't immediately close the spelling menu.
+ * Safari emits an additional "click" event when event.preventDefault() is called in the "contextmenu" event listener.
+ */
+let openingUntil = 0
+
+/*
+ * Hide the spelling menu on click
+ */
+const handleClickEvent = (event: MouseEvent, view: EditorView) => {
+  if (Date.now() < openingUntil) {
+    return
+  }
+
+  if (view.state.field(spellingMenuField, false)) {
+    view.dispatch({
+      effects: hideSpellingMenu.of(null),
+    })
+  }
+}
+
+/*
+ * Detect when the user right-clicks on a misspelled word,
+ * and show a menu of suggestions
+ */
+const handleContextMenuEvent = (event: MouseEvent, view: EditorView) => {
+  const position = view.posAtCoords(
+    {
+      x: event.pageX,
+      y: event.pageY,
+    },
+    false
+  )
+  const targetMark = getMarkAtPosition(view, position)
+
+  if (!targetMark) {
+    return
+  }
+
+  const { from, to, value } = targetMark
+
+  const targetWord = value.spec.word
+  if (!targetWord) {
+    debugConsole.debug(
+      '>> spelling no word associated with decorated range, stopping'
+    )
+    return
+  }
+
+  event.preventDefault()
+
+  openingUntil = Date.now() + 100
+
+  view.dispatch({
+    selection: EditorSelection.range(from, to),
+    effects: showSpellingMenu.of({
+      mark: targetMark,
+      word: targetWord,
+    }),
+  })
+}
+
+const handleShortcutEvent = (view: EditorView) => {
+  const targetMark = getMarkAtPosition(view, view.state.selection.main.from)
+
+  if (!targetMark || !targetMark.value) {
+    return false
+  }
+
+  view.dispatch({
+    selection: EditorSelection.range(targetMark.from, targetMark.to),
+    effects: showSpellingMenu.of({
+      mark: targetMark,
+      word: targetMark.value.spec.word,
+    }),
+  })
+
+  return true
+}
+
+/*
+ * Spelling menu "tooltip" field.
+ * Manages the menu of suggestions shown on right-click
+ */
+export const spellingMenuField = StateField.define<Tooltip | null>({
+  create() {
+    return null
+  },
+  update(value, transaction) {
+    if (value) {
+      value = {
+        ...value,
+        pos: transaction.changes.mapPos(value.pos),
+        end: value.end ? transaction.changes.mapPos(value.end) : undefined,
+      }
+    }
+
+    for (const effect of transaction.effects) {
+      if (effect.is(hideSpellingMenu)) {
+        value = null
+      } else if (effect.is(showSpellingMenu)) {
+        const { mark, word } = effect.value
+        // Build a "Tooltip" showing the suggestions
+        value = {
+          pos: mark.from,
+          end: mark.to,
+          above: false,
+          strictSide: false,
+          create: createSpellingSuggestionList(word),
+        }
+      }
+    }
+    return value
+  },
+  provide: field => {
+    return [
+      showTooltip.from(field),
+      EditorView.domEventHandlers({
+        contextmenu: handleContextMenuEvent,
+        click: handleClickEvent,
+      }),
+      Prec.highest(
+        keymap.of([
+          { key: 'Ctrl-Space', run: handleShortcutEvent },
+          { key: 'Alt-Space', run: handleShortcutEvent },
+        ])
+      ),
+    ]
+  },
+})
+
+const showSpellingMenu = StateEffect.define<{ mark: Mark; word: Word }>()
+
+export const hideSpellingMenu = StateEffect.define()
+
+/*
+ * Creates the suggestion menu dom, to be displayed in the
+ * spelling menu "tooltip"
+ * */
+const createSpellingSuggestionList = (word: Word) => (view: EditorView) => {
+  const dom = document.createElement('div')
+  dom.classList.add('ol-cm-spelling-context-menu-tooltip')
+
+  ReactDOM.render(
+    <SplitTestProvider>
+      <SpellingSuggestions
+        word={word}
+        spellCheckLanguage={getSpellCheckLanguage(view.state)}
+        spellChecker={getSpellChecker(view.state)}
+        handleClose={() => {
+          view.dispatch({
+            effects: hideSpellingMenu.of(null),
+          })
+          view.focus()
+        }}
+        handleLearnWord={() => {
+          learnWordRequest(word)
+            .then(() => {
+              view.dispatch({
+                effects: [addIgnoredWord.of(word), hideSpellingMenu.of(null)],
+              })
+              sendMB('spelling-word-added', {
+                language: getSpellCheckLanguage(view.state),
+              })
+            })
+            .catch(error => {
+              debugConsole.error(error)
+            })
+        }}
+        handleCorrectWord={(text: string) => {
+          const tooltip = view.state.field(spellingMenuField)
+          if (!tooltip) {
+            throw new Error('No active tooltip')
+          }
+
+          const existingText = view.state.doc.sliceString(
+            tooltip.pos,
+            tooltip.end
+          )
+          if (existingText !== word.text) {
+            return
+          }
+
+          view.dispatch({
+            changes: [{ from: tooltip.pos, to: tooltip.end, insert: text }],
+            effects: [hideSpellingMenu.of(null)],
+          })
+          view.focus()
+
+          sendMB('spelling-suggestion-click', {
+            language: getSpellCheckLanguage(view.state),
+          })
+        }}
+      />
+    </SplitTestProvider>,
+    dom
+  )
+
+  const destroy = () => {
+    ReactDOM.unmountComponentAtNode(dom)
+  }
+
+  return { dom, destroy }
+}

+ 6 - 0
services/web/frontend/js/features/source-editor/extensions/spelling/ignored-words.ts

@@ -20,6 +20,12 @@ export const addIgnoredWord = StateEffect.define<{
   text: string
 }>()
 
+export const removeIgnoredWord = StateEffect.define<{
+  text: string
+}>()
+
 export const updateAfterAddingIgnoredWord = StateEffect.define<string>()
 
+export const updateAfterRemovingIgnoredWord = StateEffect.define<string>()
+
 export const resetSpellChecker = StateEffect.define()

+ 64 - 24
services/web/frontend/js/features/source-editor/extensions/spelling/index.ts

@@ -1,26 +1,29 @@
 import { EditorView, ViewPlugin } from '@codemirror/view'
 import {
-  Compartment,
-  Facet,
+  EditorState,
   StateEffect,
   StateField,
   TransactionSpec,
 } from '@codemirror/state'
-import { misspelledWordsField, resetMisspelledWords } from './misspelled-words'
+import { misspelledWordsField } from './misspelled-words'
 import {
+  addIgnoredWord,
   ignoredWordsField,
+  removeIgnoredWord,
   resetSpellChecker,
   updateAfterAddingIgnoredWord,
 } from './ignored-words'
 import { addWordToCache, cacheField, removeWordFromCache } from './cache'
-import { spellingMenuField } from './context-menu'
+import { hideSpellingMenu, spellingMenuField } from './context-menu'
 import { SpellChecker } from './spellchecker'
 import { parserWatcher } from '../wait-for-parser'
+import type { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
+import { debugConsole } from '@/utils/debugging'
 
-const spellCheckLanguageConf = new Compartment()
-const spellCheckLanguageFacet = Facet.define<string | undefined>()
-
-type Options = { spellCheckLanguage?: string }
+type Options = {
+  spellCheckLanguage?: string
+  hunspellManager?: HunspellManager
+}
 
 /**
  * A custom extension that creates a spell checker for the current language (from the user settings).
@@ -29,12 +32,16 @@ type Options = { spellCheckLanguage?: string }
  * Mis-spelled words are decorated with a Mark decoration.
  * The suggestions menu is displayed in a tooltip, activated with a right-click on the decoration.
  */
-export const spelling = ({ spellCheckLanguage }: Options) => {
+export const spelling = ({ spellCheckLanguage, hunspellManager }: Options) => {
   return [
     spellingTheme,
     parserWatcher,
-    spellCheckLanguageConf.of(spellCheckLanguageFacet.of(spellCheckLanguage)),
-    spellCheckField,
+    spellCheckLanguageField.init(() => spellCheckLanguage),
+    spellCheckerField.init(() =>
+      spellCheckLanguage
+        ? new SpellChecker(spellCheckLanguage, hunspellManager)
+        : null
+    ),
     misspelledWordsField,
     ignoredWordsField,
     cacheField,
@@ -56,16 +63,27 @@ const spellingTheme = EditorView.baseTheme({
   },
 })
 
-const spellCheckField = StateField.define<SpellChecker | null>({
-  create(state) {
-    const [spellCheckLanguage] = state.facet(spellCheckLanguageFacet)
-    return spellCheckLanguage ? new SpellChecker(spellCheckLanguage) : null
+export const getSpellChecker = (state: EditorState) =>
+  state.field(spellCheckerField, false)
+
+const spellCheckerField = StateField.define<SpellChecker | null>({
+  create() {
+    return null
   },
   update(value, tr) {
     for (const effect of tr.effects) {
       if (effect.is(setSpellCheckLanguageEffect)) {
         value?.destroy()
-        return effect.value ? new SpellChecker(effect.value) : null
+        value = effect.value.spellCheckLanguage
+          ? new SpellChecker(
+              effect.value.spellCheckLanguage,
+              effect.value.hunspellManager
+            )
+          : null
+      } else if (effect.is(addIgnoredWord)) {
+        value?.addWord(effect.value.text).catch(debugConsole.error)
+      } else if (effect.is(removeIgnoredWord)) {
+        value?.removeWord(effect.value.text).catch(debugConsole.error)
       }
     }
     return value
@@ -80,7 +98,7 @@ const spellCheckField = StateField.define<SpellChecker | null>({
         }
       }),
       EditorView.domEventHandlers({
-        focus: (event, view) => {
+        focus: (_event, view) => {
           if (view.state.facet(EditorView.editable)) {
             view.state.field(field)?.scheduleSpellCheck(view)
           }
@@ -95,18 +113,39 @@ const spellCheckField = StateField.define<SpellChecker | null>({
   },
 })
 
-const setSpellCheckLanguageEffect = StateEffect.define<string | undefined>()
+export const getSpellCheckLanguage = (state: EditorState) =>
+  state.field(spellCheckLanguageField, false)
 
-export const setSpelling = ({
+const spellCheckLanguageField = StateField.define<string | undefined>({
+  create() {
+    return undefined
+  },
+  update(value, tr) {
+    for (const effect of tr.effects) {
+      if (effect.is(setSpellCheckLanguageEffect)) {
+        value = effect.value.spellCheckLanguage
+      }
+    }
+    return value
+  },
+})
+
+export const setSpellCheckLanguageEffect = StateEffect.define<{
+  spellCheckLanguage: string | undefined
+  hunspellManager?: HunspellManager
+}>()
+
+export const setSpellCheckLanguage = ({
   spellCheckLanguage,
+  hunspellManager,
 }: Options): TransactionSpec => {
   return {
     effects: [
-      resetMisspelledWords.of(null),
-      spellCheckLanguageConf.reconfigure(
-        spellCheckLanguageFacet.of(spellCheckLanguage)
-      ),
-      setSpellCheckLanguageEffect.of(spellCheckLanguage),
+      setSpellCheckLanguageEffect.of({
+        spellCheckLanguage,
+        hunspellManager,
+      }),
+      hideSpellingMenu.of(null),
     ],
   }
 }
@@ -137,6 +176,7 @@ export const removeLearnedWord = (
         lang: spellCheckLanguage,
         wordText: word,
       }),
+      removeIgnoredWord.of({ text: word }),
     ],
   }
 }

+ 9 - 18
services/web/frontend/js/features/source-editor/extensions/spelling/misspelled-words.ts

@@ -2,11 +2,10 @@ import { StateField, StateEffect } from '@codemirror/state'
 import { EditorView, Decoration, DecorationSet } from '@codemirror/view'
 import { updateAfterAddingIgnoredWord } from './ignored-words'
 import { Word } from './spellchecker'
+import { setSpellCheckLanguageEffect } from '@/features/source-editor/extensions/spelling/index'
 
 export const addMisspelledWords = StateEffect.define<Word[]>()
 
-export const resetMisspelledWords = StateEffect.define()
-
 const createMark = (word: Word) => {
   return Decoration.mark({
     class: 'ol-cm-spelling-error',
@@ -39,14 +38,17 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
       if (effect.is(addMisspelledWords)) {
         // Merge the new misspelled words into the existing set of marks
         marks = marks.update({
-          add: effect.value.map(word => createMark(word)),
+          add: effect.value.map(word => createMark(word)), // TODO: make sure these positions are still accurate
           sort: true,
         })
       } else if (effect.is(updateAfterAddingIgnoredWord)) {
-        // Remove a misspelled word, all instances that match text
-        const word = effect.value
-        marks = removeAllMarksMatchingWordText(marks, word)
-      } else if (effect.is(resetMisspelledWords)) {
+        // Remove existing marks matching the text of a supplied word
+        marks = marks.update({
+          filter(_from, _to, mark) {
+            return mark.spec.word.text !== effect.value
+          },
+        })
+      } else if (effect.is(setSpellCheckLanguageEffect)) {
         marks = Decoration.none
       }
     }
@@ -56,14 +58,3 @@ export const misspelledWordsField = StateField.define<DecorationSet>({
     return EditorView.decorations.from(field)
   },
 })
-
-/*
- * Remove existing marks matching the text of a supplied word
- */
-const removeAllMarksMatchingWordText = (marks: DecorationSet, word: string) => {
-  return marks.update({
-    filter: (from, to, mark) => {
-      return mark.spec.word.text !== word
-    },
-  })
-}

+ 85 - 23
services/web/frontend/js/features/source-editor/extensions/spelling/spellchecker.ts

@@ -13,6 +13,7 @@ import {
 } from '../../utils/tree-query'
 import { waitForParser } from '../wait-for-parser'
 import { debugConsole } from '@/utils/debugging'
+import type { HunspellManager } from '../../hunspell/HunspellManager'
 
 /*
  * Spellchecker, handles updates, schedules spelling checks
@@ -26,13 +27,17 @@ export class SpellChecker {
   private trackedChanges: ChangeSet
 
   // eslint-disable-next-line no-useless-constructor
-  constructor(private readonly language: string) {
-    this.language = language
+  constructor(
+    private readonly language: string,
+    private hunspellManager?: HunspellManager
+  ) {
+    debugConsole.log('SpellChecker', language, hunspellManager)
     this.trackedChanges = ChangeSet.empty(0)
   }
 
   destroy() {
     this._clearPendingSpellCheck()
+    // this.hunspellManager?.destroy()
   }
 
   _abortRequest() {
@@ -86,7 +91,7 @@ export class SpellChecker {
       wordsToCheck
     )
     const processResult = (
-      misspellings: { index: number; suggestions: string[] }[]
+      misspellings: { index: number; suggestions?: string[] }[]
     ) => {
       this.trackedChanges = ChangeSet.empty(0)
 
@@ -108,16 +113,77 @@ export class SpellChecker {
     } else {
       this._abortRequest()
       this.abortController = new AbortController()
-      spellCheckRequest(this.language, unknownWords, this.abortController)
-        .then(result => {
-          this.abortController = null
-          processResult(result.misspellings)
+      if (this.hunspellManager) {
+        const signal = this.abortController.signal
+        this.hunspellManager.send(
+          {
+            type: 'spell',
+            words: unknownWords.map(word => word.text),
+          },
+          (result: any) => {
+            if (!signal.aborted) {
+              if (result.error) {
+                debugConsole.error(result.error)
+              } else {
+                processResult(result.misspellings)
+              }
+            }
+          }
+        )
+      } else {
+        spellCheckRequest(this.language, unknownWords, this.abortController)
+          .then(result => {
+            this.abortController = null
+            return processResult(result.misspellings)
+          })
+          .catch(error => {
+            this.abortController = null
+            debugConsole.error(error)
+          })
+      }
+    }
+  }
+
+  suggest(word: string) {
+    return new Promise<{ suggestions: string[] }>((resolve, reject) => {
+      if (this.hunspellManager) {
+        this.hunspellManager.send({ type: 'suggest', word }, result => {
+          if ((result as { error: true }).error) {
+            reject(new Error())
+          } else {
+            resolve(result as { suggestions: string[] })
+          }
         })
-        .catch(error => {
-          this.abortController = null
-          debugConsole.error(error)
+      }
+    })
+  }
+
+  addWord(word: string) {
+    return new Promise<void>((resolve, reject) => {
+      if (this.hunspellManager) {
+        this.hunspellManager.send({ type: 'add_word', word }, result => {
+          if ((result as { error: true }).error) {
+            reject(new Error())
+          } else {
+            resolve()
+          }
         })
-    }
+      }
+    })
+  }
+
+  removeWord(word: string) {
+    return new Promise<void>((resolve, reject) => {
+      if (this.hunspellManager) {
+        this.hunspellManager.send({ type: 'remove_word', word }, result => {
+          if ((result as { error: true }).error) {
+            reject(new Error())
+          } else {
+            resolve()
+          }
+        })
+      }
+    })
   }
 
   _spellCheckWhenParserReady(view: EditorView) {
@@ -163,7 +229,7 @@ export class SpellChecker {
     const { from, to } = view.viewport
     const changedLineNumbers = new Set<number>()
     if (this.trackedChanges.length > 0) {
-      this.trackedChanges.iterChangedRanges((fromA, toA, fromB, toB) => {
+      this.trackedChanges.iterChangedRanges((_fromA, _toA, fromB, toB) => {
         if (fromB <= to && toB >= from) {
           const fromLine = view.state.doc.lineAt(fromB).number
           const toLine = view.state.doc.lineAt(toB).number
@@ -180,7 +246,9 @@ export class SpellChecker {
       }
     }
 
-    const ignoredWords = view.state.field(ignoredWordsField)
+    const ignoredWords = this.hunspellManager
+      ? null
+      : view.state.field(ignoredWordsField)
     for (const i of changedLineNumbers) {
       const line = view.state.doc.line(i)
       wordsToCheck.push(
@@ -228,7 +296,7 @@ export class Word {
 export const buildSpellCheckResult = (
   knownMisspelledWords: Word[],
   unknownWords: Word[],
-  misspellings: { index: number; suggestions: string[] }[]
+  misspellings: { index: number; suggestions?: string[] }[]
 ) => {
   const cacheAdditions: [Word, string[] | boolean][] = []
 
@@ -277,7 +345,7 @@ export const compileEffects = (results: {
 export const getWordsFromLine = (
   view: EditorView,
   line: Line,
-  ignoredWords: IgnoredWords,
+  ignoredWords: IgnoredWords | null,
   lang: string
 ): Word[] => {
   const normalTextSpans: Array<NormalTextSpan> = getNormalTextSpansFromLine(
@@ -287,14 +355,8 @@ export const getWordsFromLine = (
   const words: Word[] = []
   for (const span of normalTextSpans) {
     for (const match of span.text.matchAll(WORD_REGEX)) {
-      let word = match[0]
-      if (word.startsWith("'")) {
-        word = word.slice(1)
-      }
-      if (word.endsWith("'")) {
-        word = word.slice(0, -1)
-      }
-      if (!ignoredWords.has(word)) {
+      const word = match[0].replace(/^'+/, '').replace(/'+$/, '')
+      if (!ignoredWords?.has(word)) {
         const from = span.from + match.index
         words.push(
           new Word({

+ 189 - 0
services/web/frontend/js/features/source-editor/extensions/spelling/spelling-suggestions.tsx

@@ -0,0 +1,189 @@
+import {
+  FC,
+  MouseEventHandler,
+  useCallback,
+  useEffect,
+  useMemo,
+  useState,
+} from 'react'
+import { SpellChecker, Word } from './spellchecker'
+import { useTranslation } from 'react-i18next'
+import SplitTestBadge from '@/shared/components/split-test-badge'
+import getMeta from '@/utils/meta'
+import classnames from 'classnames'
+import { SpellCheckLanguage } from '../../../../../../types/project-settings'
+import Icon from '@/shared/components/icon'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+import { sendMB } from '@/infrastructure/event-tracking'
+
+const ITEMS_TO_SHOW = 8
+
+// TODO: messaging below the spelling suggestions
+const SHOW_FOOTER = false
+
+// (index % length) that works for negative index
+const wrapArrayIndex = (index: number, length: number) =>
+  ((index % length) + length) % length
+
+export const SpellingSuggestions: FC<{
+  word: Word
+  spellCheckLanguage?: string
+  spellChecker?: SpellChecker | null
+  handleClose: () => void
+  handleLearnWord: () => void
+  handleCorrectWord: (text: string) => void
+}> = ({
+  word,
+  spellCheckLanguage,
+  spellChecker,
+  handleClose,
+  handleLearnWord,
+  handleCorrectWord,
+}) => {
+  const { t } = useTranslation()
+
+  const [suggestions, setSuggestions] = useState(() =>
+    Array.isArray(word.suggestions)
+      ? word.suggestions.slice(0, ITEMS_TO_SHOW)
+      : []
+  )
+
+  const [waiting, setWaiting] = useState(!word.suggestions)
+
+  const [selectedIndex, setSelectedIndex] = useState(0)
+
+  const itemsLength = suggestions.length + 1
+
+  useEffect(() => {
+    if (!word.suggestions) {
+      spellChecker?.suggest(word.text).then(result => {
+        setSuggestions(result.suggestions.slice(0, ITEMS_TO_SHOW))
+        setWaiting(false)
+        sendMB('spelling-suggestion-shown', {
+          language: spellCheckLanguage,
+          count: result.suggestions.length,
+          // word: transaction.state.sliceDoc(mark.from, mark.to),
+        })
+      })
+    }
+  }, [word, spellChecker, spellCheckLanguage])
+
+  const language = useMemo(() => {
+    if (spellCheckLanguage) {
+      return getMeta('ol-languages').find(
+        item => item.code === spellCheckLanguage
+      )
+    }
+  }, [spellCheckLanguage])
+
+  return (
+    <ul
+      className={classnames('dropdown-menu', 'dropdown-menu-unpositioned', {
+        hidden: waiting,
+      })}
+      tabIndex={0}
+      role="menu"
+      onKeyDown={event => {
+        switch (event.code) {
+          case 'ArrowDown':
+            setSelectedIndex(value => wrapArrayIndex(value + 1, itemsLength))
+            break
+
+          case 'ArrowUp':
+            setSelectedIndex(value => wrapArrayIndex(value - 1, itemsLength))
+            break
+
+          case 'Escape':
+          case 'Tab':
+            event.preventDefault()
+            handleClose()
+            break
+        }
+      }}
+    >
+      {Array.isArray(suggestions) && (
+        <>
+          {suggestions.map((suggestion, index) => (
+            <ListItem
+              key={suggestion}
+              content={suggestion}
+              selected={index === selectedIndex}
+              handleClick={event => {
+                event.preventDefault()
+                handleCorrectWord(suggestion)
+              }}
+            />
+          ))}
+          {suggestions.length > 0 && <li className="divider" />}
+        </>
+      )}
+      <ListItem
+        content={t('add_to_dictionary')}
+        selected={selectedIndex === itemsLength - 1}
+        handleClick={event => {
+          event.preventDefault()
+          handleLearnWord()
+        }}
+      />
+      {SHOW_FOOTER && language && (
+        <>
+          <li className="divider" />
+          <li>
+            <Footer language={language} />
+          </li>
+        </>
+      )}
+    </ul>
+  )
+}
+
+const Footer: FC<{ language: SpellCheckLanguage }> = ({ language }) => {
+  const spellCheckClientEnabled = useFeatureFlag('spell-check-client')
+
+  if (!spellCheckClientEnabled) {
+    return null
+  }
+
+  return language.dic ? (
+    <div>
+      <SplitTestBadge
+        splitTestName="spell-check-client"
+        displayOnVariants={['enabled']}
+      />{' '}
+      <small>{language?.name}</small>
+    </div>
+  ) : (
+    <div>
+      <Icon type="warning" fw /> <small>{language?.name}</small>
+    </div>
+  )
+}
+
+const ListItem: FC<{
+  content: string
+  selected: boolean
+  handleClick: MouseEventHandler<HTMLButtonElement>
+}> = ({ content, selected, handleClick }) => {
+  const handleListItem = useCallback(
+    (element: HTMLElement | null) => {
+      if (element && selected) {
+        window.setTimeout(() => {
+          element.focus()
+        })
+      }
+    },
+    [selected]
+  )
+
+  return (
+    <li role="menuitem">
+      <button
+        className="btn-link text-left dropdown-menu-button"
+        onClick={handleClick}
+        ref={handleListItem}
+      >
+        {content}
+      </button>
+    </li>
+  )
+}

+ 8 - 3
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -37,7 +37,7 @@ import {
   addLearnedWord,
   removeLearnedWord,
   resetLearnedWords,
-  setSpelling,
+  setSpellCheckLanguage,
 } from '../extensions/spelling'
 import {
   createChangeManager,
@@ -65,6 +65,7 @@ import { setMathPreview } from '@/features/source-editor/extensions/math-preview
 import { useRangesContext } from '@/features/review-panel-new/context/ranges-context'
 import { updateRanges } from '@/features/source-editor/extensions/ranges'
 import { useThreadsContext } from '@/features/review-panel-new/context/threads-context'
+import { useHunspell } from '@/features/source-editor/hooks/use-hunspell'
 
 function useCodeMirrorScope(view: EditorView) {
   const { fileTreeData } = useFileTreeData()
@@ -111,6 +112,8 @@ function useCodeMirrorScope(view: EditorView) {
     'project.spellCheckLanguage'
   )
 
+  const hunspellManager = useHunspell(spellCheckLanguage)
+
   const [visual] = useScopeValue<boolean>('editor.showVisual')
 
   const { referenceKeys } = useReferencesContext()
@@ -214,14 +217,16 @@ function useCodeMirrorScope(view: EditorView) {
 
   const spellingRef = useRef({
     spellCheckLanguage,
+    hunspellManager,
   })
 
   useEffect(() => {
     spellingRef.current = {
       spellCheckLanguage,
+      hunspellManager,
     }
-    view.dispatch(setSpelling(spellingRef.current))
-  }, [view, spellCheckLanguage])
+    view.dispatch(setSpellCheckLanguage(spellingRef.current))
+  }, [view, spellCheckLanguage, hunspellManager])
 
   // listen to doc:after-opened, and focus the editor if it's not a new doc
   useEffect(() => {

+ 35 - 0
services/web/frontend/js/features/source-editor/hooks/use-hunspell.ts

@@ -0,0 +1,35 @@
+import { isSplitTestEnabled } from '@/utils/splitTestUtils'
+import { useEffect, useState } from 'react'
+import getMeta from '@/utils/meta'
+import { globalLearnedWords } from '@/features/dictionary/ignored-words'
+import { HunspellManager } from '@/features/source-editor/hunspell/HunspellManager'
+import { debugConsole } from '@/utils/debugging'
+
+export const useHunspell = (spellCheckLanguage: string | null) => {
+  const [hunspellManager, setHunspellManager] = useState<HunspellManager>()
+
+  useEffect(() => {
+    if (isSplitTestEnabled('spell-check-client')) {
+      if (spellCheckLanguage) {
+        const languages = getMeta('ol-languages')
+        const lang = languages.find(item => item.code === spellCheckLanguage)
+        if (lang?.dic) {
+          const hunspellManager = new HunspellManager(lang.dic, [
+            ...globalLearnedWords,
+            ...getMeta('ol-learnedWords'),
+          ])
+          setHunspellManager(hunspellManager)
+          debugConsole.log(spellCheckLanguage, hunspellManager)
+
+          return () => {
+            hunspellManager.destroy()
+          }
+        } else {
+          setHunspellManager(undefined)
+        }
+      }
+    }
+  }, [spellCheckLanguage])
+
+  return hunspellManager
+}

+ 9 - 0
services/web/frontend/js/features/source-editor/hunspell/Dockerfile

@@ -0,0 +1,9 @@
+FROM emscripten/emsdk:3.1.67
+
+RUN apt-get update && apt-get install -y \
+    autoconf \
+    automake \
+    autopoint \
+    build-essential \
+    libtool \
+    cmake

+ 111 - 0
services/web/frontend/js/features/source-editor/hunspell/HunspellManager.ts

@@ -0,0 +1,111 @@
+import { v4 as uuid } from 'uuid'
+import { createWorker } from '@/utils/worker'
+import getMeta from '@/utils/meta'
+import { debugConsole } from '@/utils/debugging'
+
+type Message =
+  | {
+      id?: string
+      type: 'spell'
+      words: string[]
+    }
+  | {
+      id?: string
+      type: 'suggest'
+      word: string
+    }
+  | {
+      id?: string
+      type: 'add_word'
+      word: string
+    }
+  | {
+      id?: string
+      type: 'remove_word'
+      word: string
+    }
+  | {
+      id?: string
+      type: 'destroy'
+    }
+
+export class HunspellManager {
+  dictionariesRoot: string
+  hunspellWorker!: Worker
+  abortController: AbortController | undefined
+  listening = false
+  loaded = false
+  pendingMessages: Message[] = []
+  callbacks: Map<string, (value: unknown) => void> = new Map()
+
+  constructor(
+    private readonly language: string,
+    private readonly learnedWords: string[]
+  ) {
+    const baseAssetPath = new URL(
+      getMeta('ol-baseAssetPath'),
+      window.location.href
+    )
+
+    this.dictionariesRoot = new URL(
+      getMeta('ol-dictionariesRoot'),
+      baseAssetPath
+    ).toString()
+
+    createWorker(() => {
+      this.hunspellWorker = new Worker(
+        new URL('./hunspell.worker.ts', import.meta.url),
+        { type: 'module' }
+      )
+
+      this.hunspellWorker.addEventListener('message', this.receive.bind(this))
+    })
+  }
+
+  destroy() {
+    this.send({ type: 'destroy' }, () => {
+      this.hunspellWorker.terminate()
+    })
+  }
+
+  send(message: Message, callback: (value: unknown) => void) {
+    debugConsole.log(message)
+    if (callback) {
+      message.id = uuid()
+      this.callbacks.set(message.id, callback)
+    }
+
+    if (this.listening) {
+      this.hunspellWorker.postMessage(message)
+    } else {
+      this.pendingMessages.push(message)
+    }
+  }
+
+  receive(event: MessageEvent) {
+    debugConsole.log(event.data)
+    const { id, listening, loaded, ...rest } = event.data
+    if (id) {
+      const callback = this.callbacks.get(id)
+      if (callback) {
+        this.callbacks.delete(id)
+        callback(rest)
+      }
+    } else if (listening) {
+      this.listening = true
+      this.hunspellWorker.postMessage({
+        type: 'init',
+        lang: this.language,
+        learnedWords: this.learnedWords, // TODO: add words
+        dictionariesRoot: this.dictionariesRoot,
+      })
+      for (const message of this.pendingMessages) {
+        this.hunspellWorker.postMessage(message)
+        this.pendingMessages.length = 0
+      }
+    } else if (loaded) {
+      this.loaded = true
+      // TODO: use this to display pending state?
+    }
+  }
+}

+ 13 - 0
services/web/frontend/js/features/source-editor/hunspell/build.sh

@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -e
+
+# build an Emscripten SDK Docker image with Hunspell's build dependencies installed
+docker build --pull --tag overleaf/emsdk .
+
+# compile Hunspell to WASM and copy the output files from the Docker container
+docker run --rm \
+  --workdir /opt \
+  --volume "$(pwd)/wasm":/wasm \
+  --volume "$(pwd)/compile.sh":/opt/compile.sh:ro \
+  overleaf/emsdk \
+  bash compile.sh

+ 24 - 0
services/web/frontend/js/features/source-editor/hunspell/compile.sh

@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -e
+
+COMMIT="e994dceb97fb695bca6bfe5ad5665525426bf01f"
+
+curl -L "https://github.com/hunspell/hunspell/archive/${COMMIT}.tar.gz" | tar xvz
+
+cd "hunspell-${COMMIT}"
+autoreconf -fiv
+emconfigure ./configure --disable-shared --enable-static
+emmake make
+
+em++ \
+    -s EXPORTED_FUNCTIONS="['_Hunspell_create', '_Hunspell_destroy', '_Hunspell_spell', '_Hunspell_suggest', '_Hunspell_free_list', '_Hunspell_add_dic', '_Hunspell_add', '_Hunspell_remove', '_free', '_malloc', 'FS']" \
+    -s EXPORTED_RUNTIME_METHODS="['ccall', 'cwrap', 'getValue', 'stringToNewUTF8', 'UTF8ToString', 'WORKERFS']" \
+    -s ENVIRONMENT=worker \
+    -s ALLOW_MEMORY_GROWTH \
+    -lworkerfs.js \
+    -O2 \
+    -g2 \
+    src/hunspell/.libs/libhunspell-1.7.a \
+    -o hunspell.mjs
+
+cp hunspell.{mjs,wasm} /wasm/

+ 237 - 0
services/web/frontend/js/features/source-editor/hunspell/hunspell.worker.ts

@@ -0,0 +1,237 @@
+import Hunspell from './wasm/hunspell'
+
+type SpellChecker = {
+  spell(words: string[]): { index: number }[]
+  suggest(word: string): string[]
+  addWord(word: string): void
+  removeWord(word: string): void
+  destroy(): void
+}
+
+const createSpellChecker = async ({
+  lang,
+  learnedWords,
+  dictionariesRoot,
+}: {
+  lang: string
+  learnedWords: string[]
+  dictionariesRoot: string
+}) => {
+  const hunspell = await Hunspell()
+
+  const {
+    cwrap,
+    FS,
+    WORKERFS,
+    stringToNewUTF8,
+    _malloc,
+    _free,
+    getValue,
+    UTF8ToString,
+  } = hunspell
+
+  // https://github.com/hunspell/hunspell/blob/master/src/hunspell/hunspell.h
+  // https://github.com/kwonoj/hunspell-asm/blob/master/src/wrapHunspellInterface.ts
+
+  const create = cwrap('Hunspell_create', 'number', ['number', 'number'])
+  const destroy = cwrap('Hunspell_destroy', 'number', ['number', 'number'])
+  const spell = cwrap('Hunspell_spell', 'number', ['number', 'number'])
+  const suggest = cwrap('Hunspell_suggest', 'number', [
+    'number',
+    'number',
+    'number',
+  ])
+  const addWord = cwrap('Hunspell_add', 'number', ['number', 'number'])
+  const removeWord = cwrap('Hunspell_remove', 'number', ['number', 'number'])
+  const freeList = cwrap('Hunspell_free_list', 'number', [
+    'number',
+    'number',
+    'number',
+  ])
+
+  FS.mkdir('/dictionaries')
+
+  const [dic, aff] = await Promise.all([
+    fetch(new URL(`./${lang}.dic`, dictionariesRoot)).then(response =>
+      response.blob()
+    ),
+    fetch(new URL(`./${lang}.aff`, dictionariesRoot)).then(response =>
+      response.blob()
+    ),
+  ])
+
+  FS.mount(
+    WORKERFS,
+    {
+      blobs: [
+        { name: 'index.dic', data: dic },
+        { name: 'index.aff', data: aff },
+      ],
+    },
+    '/dictionaries'
+  )
+
+  const dicPtr = stringToNewUTF8('/dictionaries/index.dic')
+  const affPtr = stringToNewUTF8('/dictionaries/index.aff')
+  const spellPtr = create(affPtr, dicPtr)
+
+  for (const word of learnedWords) {
+    const wordPtr = stringToNewUTF8(word)
+    addWord(spellPtr, wordPtr)
+    _free(wordPtr)
+  }
+
+  const spellChecker: SpellChecker = {
+    spell(words) {
+      const misspellings: { index: number }[] = []
+
+      for (const [index, word] of words.entries()) {
+        const wordPtr = stringToNewUTF8(word)
+        const spellResult = spell(spellPtr, wordPtr)
+        _free(wordPtr)
+
+        if (spellResult === 0) {
+          misspellings.push({ index })
+        }
+      }
+
+      return misspellings
+    },
+    suggest(word) {
+      const suggestions: string[] = []
+
+      const suggestionListPtr = _malloc(4)
+      const wordPtr = stringToNewUTF8(word)
+      const suggestionCount = suggest(spellPtr, suggestionListPtr, wordPtr)
+      _free(wordPtr)
+      const suggestionListValuePtr = getValue(suggestionListPtr, '*')
+
+      for (let i = 0; i < suggestionCount; i++) {
+        const suggestion = UTF8ToString(
+          getValue(suggestionListValuePtr + i * 4, '*')
+        )
+        suggestions.push(suggestion)
+      }
+
+      freeList(spellPtr, suggestionListPtr, suggestionCount)
+      _free(suggestionListPtr)
+
+      return suggestions
+    },
+    addWord(word) {
+      const wordPtr = stringToNewUTF8(word)
+      const result = addWord(spellPtr, wordPtr)
+      _free(wordPtr)
+
+      if (result !== 0) {
+        throw new Error('The word could not be added to the dictionary')
+      }
+    },
+    removeWord(word) {
+      const wordPtr = stringToNewUTF8(word)
+      const result = removeWord(spellPtr, wordPtr)
+      _free(wordPtr)
+
+      if (result !== 0) {
+        throw new Error('The word could not be removed from the dictionary')
+      }
+    },
+    destroy() {
+      destroy(spellPtr)
+      _free(spellPtr)
+      _free(dicPtr)
+      _free(affPtr)
+    },
+  }
+
+  return spellChecker
+}
+
+let spellCheckerPromise: Promise<SpellChecker>
+
+self.addEventListener('message', async event => {
+  switch (event.data.type) {
+    case 'init':
+      try {
+        spellCheckerPromise = createSpellChecker(event.data)
+        await spellCheckerPromise
+        self.postMessage({ loaded: true })
+      } catch (error) {
+        console.error(error)
+        self.postMessage({ error: true })
+      }
+      break
+
+    case 'spell':
+      {
+        const { id, words } = event.data
+        try {
+          const spellChecker = await spellCheckerPromise
+          const misspellings = spellChecker.spell(words)
+          self.postMessage({ id, misspellings })
+        } catch (error) {
+          console.error(error)
+          self.postMessage({ id, error: true })
+        }
+      }
+      break
+
+    case 'suggest':
+      {
+        const { id, word } = event.data
+        try {
+          const spellChecker = await spellCheckerPromise
+          const suggestions = spellChecker.suggest(word)
+          self.postMessage({ id, suggestions })
+        } catch (error) {
+          console.error(error)
+          self.postMessage({ id, error: true })
+        }
+      }
+      break
+
+    case 'add_word':
+      {
+        const { id, word } = event.data
+        try {
+          const spellChecker = await spellCheckerPromise
+          spellChecker.addWord(word)
+          self.postMessage({ id })
+        } catch (error) {
+          console.error(error)
+          self.postMessage({ id, error: true })
+        }
+      }
+      break
+
+    case 'remove_word':
+      {
+        const { id, word } = event.data
+        try {
+          const spellChecker = await spellCheckerPromise
+          spellChecker.removeWord(word)
+          self.postMessage({ id })
+        } catch (error) {
+          console.error(error)
+          self.postMessage({ id, error: true })
+        }
+      }
+      break
+
+    case 'destroy':
+      {
+        const { id } = event.data
+        try {
+          const spellChecker = await spellCheckerPromise
+          spellChecker.destroy()
+          self.postMessage({ id })
+        } catch (error) {
+          console.error(error)
+          self.postMessage({ id, error: true })
+        }
+      }
+      break
+  }
+})
+
+self.postMessage({ listening: true })

+ 66 - 0
services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.d.ts

@@ -0,0 +1,66 @@
+/* eslint no-dupe-class-members: 0 */
+
+declare class Hunspell {
+  cwrap(
+    method: 'Hunspell_create',
+    output: string,
+    input: string[]
+  ): (affPtr: number, dicPtr: number) => number
+
+  cwrap(
+    method: 'Hunspell_destroy',
+    output: string,
+    input: string[]
+  ): (spellPtr: number) => number
+
+  cwrap(
+    method: 'Hunspell_spell',
+    output: string,
+    input: string[]
+  ): (spellPtr: number, wordPtr: number) => number
+
+  cwrap(
+    method: 'Hunspell_suggest',
+    output: string,
+    input: string[]
+  ): (spellPtr: number, suggestionListPtr: number, wordPtr: number) => number
+
+  cwrap(
+    method: 'Hunspell_add',
+    output: string,
+    input: string[]
+  ): (spellPtr: number, wordPtr: number) => number
+
+  cwrap(
+    method: 'Hunspell_remove',
+    output: string,
+    input: string[]
+  ): (spellPtr: number, wordPtr: number) => number
+
+  cwrap(
+    method: 'Hunspell_free_list',
+    output: string,
+    input: string[]
+  ): (spellPtr: number, suggestionListPtr: number, n: number) => number
+
+  stringToNewUTF8(input: string): number
+  UTF8ToString(input: number): string
+  _malloc(length: number): number
+  _free(ptr: number): void
+  getValue(ptr: number, type: string): number
+  FS: {
+    mkdir(path: string): void
+    mount(
+      type: any,
+      data: { blobs: Record<{ name: string; data: BlobPart }>[] },
+      dir: string
+    ): void
+  }
+
+  WORKERFS: any
+}
+
+declare const factory = async (options?: Record<string, any>) =>
+  new Hunspell(options)
+
+export default factory

+ 3940 - 0
services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.mjs

@@ -0,0 +1,3940 @@
+
+var Module = (() => {
+  var _scriptName = import.meta.url;
+  
+  return (
+function(moduleArg = {}) {
+  var moduleRtn;
+
+// include: shell.js
+// The Module object: Our interface to the outside world. We import
+// and export values on it. There are various ways Module can be used:
+// 1. Not defined. We create it here
+// 2. A function parameter, function(moduleArg) => Promise<Module>
+// 3. pre-run appended it, var Module = {}; ..generated code..
+// 4. External script tag defines var Module.
+// We need to check if Module already exists (e.g. case 3 above).
+// Substitution will be replaced with actual code on later stage of the build,
+// this way Closure Compiler will not mangle it (e.g. case 4. above).
+// Note that if you want to run closure, and also to use Module
+// after the generated code, you will need to define   var Module = {};
+// before the code. Then that object will be used in the code, and you
+// can continue to use Module afterwards as well.
+var Module = moduleArg;
+
+// Set up the promise that indicates the Module is initialized
+var readyPromiseResolve, readyPromiseReject;
+
+var readyPromise = new Promise((resolve, reject) => {
+  readyPromiseResolve = resolve;
+  readyPromiseReject = reject;
+});
+
+// Determine the runtime environment we are in. You can customize this by
+// setting the ENVIRONMENT setting at compile time (see settings.js).
+var ENVIRONMENT_IS_WEB = false;
+
+var ENVIRONMENT_IS_WORKER = true;
+
+// --pre-jses are emitted after the Module integration code, so that they can
+// refer to Module (if they choose; they can also define Module)
+// Sometimes an existing Module object exists with properties
+// meant to overwrite the default module functionality. Here
+// we collect those properties and reapply _after_ we configure
+// the current environment's defaults to avoid having to be so
+// defensive during initialization.
+var moduleOverrides = Object.assign({}, Module);
+
+var arguments_ = [];
+
+var thisProgram = "./this.program";
+
+// `/` should be present at the end if `scriptDirectory` is not empty
+var scriptDirectory = "";
+
+function locateFile(path) {
+  if (Module["locateFile"]) {
+    return Module["locateFile"](path, scriptDirectory);
+  }
+  return scriptDirectory + path;
+}
+
+// Hooks that are implemented differently in different runtime environments.
+var readAsync, readBinary;
+
+// Note that this includes Node.js workers when relevant (pthreads is enabled).
+// Node.js workers are detected as a combination of ENVIRONMENT_IS_WORKER and
+// ENVIRONMENT_IS_NODE.
+if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) {
+  if (ENVIRONMENT_IS_WORKER) {
+    // Check worker, not web, since window could be polyfilled
+    scriptDirectory = self.location.href;
+  } else if (typeof document != "undefined" && document.currentScript) {
+    // web
+    scriptDirectory = document.currentScript.src;
+  }
+  // When MODULARIZE, this JS may be executed later, after document.currentScript
+  // is gone, so we saved it, and we use it here instead of any other info.
+  if (_scriptName) {
+    scriptDirectory = _scriptName;
+  }
+  // blob urls look like blob:http://site.com/etc/etc and we cannot infer anything from them.
+  // otherwise, slice off the final part of the url to find the script directory.
+  // if scriptDirectory does not contain a slash, lastIndexOf will return -1,
+  // and scriptDirectory will correctly be replaced with an empty string.
+  // If scriptDirectory contains a query (starting with ?) or a fragment (starting with #),
+  // they are removed because they could contain a slash.
+  if (scriptDirectory.startsWith("blob:")) {
+    scriptDirectory = "";
+  } else {
+    scriptDirectory = scriptDirectory.substr(0, scriptDirectory.replace(/[?#].*/, "").lastIndexOf("/") + 1);
+  }
+  {
+    // include: web_or_worker_shell_read.js
+    if (ENVIRONMENT_IS_WORKER) {
+      readBinary = url => {
+        var xhr = new XMLHttpRequest;
+        xhr.open("GET", url, false);
+        xhr.responseType = "arraybuffer";
+        xhr.send(null);
+        return new Uint8Array(/** @type{!ArrayBuffer} */ (xhr.response));
+      };
+    }
+    readAsync = url => fetch(url, {
+      credentials: "same-origin"
+    }).then(response => {
+      if (response.ok) {
+        return response.arrayBuffer();
+      }
+      return Promise.reject(new Error(response.status + " : " + response.url));
+    });
+  }
+} else // end include: web_or_worker_shell_read.js
+{}
+
+var out = Module["print"] || console.log.bind(console);
+
+var err = Module["printErr"] || console.error.bind(console);
+
+// Merge back in the overrides
+Object.assign(Module, moduleOverrides);
+
+// Free the object hierarchy contained in the overrides, this lets the GC
+// reclaim data used.
+moduleOverrides = null;
+
+// Emit code to handle expected values on the Module object. This applies Module.x
+// to the proper local x. This has two benefits: first, we only emit it if it is
+// expected to arrive, and second, by using a local everywhere else that can be
+// minified.
+if (Module["arguments"]) arguments_ = Module["arguments"];
+
+if (Module["thisProgram"]) thisProgram = Module["thisProgram"];
+
+// perform assertions in shell.js after we set up out() and err(), as otherwise if an assertion fails it cannot print the message
+// end include: shell.js
+// include: preamble.js
+// === Preamble library stuff ===
+// Documentation for the public APIs defined in this file must be updated in:
+//    site/source/docs/api_reference/preamble.js.rst
+// A prebuilt local version of the documentation is available at:
+//    site/build/text/docs/api_reference/preamble.js.txt
+// You can also build docs locally as HTML or other formats in site/
+// An online HTML version (which may be of a different version of Emscripten)
+//    is up at http://kripken.github.io/emscripten-site/docs/api_reference/preamble.js.html
+var wasmBinary = Module["wasmBinary"];
+
+// Wasm globals
+var wasmMemory;
+
+//========================================
+// Runtime essentials
+//========================================
+// whether we are quitting the application. no code should run after this.
+// set in exit() and abort()
+var ABORT = false;
+
+// In STRICT mode, we only define assert() when ASSERTIONS is set.  i.e. we
+// don't define it at all in release modes.  This matches the behaviour of
+// MINIMAL_RUNTIME.
+// TODO(sbc): Make this the default even without STRICT enabled.
+/** @type {function(*, string=)} */ function assert(condition, text) {
+  if (!condition) {
+    // This build was created without ASSERTIONS defined.  `assert()` should not
+    // ever be called in this configuration but in case there are callers in
+    // the wild leave this simple abort() implementation here for now.
+    abort(text);
+  }
+}
+
+// Memory management
+var /** @type {!Int8Array} */ HEAP8, /** @type {!Uint8Array} */ HEAPU8, /** @type {!Int16Array} */ HEAP16, /** @type {!Uint16Array} */ HEAPU16, /** @type {!Int32Array} */ HEAP32, /** @type {!Uint32Array} */ HEAPU32, /** @type {!Float32Array} */ HEAPF32, /** @type {!Float64Array} */ HEAPF64;
+
+// include: runtime_shared.js
+function updateMemoryViews() {
+  var b = wasmMemory.buffer;
+  Module["HEAP8"] = HEAP8 = new Int8Array(b);
+  Module["HEAP16"] = HEAP16 = new Int16Array(b);
+  Module["HEAPU8"] = HEAPU8 = new Uint8Array(b);
+  Module["HEAPU16"] = HEAPU16 = new Uint16Array(b);
+  Module["HEAP32"] = HEAP32 = new Int32Array(b);
+  Module["HEAPU32"] = HEAPU32 = new Uint32Array(b);
+  Module["HEAPF32"] = HEAPF32 = new Float32Array(b);
+  Module["HEAPF64"] = HEAPF64 = new Float64Array(b);
+}
+
+// end include: runtime_shared.js
+// include: runtime_stack_check.js
+// end include: runtime_stack_check.js
+var __ATPRERUN__ = [];
+
+// functions called before the runtime is initialized
+var __ATINIT__ = [];
+
+// functions called during shutdown
+var __ATPOSTRUN__ = [];
+
+// functions called after the main() is called
+var runtimeInitialized = false;
+
+function preRun() {
+  if (Module["preRun"]) {
+    if (typeof Module["preRun"] == "function") Module["preRun"] = [ Module["preRun"] ];
+    while (Module["preRun"].length) {
+      addOnPreRun(Module["preRun"].shift());
+    }
+  }
+  callRuntimeCallbacks(__ATPRERUN__);
+}
+
+function initRuntime() {
+  runtimeInitialized = true;
+  if (!Module["noFSInit"] && !FS.initialized) FS.init();
+  FS.ignorePermissions = false;
+  TTY.init();
+  callRuntimeCallbacks(__ATINIT__);
+}
+
+function postRun() {
+  if (Module["postRun"]) {
+    if (typeof Module["postRun"] == "function") Module["postRun"] = [ Module["postRun"] ];
+    while (Module["postRun"].length) {
+      addOnPostRun(Module["postRun"].shift());
+    }
+  }
+  callRuntimeCallbacks(__ATPOSTRUN__);
+}
+
+function addOnPreRun(cb) {
+  __ATPRERUN__.unshift(cb);
+}
+
+function addOnInit(cb) {
+  __ATINIT__.unshift(cb);
+}
+
+function addOnPostRun(cb) {
+  __ATPOSTRUN__.unshift(cb);
+}
+
+// include: runtime_math.js
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/imul
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/fround
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/clz32
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/trunc
+// end include: runtime_math.js
+// A counter of dependencies for calling run(). If we need to
+// do asynchronous work before running, increment this and
+// decrement it. Incrementing must happen in a place like
+// Module.preRun (used by emcc to add file preloading).
+// Note that you can add dependencies in preRun, even though
+// it happens right before run - run will be postponed until
+// the dependencies are met.
+var runDependencies = 0;
+
+var runDependencyWatcher = null;
+
+var dependenciesFulfilled = null;
+
+// overridden to take different actions when all run dependencies are fulfilled
+function getUniqueRunDependency(id) {
+  return id;
+}
+
+function addRunDependency(id) {
+  runDependencies++;
+  Module["monitorRunDependencies"]?.(runDependencies);
+}
+
+function removeRunDependency(id) {
+  runDependencies--;
+  Module["monitorRunDependencies"]?.(runDependencies);
+  if (runDependencies == 0) {
+    if (runDependencyWatcher !== null) {
+      clearInterval(runDependencyWatcher);
+      runDependencyWatcher = null;
+    }
+    if (dependenciesFulfilled) {
+      var callback = dependenciesFulfilled;
+      dependenciesFulfilled = null;
+      callback();
+    }
+  }
+}
+
+/** @param {string|number=} what */ function abort(what) {
+  Module["onAbort"]?.(what);
+  what = "Aborted(" + what + ")";
+  // TODO(sbc): Should we remove printing and leave it up to whoever
+  // catches the exception?
+  err(what);
+  ABORT = true;
+  what += ". Build with -sASSERTIONS for more info.";
+  // Use a wasm runtime error, because a JS error might be seen as a foreign
+  // exception, which means we'd run destructors on it. We need the error to
+  // simply make the program stop.
+  // FIXME This approach does not work in Wasm EH because it currently does not assume
+  // all RuntimeErrors are from traps; it decides whether a RuntimeError is from
+  // a trap or not based on a hidden field within the object. So at the moment
+  // we don't have a way of throwing a wasm trap from JS. TODO Make a JS API that
+  // allows this in the wasm spec.
+  // Suppress closure compiler warning here. Closure compiler's builtin extern
+  // definition for WebAssembly.RuntimeError claims it takes no arguments even
+  // though it can.
+  // TODO(https://github.com/google/closure-compiler/pull/3913): Remove if/when upstream closure gets fixed.
+  /** @suppress {checkTypes} */ var e = new WebAssembly.RuntimeError(what);
+  readyPromiseReject(e);
+  // Throw the error whether or not MODULARIZE is set because abort is used
+  // in code paths apart from instantiation where an exception is expected
+  // to be thrown when abort is called.
+  throw e;
+}
+
+// include: memoryprofiler.js
+// end include: memoryprofiler.js
+// include: URIUtils.js
+// Prefix of data URIs emitted by SINGLE_FILE and related options.
+var dataURIPrefix = "data:application/octet-stream;base64,";
+
+/**
+ * Indicates whether filename is a base64 data URI.
+ * @noinline
+ */ var isDataURI = filename => filename.startsWith(dataURIPrefix);
+
+// end include: URIUtils.js
+// include: runtime_exceptions.js
+// end include: runtime_exceptions.js
+function findWasmBinary() {
+  if (Module["locateFile"]) {
+    var f = "hunspell.wasm";
+    if (!isDataURI(f)) {
+      return locateFile(f);
+    }
+    return f;
+  }
+  // Use bundler-friendly `new URL(..., import.meta.url)` pattern; works in browsers too.
+  return new URL("hunspell.wasm", import.meta.url).href;
+}
+
+var wasmBinaryFile;
+
+function getBinarySync(file) {
+  if (file == wasmBinaryFile && wasmBinary) {
+    return new Uint8Array(wasmBinary);
+  }
+  if (readBinary) {
+    return readBinary(file);
+  }
+  throw "both async and sync fetching of the wasm failed";
+}
+
+function getBinaryPromise(binaryFile) {
+  // If we don't have the binary yet, load it asynchronously using readAsync.
+  if (!wasmBinary) {
+    // Fetch the binary using readAsync
+    return readAsync(binaryFile).then(response => new Uint8Array(/** @type{!ArrayBuffer} */ (response)), // Fall back to getBinarySync if readAsync fails
+    () => getBinarySync(binaryFile));
+  }
+  // Otherwise, getBinarySync should be able to get it synchronously
+  return Promise.resolve().then(() => getBinarySync(binaryFile));
+}
+
+function instantiateArrayBuffer(binaryFile, imports, receiver) {
+  return getBinaryPromise(binaryFile).then(binary => WebAssembly.instantiate(binary, imports)).then(receiver, reason => {
+    err(`failed to asynchronously prepare wasm: ${reason}`);
+    abort(reason);
+  });
+}
+
+function instantiateAsync(binary, binaryFile, imports, callback) {
+  if (!binary && typeof WebAssembly.instantiateStreaming == "function" && !isDataURI(binaryFile) && typeof fetch == "function") {
+    return fetch(binaryFile, {
+      credentials: "same-origin"
+    }).then(response => {
+      // Suppress closure warning here since the upstream definition for
+      // instantiateStreaming only allows Promise<Repsponse> rather than
+      // an actual Response.
+      // TODO(https://github.com/google/closure-compiler/pull/3913): Remove if/when upstream closure is fixed.
+      /** @suppress {checkTypes} */ var result = WebAssembly.instantiateStreaming(response, imports);
+      return result.then(callback, function(reason) {
+        // We expect the most common failure cause to be a bad MIME type for the binary,
+        // in which case falling back to ArrayBuffer instantiation should work.
+        err(`wasm streaming compile failed: ${reason}`);
+        err("falling back to ArrayBuffer instantiation");
+        return instantiateArrayBuffer(binaryFile, imports, callback);
+      });
+    });
+  }
+  return instantiateArrayBuffer(binaryFile, imports, callback);
+}
+
+function getWasmImports() {
+  // prepare imports
+  return {
+    "env": wasmImports,
+    "wasi_snapshot_preview1": wasmImports
+  };
+}
+
+// Create the wasm instance.
+// Receives the wasm imports, returns the exports.
+function createWasm() {
+  var info = getWasmImports();
+  // Load the wasm module and create an instance of using native support in the JS engine.
+  // handle a generated wasm instance, receiving its exports and
+  // performing other necessary setup
+  /** @param {WebAssembly.Module=} module*/ function receiveInstance(instance, module) {
+    wasmExports = instance.exports;
+    wasmMemory = wasmExports["memory"];
+    updateMemoryViews();
+    addOnInit(wasmExports["__wasm_call_ctors"]);
+    removeRunDependency("wasm-instantiate");
+    return wasmExports;
+  }
+  // wait for the pthread pool (if any)
+  addRunDependency("wasm-instantiate");
+  // Prefer streaming instantiation if available.
+  function receiveInstantiationResult(result) {
+    // 'result' is a ResultObject object which has both the module and instance.
+    // receiveInstance() will swap in the exports (to Module.asm) so they can be called
+    // TODO: Due to Closure regression https://github.com/google/closure-compiler/issues/3193, the above line no longer optimizes out down to the following line.
+    // When the regression is fixed, can restore the above PTHREADS-enabled path.
+    receiveInstance(result["instance"]);
+  }
+  // User shell pages can write their own Module.instantiateWasm = function(imports, successCallback) callback
+  // to manually instantiate the Wasm module themselves. This allows pages to
+  // run the instantiation parallel to any other async startup actions they are
+  // performing.
+  // Also pthreads and wasm workers initialize the wasm instance through this
+  // path.
+  if (Module["instantiateWasm"]) {
+    try {
+      return Module["instantiateWasm"](info, receiveInstance);
+    } catch (e) {
+      err(`Module.instantiateWasm callback failed with error: ${e}`);
+      // If instantiation fails, reject the module ready promise.
+      readyPromiseReject(e);
+    }
+  }
+  if (!wasmBinaryFile) wasmBinaryFile = findWasmBinary();
+  // If instantiation fails, reject the module ready promise.
+  instantiateAsync(wasmBinary, wasmBinaryFile, info, receiveInstantiationResult).catch(readyPromiseReject);
+  return {};
+}
+
+// Globals used by JS i64 conversions (see makeSetValue)
+var tempDouble;
+
+var tempI64;
+
+var callRuntimeCallbacks = callbacks => {
+  while (callbacks.length > 0) {
+    // Pass the module as the first argument.
+    callbacks.shift()(Module);
+  }
+};
+
+/**
+     * @param {number} ptr
+     * @param {string} type
+     */ function getValue(ptr, type = "i8") {
+  if (type.endsWith("*")) type = "*";
+  switch (type) {
+   case "i1":
+    return HEAP8[ptr];
+
+   case "i8":
+    return HEAP8[ptr];
+
+   case "i16":
+    return HEAP16[((ptr) >> 1)];
+
+   case "i32":
+    return HEAP32[((ptr) >> 2)];
+
+   case "i64":
+    abort("to do getValue(i64) use WASM_BIGINT");
+
+   case "float":
+    return HEAPF32[((ptr) >> 2)];
+
+   case "double":
+    return HEAPF64[((ptr) >> 3)];
+
+   case "*":
+    return HEAPU32[((ptr) >> 2)];
+
+   default:
+    abort(`invalid type for getValue: ${type}`);
+  }
+}
+
+var noExitRuntime = Module["noExitRuntime"] || true;
+
+var stackRestore = val => __emscripten_stack_restore(val);
+
+var stackSave = () => _emscripten_stack_get_current();
+
+var UTF8Decoder = typeof TextDecoder != "undefined" ? new TextDecoder : undefined;
+
+/**
+     * Given a pointer 'idx' to a null-terminated UTF8-encoded string in the given
+     * array that contains uint8 values, returns a copy of that string as a
+     * Javascript String object.
+     * heapOrArray is either a regular array, or a JavaScript typed array view.
+     * @param {number} idx
+     * @param {number=} maxBytesToRead
+     * @return {string}
+     */ var UTF8ArrayToString = (heapOrArray, idx, maxBytesToRead) => {
+  var endIdx = idx + maxBytesToRead;
+  var endPtr = idx;
+  // TextDecoder needs to know the byte length in advance, it doesn't stop on
+  // null terminator by itself.  Also, use the length info to avoid running tiny
+  // strings through TextDecoder, since .subarray() allocates garbage.
+  // (As a tiny code save trick, compare endPtr against endIdx using a negation,
+  // so that undefined means Infinity)
+  while (heapOrArray[endPtr] && !(endPtr >= endIdx)) ++endPtr;
+  if (endPtr - idx > 16 && heapOrArray.buffer && UTF8Decoder) {
+    return UTF8Decoder.decode(heapOrArray.subarray(idx, endPtr));
+  }
+  var str = "";
+  // If building with TextDecoder, we have already computed the string length
+  // above, so test loop end condition against that
+  while (idx < endPtr) {
+    // For UTF8 byte structure, see:
+    // http://en.wikipedia.org/wiki/UTF-8#Description
+    // https://www.ietf.org/rfc/rfc2279.txt
+    // https://tools.ietf.org/html/rfc3629
+    var u0 = heapOrArray[idx++];
+    if (!(u0 & 128)) {
+      str += String.fromCharCode(u0);
+      continue;
+    }
+    var u1 = heapOrArray[idx++] & 63;
+    if ((u0 & 224) == 192) {
+      str += String.fromCharCode(((u0 & 31) << 6) | u1);
+      continue;
+    }
+    var u2 = heapOrArray[idx++] & 63;
+    if ((u0 & 240) == 224) {
+      u0 = ((u0 & 15) << 12) | (u1 << 6) | u2;
+    } else {
+      u0 = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | (heapOrArray[idx++] & 63);
+    }
+    if (u0 < 65536) {
+      str += String.fromCharCode(u0);
+    } else {
+      var ch = u0 - 65536;
+      str += String.fromCharCode(55296 | (ch >> 10), 56320 | (ch & 1023));
+    }
+  }
+  return str;
+};
+
+/**
+     * Given a pointer 'ptr' to a null-terminated UTF8-encoded string in the
+     * emscripten HEAP, returns a copy of that string as a Javascript String object.
+     *
+     * @param {number} ptr
+     * @param {number=} maxBytesToRead - An optional length that specifies the
+     *   maximum number of bytes to read. You can omit this parameter to scan the
+     *   string until the first 0 byte. If maxBytesToRead is passed, and the string
+     *   at [ptr, ptr+maxBytesToReadr[ contains a null byte in the middle, then the
+     *   string will cut short at that byte index (i.e. maxBytesToRead will not
+     *   produce a string of exact length [ptr, ptr+maxBytesToRead[) N.B. mixing
+     *   frequent uses of UTF8ToString() with and without maxBytesToRead may throw
+     *   JS JIT optimizations off, so it is worth to consider consistently using one
+     * @return {string}
+     */ var UTF8ToString = (ptr, maxBytesToRead) => ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : "";
+
+var ___assert_fail = (condition, filename, line, func) => {
+  abort(`Assertion failed: ${UTF8ToString(condition)}, at: ` + [ filename ? UTF8ToString(filename) : "unknown filename", line, func ? UTF8ToString(func) : "unknown function" ]);
+};
+
+class ExceptionInfo {
+  // excPtr - Thrown object pointer to wrap. Metadata pointer is calculated from it.
+  constructor(excPtr) {
+    this.excPtr = excPtr;
+    this.ptr = excPtr - 24;
+  }
+  set_type(type) {
+    HEAPU32[(((this.ptr) + (4)) >> 2)] = type;
+  }
+  get_type() {
+    return HEAPU32[(((this.ptr) + (4)) >> 2)];
+  }
+  set_destructor(destructor) {
+    HEAPU32[(((this.ptr) + (8)) >> 2)] = destructor;
+  }
+  get_destructor() {
+    return HEAPU32[(((this.ptr) + (8)) >> 2)];
+  }
+  set_caught(caught) {
+    caught = caught ? 1 : 0;
+    HEAP8[(this.ptr) + (12)] = caught;
+  }
+  get_caught() {
+    return HEAP8[(this.ptr) + (12)] != 0;
+  }
+  set_rethrown(rethrown) {
+    rethrown = rethrown ? 1 : 0;
+    HEAP8[(this.ptr) + (13)] = rethrown;
+  }
+  get_rethrown() {
+    return HEAP8[(this.ptr) + (13)] != 0;
+  }
+  // Initialize native structure fields. Should be called once after allocated.
+  init(type, destructor) {
+    this.set_adjusted_ptr(0);
+    this.set_type(type);
+    this.set_destructor(destructor);
+  }
+  set_adjusted_ptr(adjustedPtr) {
+    HEAPU32[(((this.ptr) + (16)) >> 2)] = adjustedPtr;
+  }
+  get_adjusted_ptr() {
+    return HEAPU32[(((this.ptr) + (16)) >> 2)];
+  }
+}
+
+var exceptionLast = 0;
+
+var uncaughtExceptionCount = 0;
+
+var ___cxa_throw = (ptr, type, destructor) => {
+  var info = new ExceptionInfo(ptr);
+  // Initialize ExceptionInfo content after it was allocated in __cxa_allocate_exception.
+  info.init(type, destructor);
+  exceptionLast = ptr;
+  uncaughtExceptionCount++;
+  throw exceptionLast;
+};
+
+/** @suppress {duplicate } */ function syscallGetVarargI() {
+  // the `+` prepended here is necessary to convince the JSCompiler that varargs is indeed a number.
+  var ret = HEAP32[((+SYSCALLS.varargs) >> 2)];
+  SYSCALLS.varargs += 4;
+  return ret;
+}
+
+var syscallGetVarargP = syscallGetVarargI;
+
+var PATH = {
+  isAbs: path => path.charAt(0) === "/",
+  splitPath: filename => {
+    var splitPathRe = /^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/;
+    return splitPathRe.exec(filename).slice(1);
+  },
+  normalizeArray: (parts, allowAboveRoot) => {
+    // if the path tries to go above the root, `up` ends up > 0
+    var up = 0;
+    for (var i = parts.length - 1; i >= 0; i--) {
+      var last = parts[i];
+      if (last === ".") {
+        parts.splice(i, 1);
+      } else if (last === "..") {
+        parts.splice(i, 1);
+        up++;
+      } else if (up) {
+        parts.splice(i, 1);
+        up--;
+      }
+    }
+    // if the path is allowed to go above the root, restore leading ..s
+    if (allowAboveRoot) {
+      for (;up; up--) {
+        parts.unshift("..");
+      }
+    }
+    return parts;
+  },
+  normalize: path => {
+    var isAbsolute = PATH.isAbs(path), trailingSlash = path.substr(-1) === "/";
+    // Normalize the path
+    path = PATH.normalizeArray(path.split("/").filter(p => !!p), !isAbsolute).join("/");
+    if (!path && !isAbsolute) {
+      path = ".";
+    }
+    if (path && trailingSlash) {
+      path += "/";
+    }
+    return (isAbsolute ? "/" : "") + path;
+  },
+  dirname: path => {
+    var result = PATH.splitPath(path), root = result[0], dir = result[1];
+    if (!root && !dir) {
+      // No dirname whatsoever
+      return ".";
+    }
+    if (dir) {
+      // It has a dirname, strip trailing slash
+      dir = dir.substr(0, dir.length - 1);
+    }
+    return root + dir;
+  },
+  basename: path => {
+    // EMSCRIPTEN return '/'' for '/', not an empty string
+    if (path === "/") return "/";
+    path = PATH.normalize(path);
+    path = path.replace(/\/$/, "");
+    var lastSlash = path.lastIndexOf("/");
+    if (lastSlash === -1) return path;
+    return path.substr(lastSlash + 1);
+  },
+  join: (...paths) => PATH.normalize(paths.join("/")),
+  join2: (l, r) => PATH.normalize(l + "/" + r)
+};
+
+var initRandomFill = () => {
+  if (typeof crypto == "object" && typeof crypto["getRandomValues"] == "function") {
+    // for modern web browsers
+    return view => crypto.getRandomValues(view);
+  } else // we couldn't find a proper implementation, as Math.random() is not suitable for /dev/random, see emscripten-core/emscripten/pull/7096
+  abort("initRandomDevice");
+};
+
+var randomFill = view => (randomFill = initRandomFill())(view);
+
+var PATH_FS = {
+  resolve: (...args) => {
+    var resolvedPath = "", resolvedAbsolute = false;
+    for (var i = args.length - 1; i >= -1 && !resolvedAbsolute; i--) {
+      var path = (i >= 0) ? args[i] : FS.cwd();
+      // Skip empty and invalid entries
+      if (typeof path != "string") {
+        throw new TypeError("Arguments to path.resolve must be strings");
+      } else if (!path) {
+        return "";
+      }
+      // an invalid portion invalidates the whole thing
+      resolvedPath = path + "/" + resolvedPath;
+      resolvedAbsolute = PATH.isAbs(path);
+    }
+    // At this point the path should be resolved to a full absolute path, but
+    // handle relative paths to be safe (might happen when process.cwd() fails)
+    resolvedPath = PATH.normalizeArray(resolvedPath.split("/").filter(p => !!p), !resolvedAbsolute).join("/");
+    return ((resolvedAbsolute ? "/" : "") + resolvedPath) || ".";
+  },
+  relative: (from, to) => {
+    from = PATH_FS.resolve(from).substr(1);
+    to = PATH_FS.resolve(to).substr(1);
+    function trim(arr) {
+      var start = 0;
+      for (;start < arr.length; start++) {
+        if (arr[start] !== "") break;
+      }
+      var end = arr.length - 1;
+      for (;end >= 0; end--) {
+        if (arr[end] !== "") break;
+      }
+      if (start > end) return [];
+      return arr.slice(start, end - start + 1);
+    }
+    var fromParts = trim(from.split("/"));
+    var toParts = trim(to.split("/"));
+    var length = Math.min(fromParts.length, toParts.length);
+    var samePartsLength = length;
+    for (var i = 0; i < length; i++) {
+      if (fromParts[i] !== toParts[i]) {
+        samePartsLength = i;
+        break;
+      }
+    }
+    var outputParts = [];
+    for (var i = samePartsLength; i < fromParts.length; i++) {
+      outputParts.push("..");
+    }
+    outputParts = outputParts.concat(toParts.slice(samePartsLength));
+    return outputParts.join("/");
+  }
+};
+
+var FS_stdin_getChar_buffer = [];
+
+var lengthBytesUTF8 = str => {
+  var len = 0;
+  for (var i = 0; i < str.length; ++i) {
+    // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code
+    // unit, not a Unicode code point of the character! So decode
+    // UTF16->UTF32->UTF8.
+    // See http://unicode.org/faq/utf_bom.html#utf16-3
+    var c = str.charCodeAt(i);
+    // possibly a lead surrogate
+    if (c <= 127) {
+      len++;
+    } else if (c <= 2047) {
+      len += 2;
+    } else if (c >= 55296 && c <= 57343) {
+      len += 4;
+      ++i;
+    } else {
+      len += 3;
+    }
+  }
+  return len;
+};
+
+var stringToUTF8Array = (str, heap, outIdx, maxBytesToWrite) => {
+  // Parameter maxBytesToWrite is not optional. Negative values, 0, null,
+  // undefined and false each don't write out any bytes.
+  if (!(maxBytesToWrite > 0)) return 0;
+  var startIdx = outIdx;
+  var endIdx = outIdx + maxBytesToWrite - 1;
+  // -1 for string null terminator.
+  for (var i = 0; i < str.length; ++i) {
+    // Gotcha: charCodeAt returns a 16-bit word that is a UTF-16 encoded code
+    // unit, not a Unicode code point of the character! So decode
+    // UTF16->UTF32->UTF8.
+    // See http://unicode.org/faq/utf_bom.html#utf16-3
+    // For UTF8 byte structure, see http://en.wikipedia.org/wiki/UTF-8#Description
+    // and https://www.ietf.org/rfc/rfc2279.txt
+    // and https://tools.ietf.org/html/rfc3629
+    var u = str.charCodeAt(i);
+    // possibly a lead surrogate
+    if (u >= 55296 && u <= 57343) {
+      var u1 = str.charCodeAt(++i);
+      u = 65536 + ((u & 1023) << 10) | (u1 & 1023);
+    }
+    if (u <= 127) {
+      if (outIdx >= endIdx) break;
+      heap[outIdx++] = u;
+    } else if (u <= 2047) {
+      if (outIdx + 1 >= endIdx) break;
+      heap[outIdx++] = 192 | (u >> 6);
+      heap[outIdx++] = 128 | (u & 63);
+    } else if (u <= 65535) {
+      if (outIdx + 2 >= endIdx) break;
+      heap[outIdx++] = 224 | (u >> 12);
+      heap[outIdx++] = 128 | ((u >> 6) & 63);
+      heap[outIdx++] = 128 | (u & 63);
+    } else {
+      if (outIdx + 3 >= endIdx) break;
+      heap[outIdx++] = 240 | (u >> 18);
+      heap[outIdx++] = 128 | ((u >> 12) & 63);
+      heap[outIdx++] = 128 | ((u >> 6) & 63);
+      heap[outIdx++] = 128 | (u & 63);
+    }
+  }
+  // Null-terminate the pointer to the buffer.
+  heap[outIdx] = 0;
+  return outIdx - startIdx;
+};
+
+/** @type {function(string, boolean=, number=)} */ function intArrayFromString(stringy, dontAddNull, length) {
+  var len = length > 0 ? length : lengthBytesUTF8(stringy) + 1;
+  var u8array = new Array(len);
+  var numBytesWritten = stringToUTF8Array(stringy, u8array, 0, u8array.length);
+  if (dontAddNull) u8array.length = numBytesWritten;
+  return u8array;
+}
+
+var FS_stdin_getChar = () => {
+  if (!FS_stdin_getChar_buffer.length) {
+    var result = null;
+    {}
+    if (!result) {
+      return null;
+    }
+    FS_stdin_getChar_buffer = intArrayFromString(result, true);
+  }
+  return FS_stdin_getChar_buffer.shift();
+};
+
+var TTY = {
+  ttys: [],
+  init() {},
+  // https://github.com/emscripten-core/emscripten/pull/1555
+  // if (ENVIRONMENT_IS_NODE) {
+  //   // currently, FS.init does not distinguish if process.stdin is a file or TTY
+  //   // device, it always assumes it's a TTY device. because of this, we're forcing
+  //   // process.stdin to UTF8 encoding to at least make stdin reading compatible
+  //   // with text files until FS.init can be refactored.
+  //   process.stdin.setEncoding('utf8');
+  // }
+  shutdown() {},
+  // https://github.com/emscripten-core/emscripten/pull/1555
+  // if (ENVIRONMENT_IS_NODE) {
+  //   // inolen: any idea as to why node -e 'process.stdin.read()' wouldn't exit immediately (with process.stdin being a tty)?
+  //   // isaacs: because now it's reading from the stream, you've expressed interest in it, so that read() kicks off a _read() which creates a ReadReq operation
+  //   // inolen: I thought read() in that case was a synchronous operation that just grabbed some amount of buffered data if it exists?
+  //   // isaacs: it is. but it also triggers a _read() call, which calls readStart() on the handle
+  //   // isaacs: do process.stdin.pause() and i'd think it'd probably close the pending call
+  //   process.stdin.pause();
+  // }
+  register(dev, ops) {
+    TTY.ttys[dev] = {
+      input: [],
+      output: [],
+      ops
+    };
+    FS.registerDevice(dev, TTY.stream_ops);
+  },
+  stream_ops: {
+    open(stream) {
+      var tty = TTY.ttys[stream.node.rdev];
+      if (!tty) {
+        throw new FS.ErrnoError(43);
+      }
+      stream.tty = tty;
+      stream.seekable = false;
+    },
+    close(stream) {
+      // flush any pending line data
+      stream.tty.ops.fsync(stream.tty);
+    },
+    fsync(stream) {
+      stream.tty.ops.fsync(stream.tty);
+    },
+    read(stream, buffer, offset, length, pos) {
+      /* ignored */ if (!stream.tty || !stream.tty.ops.get_char) {
+        throw new FS.ErrnoError(60);
+      }
+      var bytesRead = 0;
+      for (var i = 0; i < length; i++) {
+        var result;
+        try {
+          result = stream.tty.ops.get_char(stream.tty);
+        } catch (e) {
+          throw new FS.ErrnoError(29);
+        }
+        if (result === undefined && bytesRead === 0) {
+          throw new FS.ErrnoError(6);
+        }
+        if (result === null || result === undefined) break;
+        bytesRead++;
+        buffer[offset + i] = result;
+      }
+      if (bytesRead) {
+        stream.node.timestamp = Date.now();
+      }
+      return bytesRead;
+    },
+    write(stream, buffer, offset, length, pos) {
+      if (!stream.tty || !stream.tty.ops.put_char) {
+        throw new FS.ErrnoError(60);
+      }
+      try {
+        for (var i = 0; i < length; i++) {
+          stream.tty.ops.put_char(stream.tty, buffer[offset + i]);
+        }
+      } catch (e) {
+        throw new FS.ErrnoError(29);
+      }
+      if (length) {
+        stream.node.timestamp = Date.now();
+      }
+      return i;
+    }
+  },
+  default_tty_ops: {
+    get_char(tty) {
+      return FS_stdin_getChar();
+    },
+    put_char(tty, val) {
+      if (val === null || val === 10) {
+        out(UTF8ArrayToString(tty.output, 0));
+        tty.output = [];
+      } else {
+        if (val != 0) tty.output.push(val);
+      }
+    },
+    // val == 0 would cut text output off in the middle.
+    fsync(tty) {
+      if (tty.output && tty.output.length > 0) {
+        out(UTF8ArrayToString(tty.output, 0));
+        tty.output = [];
+      }
+    },
+    ioctl_tcgets(tty) {
+      // typical setting
+      return {
+        c_iflag: 25856,
+        c_oflag: 5,
+        c_cflag: 191,
+        c_lflag: 35387,
+        c_cc: [ 3, 28, 127, 21, 4, 0, 1, 0, 17, 19, 26, 0, 18, 15, 23, 22, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 ]
+      };
+    },
+    ioctl_tcsets(tty, optional_actions, data) {
+      // currently just ignore
+      return 0;
+    },
+    ioctl_tiocgwinsz(tty) {
+      return [ 24, 80 ];
+    }
+  },
+  default_tty1_ops: {
+    put_char(tty, val) {
+      if (val === null || val === 10) {
+        err(UTF8ArrayToString(tty.output, 0));
+        tty.output = [];
+      } else {
+        if (val != 0) tty.output.push(val);
+      }
+    },
+    fsync(tty) {
+      if (tty.output && tty.output.length > 0) {
+        err(UTF8ArrayToString(tty.output, 0));
+        tty.output = [];
+      }
+    }
+  }
+};
+
+var alignMemory = (size, alignment) => Math.ceil(size / alignment) * alignment;
+
+var mmapAlloc = size => {
+  abort();
+};
+
+var MEMFS = {
+  ops_table: null,
+  mount(mount) {
+    return MEMFS.createNode(null, "/", 16384 | 511, /* 0777 */ 0);
+  },
+  createNode(parent, name, mode, dev) {
+    if (FS.isBlkdev(mode) || FS.isFIFO(mode)) {
+      // no supported
+      throw new FS.ErrnoError(63);
+    }
+    MEMFS.ops_table ||= {
+      dir: {
+        node: {
+          getattr: MEMFS.node_ops.getattr,
+          setattr: MEMFS.node_ops.setattr,
+          lookup: MEMFS.node_ops.lookup,
+          mknod: MEMFS.node_ops.mknod,
+          rename: MEMFS.node_ops.rename,
+          unlink: MEMFS.node_ops.unlink,
+          rmdir: MEMFS.node_ops.rmdir,
+          readdir: MEMFS.node_ops.readdir,
+          symlink: MEMFS.node_ops.symlink
+        },
+        stream: {
+          llseek: MEMFS.stream_ops.llseek
+        }
+      },
+      file: {
+        node: {
+          getattr: MEMFS.node_ops.getattr,
+          setattr: MEMFS.node_ops.setattr
+        },
+        stream: {
+          llseek: MEMFS.stream_ops.llseek,
+          read: MEMFS.stream_ops.read,
+          write: MEMFS.stream_ops.write,
+          allocate: MEMFS.stream_ops.allocate,
+          mmap: MEMFS.stream_ops.mmap,
+          msync: MEMFS.stream_ops.msync
+        }
+      },
+      link: {
+        node: {
+          getattr: MEMFS.node_ops.getattr,
+          setattr: MEMFS.node_ops.setattr,
+          readlink: MEMFS.node_ops.readlink
+        },
+        stream: {}
+      },
+      chrdev: {
+        node: {
+          getattr: MEMFS.node_ops.getattr,
+          setattr: MEMFS.node_ops.setattr
+        },
+        stream: FS.chrdev_stream_ops
+      }
+    };
+    var node = FS.createNode(parent, name, mode, dev);
+    if (FS.isDir(node.mode)) {
+      node.node_ops = MEMFS.ops_table.dir.node;
+      node.stream_ops = MEMFS.ops_table.dir.stream;
+      node.contents = {};
+    } else if (FS.isFile(node.mode)) {
+      node.node_ops = MEMFS.ops_table.file.node;
+      node.stream_ops = MEMFS.ops_table.file.stream;
+      node.usedBytes = 0;
+      // The actual number of bytes used in the typed array, as opposed to contents.length which gives the whole capacity.
+      // When the byte data of the file is populated, this will point to either a typed array, or a normal JS array. Typed arrays are preferred
+      // for performance, and used by default. However, typed arrays are not resizable like normal JS arrays are, so there is a small disk size
+      // penalty involved for appending file writes that continuously grow a file similar to std::vector capacity vs used -scheme.
+      node.contents = null;
+    } else if (FS.isLink(node.mode)) {
+      node.node_ops = MEMFS.ops_table.link.node;
+      node.stream_ops = MEMFS.ops_table.link.stream;
+    } else if (FS.isChrdev(node.mode)) {
+      node.node_ops = MEMFS.ops_table.chrdev.node;
+      node.stream_ops = MEMFS.ops_table.chrdev.stream;
+    }
+    node.timestamp = Date.now();
+    // add the new node to the parent
+    if (parent) {
+      parent.contents[name] = node;
+      parent.timestamp = node.timestamp;
+    }
+    return node;
+  },
+  getFileDataAsTypedArray(node) {
+    if (!node.contents) return new Uint8Array(0);
+    if (node.contents.subarray) return node.contents.subarray(0, node.usedBytes);
+    // Make sure to not return excess unused bytes.
+    return new Uint8Array(node.contents);
+  },
+  expandFileStorage(node, newCapacity) {
+    var prevCapacity = node.contents ? node.contents.length : 0;
+    if (prevCapacity >= newCapacity) return;
+    // No need to expand, the storage was already large enough.
+    // Don't expand strictly to the given requested limit if it's only a very small increase, but instead geometrically grow capacity.
+    // For small filesizes (<1MB), perform size*2 geometric increase, but for large sizes, do a much more conservative size*1.125 increase to
+    // avoid overshooting the allocation cap by a very large margin.
+    var CAPACITY_DOUBLING_MAX = 1024 * 1024;
+    newCapacity = Math.max(newCapacity, (prevCapacity * (prevCapacity < CAPACITY_DOUBLING_MAX ? 2 : 1.125)) >>> 0);
+    if (prevCapacity != 0) newCapacity = Math.max(newCapacity, 256);
+    // At minimum allocate 256b for each file when expanding.
+    var oldContents = node.contents;
+    node.contents = new Uint8Array(newCapacity);
+    // Allocate new storage.
+    if (node.usedBytes > 0) node.contents.set(oldContents.subarray(0, node.usedBytes), 0);
+  },
+  // Copy old data over to the new storage.
+  resizeFileStorage(node, newSize) {
+    if (node.usedBytes == newSize) return;
+    if (newSize == 0) {
+      node.contents = null;
+      // Fully decommit when requesting a resize to zero.
+      node.usedBytes = 0;
+    } else {
+      var oldContents = node.contents;
+      node.contents = new Uint8Array(newSize);
+      // Allocate new storage.
+      if (oldContents) {
+        node.contents.set(oldContents.subarray(0, Math.min(newSize, node.usedBytes)));
+      }
+      // Copy old data over to the new storage.
+      node.usedBytes = newSize;
+    }
+  },
+  node_ops: {
+    getattr(node) {
+      var attr = {};
+      // device numbers reuse inode numbers.
+      attr.dev = FS.isChrdev(node.mode) ? node.id : 1;
+      attr.ino = node.id;
+      attr.mode = node.mode;
+      attr.nlink = 1;
+      attr.uid = 0;
+      attr.gid = 0;
+      attr.rdev = node.rdev;
+      if (FS.isDir(node.mode)) {
+        attr.size = 4096;
+      } else if (FS.isFile(node.mode)) {
+        attr.size = node.usedBytes;
+      } else if (FS.isLink(node.mode)) {
+        attr.size = node.link.length;
+      } else {
+        attr.size = 0;
+      }
+      attr.atime = new Date(node.timestamp);
+      attr.mtime = new Date(node.timestamp);
+      attr.ctime = new Date(node.timestamp);
+      // NOTE: In our implementation, st_blocks = Math.ceil(st_size/st_blksize),
+      //       but this is not required by the standard.
+      attr.blksize = 4096;
+      attr.blocks = Math.ceil(attr.size / attr.blksize);
+      return attr;
+    },
+    setattr(node, attr) {
+      if (attr.mode !== undefined) {
+        node.mode = attr.mode;
+      }
+      if (attr.timestamp !== undefined) {
+        node.timestamp = attr.timestamp;
+      }
+      if (attr.size !== undefined) {
+        MEMFS.resizeFileStorage(node, attr.size);
+      }
+    },
+    lookup(parent, name) {
+      throw FS.genericErrors[44];
+    },
+    mknod(parent, name, mode, dev) {
+      return MEMFS.createNode(parent, name, mode, dev);
+    },
+    rename(old_node, new_dir, new_name) {
+      // if we're overwriting a directory at new_name, make sure it's empty.
+      if (FS.isDir(old_node.mode)) {
+        var new_node;
+        try {
+          new_node = FS.lookupNode(new_dir, new_name);
+        } catch (e) {}
+        if (new_node) {
+          for (var i in new_node.contents) {
+            throw new FS.ErrnoError(55);
+          }
+        }
+      }
+      // do the internal rewiring
+      delete old_node.parent.contents[old_node.name];
+      old_node.parent.timestamp = Date.now();
+      old_node.name = new_name;
+      new_dir.contents[new_name] = old_node;
+      new_dir.timestamp = old_node.parent.timestamp;
+    },
+    unlink(parent, name) {
+      delete parent.contents[name];
+      parent.timestamp = Date.now();
+    },
+    rmdir(parent, name) {
+      var node = FS.lookupNode(parent, name);
+      for (var i in node.contents) {
+        throw new FS.ErrnoError(55);
+      }
+      delete parent.contents[name];
+      parent.timestamp = Date.now();
+    },
+    readdir(node) {
+      var entries = [ ".", ".." ];
+      for (var key of Object.keys(node.contents)) {
+        entries.push(key);
+      }
+      return entries;
+    },
+    symlink(parent, newname, oldpath) {
+      var node = MEMFS.createNode(parent, newname, 511 | /* 0777 */ 40960, 0);
+      node.link = oldpath;
+      return node;
+    },
+    readlink(node) {
+      if (!FS.isLink(node.mode)) {
+        throw new FS.ErrnoError(28);
+      }
+      return node.link;
+    }
+  },
+  stream_ops: {
+    read(stream, buffer, offset, length, position) {
+      var contents = stream.node.contents;
+      if (position >= stream.node.usedBytes) return 0;
+      var size = Math.min(stream.node.usedBytes - position, length);
+      if (size > 8 && contents.subarray) {
+        // non-trivial, and typed array
+        buffer.set(contents.subarray(position, position + size), offset);
+      } else {
+        for (var i = 0; i < size; i++) buffer[offset + i] = contents[position + i];
+      }
+      return size;
+    },
+    write(stream, buffer, offset, length, position, canOwn) {
+      // If the buffer is located in main memory (HEAP), and if
+      // memory can grow, we can't hold on to references of the
+      // memory buffer, as they may get invalidated. That means we
+      // need to do copy its contents.
+      if (buffer.buffer === HEAP8.buffer) {
+        canOwn = false;
+      }
+      if (!length) return 0;
+      var node = stream.node;
+      node.timestamp = Date.now();
+      if (buffer.subarray && (!node.contents || node.contents.subarray)) {
+        // This write is from a typed array to a typed array?
+        if (canOwn) {
+          node.contents = buffer.subarray(offset, offset + length);
+          node.usedBytes = length;
+          return length;
+        } else if (node.usedBytes === 0 && position === 0) {
+          // If this is a simple first write to an empty file, do a fast set since we don't need to care about old data.
+          node.contents = buffer.slice(offset, offset + length);
+          node.usedBytes = length;
+          return length;
+        } else if (position + length <= node.usedBytes) {
+          // Writing to an already allocated and used subrange of the file?
+          node.contents.set(buffer.subarray(offset, offset + length), position);
+          return length;
+        }
+      }
+      // Appending to an existing file and we need to reallocate, or source data did not come as a typed array.
+      MEMFS.expandFileStorage(node, position + length);
+      if (node.contents.subarray && buffer.subarray) {
+        // Use typed array write which is available.
+        node.contents.set(buffer.subarray(offset, offset + length), position);
+      } else {
+        for (var i = 0; i < length; i++) {
+          node.contents[position + i] = buffer[offset + i];
+        }
+      }
+      node.usedBytes = Math.max(node.usedBytes, position + length);
+      return length;
+    },
+    llseek(stream, offset, whence) {
+      var position = offset;
+      if (whence === 1) {
+        position += stream.position;
+      } else if (whence === 2) {
+        if (FS.isFile(stream.node.mode)) {
+          position += stream.node.usedBytes;
+        }
+      }
+      if (position < 0) {
+        throw new FS.ErrnoError(28);
+      }
+      return position;
+    },
+    allocate(stream, offset, length) {
+      MEMFS.expandFileStorage(stream.node, offset + length);
+      stream.node.usedBytes = Math.max(stream.node.usedBytes, offset + length);
+    },
+    mmap(stream, length, position, prot, flags) {
+      if (!FS.isFile(stream.node.mode)) {
+        throw new FS.ErrnoError(43);
+      }
+      var ptr;
+      var allocated;
+      var contents = stream.node.contents;
+      // Only make a new copy when MAP_PRIVATE is specified.
+      if (!(flags & 2) && contents && contents.buffer === HEAP8.buffer) {
+        // We can't emulate MAP_SHARED when the file is not backed by the
+        // buffer we're mapping to (e.g. the HEAP buffer).
+        allocated = false;
+        ptr = contents.byteOffset;
+      } else {
+        allocated = true;
+        ptr = mmapAlloc(length);
+        if (!ptr) {
+          throw new FS.ErrnoError(48);
+        }
+        if (contents) {
+          // Try to avoid unnecessary slices.
+          if (position > 0 || position + length < contents.length) {
+            if (contents.subarray) {
+              contents = contents.subarray(position, position + length);
+            } else {
+              contents = Array.prototype.slice.call(contents, position, position + length);
+            }
+          }
+          HEAP8.set(contents, ptr);
+        }
+      }
+      return {
+        ptr,
+        allocated
+      };
+    },
+    msync(stream, buffer, offset, length, mmapFlags) {
+      MEMFS.stream_ops.write(stream, buffer, 0, length, offset, false);
+      // should we check if bytesWritten and length are the same?
+      return 0;
+    }
+  }
+};
+
+/** @param {boolean=} noRunDep */ var asyncLoad = (url, onload, onerror, noRunDep) => {
+  var dep = !noRunDep ? getUniqueRunDependency(`al ${url}`) : "";
+  readAsync(url).then(arrayBuffer => {
+    onload(new Uint8Array(arrayBuffer));
+    if (dep) removeRunDependency(dep);
+  }, err => {
+    if (onerror) {
+      onerror();
+    } else {
+      throw `Loading data file "${url}" failed.`;
+    }
+  });
+  if (dep) addRunDependency(dep);
+};
+
+var FS_createDataFile = (parent, name, fileData, canRead, canWrite, canOwn) => {
+  FS.createDataFile(parent, name, fileData, canRead, canWrite, canOwn);
+};
+
+var preloadPlugins = Module["preloadPlugins"] || [];
+
+var FS_handledByPreloadPlugin = (byteArray, fullname, finish, onerror) => {
+  // Ensure plugins are ready.
+  if (typeof Browser != "undefined") Browser.init();
+  var handled = false;
+  preloadPlugins.forEach(plugin => {
+    if (handled) return;
+    if (plugin["canHandle"](fullname)) {
+      plugin["handle"](byteArray, fullname, finish, onerror);
+      handled = true;
+    }
+  });
+  return handled;
+};
+
+var FS_createPreloadedFile = (parent, name, url, canRead, canWrite, onload, onerror, dontCreateFile, canOwn, preFinish) => {
+  // TODO we should allow people to just pass in a complete filename instead
+  // of parent and name being that we just join them anyways
+  var fullname = name ? PATH_FS.resolve(PATH.join2(parent, name)) : parent;
+  var dep = getUniqueRunDependency(`cp ${fullname}`);
+  // might have several active requests for the same fullname
+  function processData(byteArray) {
+    function finish(byteArray) {
+      preFinish?.();
+      if (!dontCreateFile) {
+        FS_createDataFile(parent, name, byteArray, canRead, canWrite, canOwn);
+      }
+      onload?.();
+      removeRunDependency(dep);
+    }
+    if (FS_handledByPreloadPlugin(byteArray, fullname, finish, () => {
+      onerror?.();
+      removeRunDependency(dep);
+    })) {
+      return;
+    }
+    finish(byteArray);
+  }
+  addRunDependency(dep);
+  if (typeof url == "string") {
+    asyncLoad(url, processData, onerror);
+  } else {
+    processData(url);
+  }
+};
+
+var FS_modeStringToFlags = str => {
+  var flagModes = {
+    "r": 0,
+    "r+": 2,
+    "w": 512 | 64 | 1,
+    "w+": 512 | 64 | 2,
+    "a": 1024 | 64 | 1,
+    "a+": 1024 | 64 | 2
+  };
+  var flags = flagModes[str];
+  if (typeof flags == "undefined") {
+    throw new Error(`Unknown file open mode: ${str}`);
+  }
+  return flags;
+};
+
+var FS_getMode = (canRead, canWrite) => {
+  var mode = 0;
+  if (canRead) mode |= 292 | 73;
+  if (canWrite) mode |= 146;
+  return mode;
+};
+
+var WORKERFS = {
+  DIR_MODE: 16895,
+  FILE_MODE: 33279,
+  reader: null,
+  mount(mount) {
+    assert(ENVIRONMENT_IS_WORKER);
+    if (!WORKERFS.reader) WORKERFS.reader = new FileReaderSync;
+    var root = WORKERFS.createNode(null, "/", WORKERFS.DIR_MODE, 0);
+    var createdParents = {};
+    function ensureParent(path) {
+      // return the parent node, creating subdirs as necessary
+      var parts = path.split("/");
+      var parent = root;
+      for (var i = 0; i < parts.length - 1; i++) {
+        var curr = parts.slice(0, i + 1).join("/");
+        // Issue 4254: Using curr as a node name will prevent the node
+        // from being found in FS.nameTable when FS.open is called on
+        // a path which holds a child of this node,
+        // given that all FS functions assume node names
+        // are just their corresponding parts within their given path,
+        // rather than incremental aggregates which include their parent's
+        // directories.
+        createdParents[curr] ||= WORKERFS.createNode(parent, parts[i], WORKERFS.DIR_MODE, 0);
+        parent = createdParents[curr];
+      }
+      return parent;
+    }
+    function base(path) {
+      var parts = path.split("/");
+      return parts[parts.length - 1];
+    }
+    // We also accept FileList here, by using Array.prototype
+    Array.prototype.forEach.call(mount.opts["files"] || [], function(file) {
+      WORKERFS.createNode(ensureParent(file.name), base(file.name), WORKERFS.FILE_MODE, 0, file, file.lastModifiedDate);
+    });
+    (mount.opts["blobs"] || []).forEach(obj => {
+      WORKERFS.createNode(ensureParent(obj["name"]), base(obj["name"]), WORKERFS.FILE_MODE, 0, obj["data"]);
+    });
+    (mount.opts["packages"] || []).forEach(pack => {
+      pack["metadata"].files.forEach(file => {
+        var name = file.filename.substr(1);
+        // remove initial slash
+        WORKERFS.createNode(ensureParent(name), base(name), WORKERFS.FILE_MODE, 0, pack["blob"].slice(file.start, file.end));
+      });
+    });
+    return root;
+  },
+  createNode(parent, name, mode, dev, contents, mtime) {
+    var node = FS.createNode(parent, name, mode);
+    node.mode = mode;
+    node.node_ops = WORKERFS.node_ops;
+    node.stream_ops = WORKERFS.stream_ops;
+    node.timestamp = (mtime || new Date).getTime();
+    assert(WORKERFS.FILE_MODE !== WORKERFS.DIR_MODE);
+    if (mode === WORKERFS.FILE_MODE) {
+      node.size = contents.size;
+      node.contents = contents;
+    } else {
+      node.size = 4096;
+      node.contents = {};
+    }
+    if (parent) {
+      parent.contents[name] = node;
+    }
+    return node;
+  },
+  node_ops: {
+    getattr(node) {
+      return {
+        dev: 1,
+        ino: node.id,
+        mode: node.mode,
+        nlink: 1,
+        uid: 0,
+        gid: 0,
+        rdev: 0,
+        size: node.size,
+        atime: new Date(node.timestamp),
+        mtime: new Date(node.timestamp),
+        ctime: new Date(node.timestamp),
+        blksize: 4096,
+        blocks: Math.ceil(node.size / 4096)
+      };
+    },
+    setattr(node, attr) {
+      if (attr.mode !== undefined) {
+        node.mode = attr.mode;
+      }
+      if (attr.timestamp !== undefined) {
+        node.timestamp = attr.timestamp;
+      }
+    },
+    lookup(parent, name) {
+      throw new FS.ErrnoError(44);
+    },
+    mknod(parent, name, mode, dev) {
+      throw new FS.ErrnoError(63);
+    },
+    rename(oldNode, newDir, newName) {
+      throw new FS.ErrnoError(63);
+    },
+    unlink(parent, name) {
+      throw new FS.ErrnoError(63);
+    },
+    rmdir(parent, name) {
+      throw new FS.ErrnoError(63);
+    },
+    readdir(node) {
+      var entries = [ ".", ".." ];
+      for (var key of Object.keys(node.contents)) {
+        entries.push(key);
+      }
+      return entries;
+    },
+    symlink(parent, newName, oldPath) {
+      throw new FS.ErrnoError(63);
+    }
+  },
+  stream_ops: {
+    read(stream, buffer, offset, length, position) {
+      if (position >= stream.node.size) return 0;
+      var chunk = stream.node.contents.slice(position, position + length);
+      var ab = WORKERFS.reader.readAsArrayBuffer(chunk);
+      buffer.set(new Uint8Array(ab), offset);
+      return chunk.size;
+    },
+    write(stream, buffer, offset, length, position) {
+      throw new FS.ErrnoError(29);
+    },
+    llseek(stream, offset, whence) {
+      var position = offset;
+      if (whence === 1) {
+        position += stream.position;
+      } else if (whence === 2) {
+        if (FS.isFile(stream.node.mode)) {
+          position += stream.node.size;
+        }
+      }
+      if (position < 0) {
+        throw new FS.ErrnoError(28);
+      }
+      return position;
+    }
+  }
+};
+
+var FS = {
+  root: null,
+  mounts: [],
+  devices: {},
+  streams: [],
+  nextInode: 1,
+  nameTable: null,
+  currentPath: "/",
+  initialized: false,
+  ignorePermissions: true,
+  ErrnoError: class {
+    // We set the `name` property to be able to identify `FS.ErrnoError`
+    // - the `name` is a standard ECMA-262 property of error objects. Kind of good to have it anyway.
+    // - when using PROXYFS, an error can come from an underlying FS
+    // as different FS objects have their own FS.ErrnoError each,
+    // the test `err instanceof FS.ErrnoError` won't detect an error coming from another filesystem, causing bugs.
+    // we'll use the reliable test `err.name == "ErrnoError"` instead
+    constructor(errno) {
+      // TODO(sbc): Use the inline member declaration syntax once we
+      // support it in acorn and closure.
+      this.name = "ErrnoError";
+      this.errno = errno;
+    }
+  },
+  genericErrors: {},
+  filesystems: null,
+  syncFSRequests: 0,
+  FSStream: class {
+    constructor() {
+      // TODO(https://github.com/emscripten-core/emscripten/issues/21414):
+      // Use inline field declarations.
+      this.shared = {};
+    }
+    get object() {
+      return this.node;
+    }
+    set object(val) {
+      this.node = val;
+    }
+    get isRead() {
+      return (this.flags & 2097155) !== 1;
+    }
+    get isWrite() {
+      return (this.flags & 2097155) !== 0;
+    }
+    get isAppend() {
+      return (this.flags & 1024);
+    }
+    get flags() {
+      return this.shared.flags;
+    }
+    set flags(val) {
+      this.shared.flags = val;
+    }
+    get position() {
+      return this.shared.position;
+    }
+    set position(val) {
+      this.shared.position = val;
+    }
+  },
+  FSNode: class {
+    constructor(parent, name, mode, rdev) {
+      if (!parent) {
+        parent = this;
+      }
+      // root node sets parent to itself
+      this.parent = parent;
+      this.mount = parent.mount;
+      this.mounted = null;
+      this.id = FS.nextInode++;
+      this.name = name;
+      this.mode = mode;
+      this.node_ops = {};
+      this.stream_ops = {};
+      this.rdev = rdev;
+      this.readMode = 292 | 73;
+      this.writeMode = 146;
+    }
+    get read() {
+      return (this.mode & this.readMode) === this.readMode;
+    }
+    set read(val) {
+      val ? this.mode |= this.readMode : this.mode &= ~this.readMode;
+    }
+    get write() {
+      return (this.mode & this.writeMode) === this.writeMode;
+    }
+    set write(val) {
+      val ? this.mode |= this.writeMode : this.mode &= ~this.writeMode;
+    }
+    get isFolder() {
+      return FS.isDir(this.mode);
+    }
+    get isDevice() {
+      return FS.isChrdev(this.mode);
+    }
+  },
+  lookupPath(path, opts = {}) {
+    path = PATH_FS.resolve(path);
+    if (!path) return {
+      path: "",
+      node: null
+    };
+    var defaults = {
+      follow_mount: true,
+      recurse_count: 0
+    };
+    opts = Object.assign(defaults, opts);
+    if (opts.recurse_count > 8) {
+      // max recursive lookup of 8
+      throw new FS.ErrnoError(32);
+    }
+    // split the absolute path
+    var parts = path.split("/").filter(p => !!p);
+    // start at the root
+    var current = FS.root;
+    var current_path = "/";
+    for (var i = 0; i < parts.length; i++) {
+      var islast = (i === parts.length - 1);
+      if (islast && opts.parent) {
+        // stop resolving
+        break;
+      }
+      current = FS.lookupNode(current, parts[i]);
+      current_path = PATH.join2(current_path, parts[i]);
+      // jump to the mount's root node if this is a mountpoint
+      if (FS.isMountpoint(current)) {
+        if (!islast || (islast && opts.follow_mount)) {
+          current = current.mounted.root;
+        }
+      }
+      // by default, lookupPath will not follow a symlink if it is the final path component.
+      // setting opts.follow = true will override this behavior.
+      if (!islast || opts.follow) {
+        var count = 0;
+        while (FS.isLink(current.mode)) {
+          var link = FS.readlink(current_path);
+          current_path = PATH_FS.resolve(PATH.dirname(current_path), link);
+          var lookup = FS.lookupPath(current_path, {
+            recurse_count: opts.recurse_count + 1
+          });
+          current = lookup.node;
+          if (count++ > 40) {
+            // limit max consecutive symlinks to 40 (SYMLOOP_MAX).
+            throw new FS.ErrnoError(32);
+          }
+        }
+      }
+    }
+    return {
+      path: current_path,
+      node: current
+    };
+  },
+  getPath(node) {
+    var path;
+    while (true) {
+      if (FS.isRoot(node)) {
+        var mount = node.mount.mountpoint;
+        if (!path) return mount;
+        return mount[mount.length - 1] !== "/" ? `${mount}/${path}` : mount + path;
+      }
+      path = path ? `${node.name}/${path}` : node.name;
+      node = node.parent;
+    }
+  },
+  hashName(parentid, name) {
+    var hash = 0;
+    for (var i = 0; i < name.length; i++) {
+      hash = ((hash << 5) - hash + name.charCodeAt(i)) | 0;
+    }
+    return ((parentid + hash) >>> 0) % FS.nameTable.length;
+  },
+  hashAddNode(node) {
+    var hash = FS.hashName(node.parent.id, node.name);
+    node.name_next = FS.nameTable[hash];
+    FS.nameTable[hash] = node;
+  },
+  hashRemoveNode(node) {
+    var hash = FS.hashName(node.parent.id, node.name);
+    if (FS.nameTable[hash] === node) {
+      FS.nameTable[hash] = node.name_next;
+    } else {
+      var current = FS.nameTable[hash];
+      while (current) {
+        if (current.name_next === node) {
+          current.name_next = node.name_next;
+          break;
+        }
+        current = current.name_next;
+      }
+    }
+  },
+  lookupNode(parent, name) {
+    var errCode = FS.mayLookup(parent);
+    if (errCode) {
+      throw new FS.ErrnoError(errCode);
+    }
+    var hash = FS.hashName(parent.id, name);
+    for (var node = FS.nameTable[hash]; node; node = node.name_next) {
+      var nodeName = node.name;
+      if (node.parent.id === parent.id && nodeName === name) {
+        return node;
+      }
+    }
+    // if we failed to find it in the cache, call into the VFS
+    return FS.lookup(parent, name);
+  },
+  createNode(parent, name, mode, rdev) {
+    var node = new FS.FSNode(parent, name, mode, rdev);
+    FS.hashAddNode(node);
+    return node;
+  },
+  destroyNode(node) {
+    FS.hashRemoveNode(node);
+  },
+  isRoot(node) {
+    return node === node.parent;
+  },
+  isMountpoint(node) {
+    return !!node.mounted;
+  },
+  isFile(mode) {
+    return (mode & 61440) === 32768;
+  },
+  isDir(mode) {
+    return (mode & 61440) === 16384;
+  },
+  isLink(mode) {
+    return (mode & 61440) === 40960;
+  },
+  isChrdev(mode) {
+    return (mode & 61440) === 8192;
+  },
+  isBlkdev(mode) {
+    return (mode & 61440) === 24576;
+  },
+  isFIFO(mode) {
+    return (mode & 61440) === 4096;
+  },
+  isSocket(mode) {
+    return (mode & 49152) === 49152;
+  },
+  flagsToPermissionString(flag) {
+    var perms = [ "r", "w", "rw" ][flag & 3];
+    if ((flag & 512)) {
+      perms += "w";
+    }
+    return perms;
+  },
+  nodePermissions(node, perms) {
+    if (FS.ignorePermissions) {
+      return 0;
+    }
+    // return 0 if any user, group or owner bits are set.
+    if (perms.includes("r") && !(node.mode & 292)) {
+      return 2;
+    } else if (perms.includes("w") && !(node.mode & 146)) {
+      return 2;
+    } else if (perms.includes("x") && !(node.mode & 73)) {
+      return 2;
+    }
+    return 0;
+  },
+  mayLookup(dir) {
+    if (!FS.isDir(dir.mode)) return 54;
+    var errCode = FS.nodePermissions(dir, "x");
+    if (errCode) return errCode;
+    if (!dir.node_ops.lookup) return 2;
+    return 0;
+  },
+  mayCreate(dir, name) {
+    try {
+      var node = FS.lookupNode(dir, name);
+      return 20;
+    } catch (e) {}
+    return FS.nodePermissions(dir, "wx");
+  },
+  mayDelete(dir, name, isdir) {
+    var node;
+    try {
+      node = FS.lookupNode(dir, name);
+    } catch (e) {
+      return e.errno;
+    }
+    var errCode = FS.nodePermissions(dir, "wx");
+    if (errCode) {
+      return errCode;
+    }
+    if (isdir) {
+      if (!FS.isDir(node.mode)) {
+        return 54;
+      }
+      if (FS.isRoot(node) || FS.getPath(node) === FS.cwd()) {
+        return 10;
+      }
+    } else {
+      if (FS.isDir(node.mode)) {
+        return 31;
+      }
+    }
+    return 0;
+  },
+  mayOpen(node, flags) {
+    if (!node) {
+      return 44;
+    }
+    if (FS.isLink(node.mode)) {
+      return 32;
+    } else if (FS.isDir(node.mode)) {
+      if (FS.flagsToPermissionString(flags) !== "r" || // opening for write
+      (flags & 512)) {
+        // TODO: check for O_SEARCH? (== search for dir only)
+        return 31;
+      }
+    }
+    return FS.nodePermissions(node, FS.flagsToPermissionString(flags));
+  },
+  MAX_OPEN_FDS: 4096,
+  nextfd() {
+    for (var fd = 0; fd <= FS.MAX_OPEN_FDS; fd++) {
+      if (!FS.streams[fd]) {
+        return fd;
+      }
+    }
+    throw new FS.ErrnoError(33);
+  },
+  getStreamChecked(fd) {
+    var stream = FS.getStream(fd);
+    if (!stream) {
+      throw new FS.ErrnoError(8);
+    }
+    return stream;
+  },
+  getStream: fd => FS.streams[fd],
+  createStream(stream, fd = -1) {
+    // clone it, so we can return an instance of FSStream
+    stream = Object.assign(new FS.FSStream, stream);
+    if (fd == -1) {
+      fd = FS.nextfd();
+    }
+    stream.fd = fd;
+    FS.streams[fd] = stream;
+    return stream;
+  },
+  closeStream(fd) {
+    FS.streams[fd] = null;
+  },
+  dupStream(origStream, fd = -1) {
+    var stream = FS.createStream(origStream, fd);
+    stream.stream_ops?.dup?.(stream);
+    return stream;
+  },
+  chrdev_stream_ops: {
+    open(stream) {
+      var device = FS.getDevice(stream.node.rdev);
+      // override node's stream ops with the device's
+      stream.stream_ops = device.stream_ops;
+      // forward the open call
+      stream.stream_ops.open?.(stream);
+    },
+    llseek() {
+      throw new FS.ErrnoError(70);
+    }
+  },
+  major: dev => ((dev) >> 8),
+  minor: dev => ((dev) & 255),
+  makedev: (ma, mi) => ((ma) << 8 | (mi)),
+  registerDevice(dev, ops) {
+    FS.devices[dev] = {
+      stream_ops: ops
+    };
+  },
+  getDevice: dev => FS.devices[dev],
+  getMounts(mount) {
+    var mounts = [];
+    var check = [ mount ];
+    while (check.length) {
+      var m = check.pop();
+      mounts.push(m);
+      check.push(...m.mounts);
+    }
+    return mounts;
+  },
+  syncfs(populate, callback) {
+    if (typeof populate == "function") {
+      callback = populate;
+      populate = false;
+    }
+    FS.syncFSRequests++;
+    if (FS.syncFSRequests > 1) {
+      err(`warning: ${FS.syncFSRequests} FS.syncfs operations in flight at once, probably just doing extra work`);
+    }
+    var mounts = FS.getMounts(FS.root.mount);
+    var completed = 0;
+    function doCallback(errCode) {
+      FS.syncFSRequests--;
+      return callback(errCode);
+    }
+    function done(errCode) {
+      if (errCode) {
+        if (!done.errored) {
+          done.errored = true;
+          return doCallback(errCode);
+        }
+        return;
+      }
+      if (++completed >= mounts.length) {
+        doCallback(null);
+      }
+    }
+    // sync all mounts
+    mounts.forEach(mount => {
+      if (!mount.type.syncfs) {
+        return done(null);
+      }
+      mount.type.syncfs(mount, populate, done);
+    });
+  },
+  mount(type, opts, mountpoint) {
+    var root = mountpoint === "/";
+    var pseudo = !mountpoint;
+    var node;
+    if (root && FS.root) {
+      throw new FS.ErrnoError(10);
+    } else if (!root && !pseudo) {
+      var lookup = FS.lookupPath(mountpoint, {
+        follow_mount: false
+      });
+      mountpoint = lookup.path;
+      // use the absolute path
+      node = lookup.node;
+      if (FS.isMountpoint(node)) {
+        throw new FS.ErrnoError(10);
+      }
+      if (!FS.isDir(node.mode)) {
+        throw new FS.ErrnoError(54);
+      }
+    }
+    var mount = {
+      type,
+      opts,
+      mountpoint,
+      mounts: []
+    };
+    // create a root node for the fs
+    var mountRoot = type.mount(mount);
+    mountRoot.mount = mount;
+    mount.root = mountRoot;
+    if (root) {
+      FS.root = mountRoot;
+    } else if (node) {
+      // set as a mountpoint
+      node.mounted = mount;
+      // add the new mount to the current mount's children
+      if (node.mount) {
+        node.mount.mounts.push(mount);
+      }
+    }
+    return mountRoot;
+  },
+  unmount(mountpoint) {
+    var lookup = FS.lookupPath(mountpoint, {
+      follow_mount: false
+    });
+    if (!FS.isMountpoint(lookup.node)) {
+      throw new FS.ErrnoError(28);
+    }
+    // destroy the nodes for this mount, and all its child mounts
+    var node = lookup.node;
+    var mount = node.mounted;
+    var mounts = FS.getMounts(mount);
+    Object.keys(FS.nameTable).forEach(hash => {
+      var current = FS.nameTable[hash];
+      while (current) {
+        var next = current.name_next;
+        if (mounts.includes(current.mount)) {
+          FS.destroyNode(current);
+        }
+        current = next;
+      }
+    });
+    // no longer a mountpoint
+    node.mounted = null;
+    // remove this mount from the child mounts
+    var idx = node.mount.mounts.indexOf(mount);
+    node.mount.mounts.splice(idx, 1);
+  },
+  lookup(parent, name) {
+    return parent.node_ops.lookup(parent, name);
+  },
+  mknod(path, mode, dev) {
+    var lookup = FS.lookupPath(path, {
+      parent: true
+    });
+    var parent = lookup.node;
+    var name = PATH.basename(path);
+    if (!name || name === "." || name === "..") {
+      throw new FS.ErrnoError(28);
+    }
+    var errCode = FS.mayCreate(parent, name);
+    if (errCode) {
+      throw new FS.ErrnoError(errCode);
+    }
+    if (!parent.node_ops.mknod) {
+      throw new FS.ErrnoError(63);
+    }
+    return parent.node_ops.mknod(parent, name, mode, dev);
+  },
+  create(path, mode) {
+    mode = mode !== undefined ? mode : 438;
+    /* 0666 */ mode &= 4095;
+    mode |= 32768;
+    return FS.mknod(path, mode, 0);
+  },
+  mkdir(path, mode) {
+    mode = mode !== undefined ? mode : 511;
+    /* 0777 */ mode &= 511 | 512;
+    mode |= 16384;
+    return FS.mknod(path, mode, 0);
+  },
+  mkdirTree(path, mode) {
+    var dirs = path.split("/");
+    var d = "";
+    for (var i = 0; i < dirs.length; ++i) {
+      if (!dirs[i]) continue;
+      d += "/" + dirs[i];
+      try {
+        FS.mkdir(d, mode);
+      } catch (e) {
+        if (e.errno != 20) throw e;
+      }
+    }
+  },
+  mkdev(path, mode, dev) {
+    if (typeof dev == "undefined") {
+      dev = mode;
+      mode = 438;
+    }
+    /* 0666 */ mode |= 8192;
+    return FS.mknod(path, mode, dev);
+  },
+  symlink(oldpath, newpath) {
+    if (!PATH_FS.resolve(oldpath)) {
+      throw new FS.ErrnoError(44);
+    }
+    var lookup = FS.lookupPath(newpath, {
+      parent: true
+    });
+    var parent = lookup.node;
+    if (!parent) {
+      throw new FS.ErrnoError(44);
+    }
+    var newname = PATH.basename(newpath);
+    var errCode = FS.mayCreate(parent, newname);
+    if (errCode) {
+      throw new FS.ErrnoError(errCode);
+    }
+    if (!parent.node_ops.symlink) {
+      throw new FS.ErrnoError(63);
+    }
+    return parent.node_ops.symlink(parent, newname, oldpath);
+  },
+  rename(old_path, new_path) {
+    var old_dirname = PATH.dirname(old_path);
+    var new_dirname = PATH.dirname(new_path);
+    var old_name = PATH.basename(old_path);
+    var new_name = PATH.basename(new_path);
+    // parents must exist
+    var lookup, old_dir, new_dir;
+    // let the errors from non existent directories percolate up
+    lookup = FS.lookupPath(old_path, {
+      parent: true
+    });
+    old_dir = lookup.node;
+    lookup = FS.lookupPath(new_path, {
+      parent: true
+    });
+    new_dir = lookup.node;
+    if (!old_dir || !new_dir) throw new FS.ErrnoError(44);
+    // need to be part of the same mount
+    if (old_dir.mount !== new_dir.mount) {
+      throw new FS.ErrnoError(75);
+    }
+    // source must exist
+    var old_node = FS.lookupNode(old_dir, old_name);
+    // old path should not be an ancestor of the new path
+    var relative = PATH_FS.relative(old_path, new_dirname);
+    if (relative.charAt(0) !== ".") {
+      throw new FS.ErrnoError(28);
+    }
+    // new path should not be an ancestor of the old path
+    relative = PATH_FS.relative(new_path, old_dirname);
+    if (relative.charAt(0) !== ".") {
+      throw new FS.ErrnoError(55);
+    }
+    // see if the new path already exists
+    var new_node;
+    try {
+      new_node = FS.lookupNode(new_dir, new_name);
+    } catch (e) {}
+    // early out if nothing needs to change
+    if (old_node === new_node) {
+      return;
+    }
+    // we'll need to delete the old entry
+    var isdir = FS.isDir(old_node.mode);
+    var errCode = FS.mayDelete(old_dir, old_name, isdir);
+    if (errCode) {
+      throw new FS.ErrnoError(errCode);
+    }
+    // need delete permissions if we'll be overwriting.
+    // need create permissions if new doesn't already exist.
+    errCode = new_node ? FS.mayDelete(new_dir, new_name, isdir) : FS.mayCreate(new_dir, new_name);
+    if (errCode) {
+      throw new FS.ErrnoError(errCode);
+    }
+    if (!old_dir.node_ops.rename) {
+      throw new FS.ErrnoError(63);
+    }
+    if (FS.isMountpoint(old_node) || (new_node && FS.isMountpoint(new_node))) {
+      throw new FS.ErrnoError(10);
+    }
+    // if we are going to change the parent, check write permissions
+    if (new_dir !== old_dir) {
+      errCode = FS.nodePermissions(old_dir, "w");
+      if (errCode) {
+        throw new FS.ErrnoError(errCode);
+      }
+    }
+    // remove the node from the lookup hash
+    FS.hashRemoveNode(old_node);
+    // do the underlying fs rename
+    try {
+      old_dir.node_ops.rename(old_node, new_dir, new_name);
+      // update old node (we do this here to avoid each backend 
+      // needing to)
+      old_node.parent = new_dir;
+    } catch (e) {
+      throw e;
+    } finally {
+      // add the node back to the hash (in case node_ops.rename
+      // changed its name)
+      FS.hashAddNode(old_node);
+    }
+  },
+  rmdir(path) {
+    var lookup = FS.lookupPath(path, {
+      parent: true
+    });
+    var parent = lookup.node;
+    var name = PATH.basename(path);
+    var node = FS.lookupNode(parent, name);
+    var errCode = FS.mayDelete(parent, name, true);
+    if (errCode) {
+      throw new FS.ErrnoError(errCode);
+    }
+    if (!parent.node_ops.rmdir) {
+      throw new FS.ErrnoError(63);
+    }
+    if (FS.isMountpoint(node)) {
+      throw new FS.ErrnoError(10);
+    }
+    parent.node_ops.rmdir(parent, name);
+    FS.destroyNode(node);
+  },
+  readdir(path) {
+    var lookup = FS.lookupPath(path, {
+      follow: true
+    });
+    var node = lookup.node;
+    if (!node.node_ops.readdir) {
+      throw new FS.ErrnoError(54);
+    }
+    return node.node_ops.readdir(node);
+  },
+  unlink(path) {
+    var lookup = FS.lookupPath(path, {
+      parent: true
+    });
+    var parent = lookup.node;
+    if (!parent) {
+      throw new FS.ErrnoError(44);
+    }
+    var name = PATH.basename(path);
+    var node = FS.lookupNode(parent, name);
+    var errCode = FS.mayDelete(parent, name, false);
+    if (errCode) {
+      // According to POSIX, we should map EISDIR to EPERM, but
+      // we instead do what Linux does (and we must, as we use
+      // the musl linux libc).
+      throw new FS.ErrnoError(errCode);
+    }
+    if (!parent.node_ops.unlink) {
+      throw new FS.ErrnoError(63);
+    }
+    if (FS.isMountpoint(node)) {
+      throw new FS.ErrnoError(10);
+    }
+    parent.node_ops.unlink(parent, name);
+    FS.destroyNode(node);
+  },
+  readlink(path) {
+    var lookup = FS.lookupPath(path);
+    var link = lookup.node;
+    if (!link) {
+      throw new FS.ErrnoError(44);
+    }
+    if (!link.node_ops.readlink) {
+      throw new FS.ErrnoError(28);
+    }
+    return PATH_FS.resolve(FS.getPath(link.parent), link.node_ops.readlink(link));
+  },
+  stat(path, dontFollow) {
+    var lookup = FS.lookupPath(path, {
+      follow: !dontFollow
+    });
+    var node = lookup.node;
+    if (!node) {
+      throw new FS.ErrnoError(44);
+    }
+    if (!node.node_ops.getattr) {
+      throw new FS.ErrnoError(63);
+    }
+    return node.node_ops.getattr(node);
+  },
+  lstat(path) {
+    return FS.stat(path, true);
+  },
+  chmod(path, mode, dontFollow) {
+    var node;
+    if (typeof path == "string") {
+      var lookup = FS.lookupPath(path, {
+        follow: !dontFollow
+      });
+      node = lookup.node;
+    } else {
+      node = path;
+    }
+    if (!node.node_ops.setattr) {
+      throw new FS.ErrnoError(63);
+    }
+    node.node_ops.setattr(node, {
+      mode: (mode & 4095) | (node.mode & ~4095),
+      timestamp: Date.now()
+    });
+  },
+  lchmod(path, mode) {
+    FS.chmod(path, mode, true);
+  },
+  fchmod(fd, mode) {
+    var stream = FS.getStreamChecked(fd);
+    FS.chmod(stream.node, mode);
+  },
+  chown(path, uid, gid, dontFollow) {
+    var node;
+    if (typeof path == "string") {
+      var lookup = FS.lookupPath(path, {
+        follow: !dontFollow
+      });
+      node = lookup.node;
+    } else {
+      node = path;
+    }
+    if (!node.node_ops.setattr) {
+      throw new FS.ErrnoError(63);
+    }
+    node.node_ops.setattr(node, {
+      timestamp: Date.now()
+    });
+  },
+  // we ignore the uid / gid for now
+  lchown(path, uid, gid) {
+    FS.chown(path, uid, gid, true);
+  },
+  fchown(fd, uid, gid) {
+    var stream = FS.getStreamChecked(fd);
+    FS.chown(stream.node, uid, gid);
+  },
+  truncate(path, len) {
+    if (len < 0) {
+      throw new FS.ErrnoError(28);
+    }
+    var node;
+    if (typeof path == "string") {
+      var lookup = FS.lookupPath(path, {
+        follow: true
+      });
+      node = lookup.node;
+    } else {
+      node = path;
+    }
+    if (!node.node_ops.setattr) {
+      throw new FS.ErrnoError(63);
+    }
+    if (FS.isDir(node.mode)) {
+      throw new FS.ErrnoError(31);
+    }
+    if (!FS.isFile(node.mode)) {
+      throw new FS.ErrnoError(28);
+    }
+    var errCode = FS.nodePermissions(node, "w");
+    if (errCode) {
+      throw new FS.ErrnoError(errCode);
+    }
+    node.node_ops.setattr(node, {
+      size: len,
+      timestamp: Date.now()
+    });
+  },
+  ftruncate(fd, len) {
+    var stream = FS.getStreamChecked(fd);
+    if ((stream.flags & 2097155) === 0) {
+      throw new FS.ErrnoError(28);
+    }
+    FS.truncate(stream.node, len);
+  },
+  utime(path, atime, mtime) {
+    var lookup = FS.lookupPath(path, {
+      follow: true
+    });
+    var node = lookup.node;
+    node.node_ops.setattr(node, {
+      timestamp: Math.max(atime, mtime)
+    });
+  },
+  open(path, flags, mode) {
+    if (path === "") {
+      throw new FS.ErrnoError(44);
+    }
+    flags = typeof flags == "string" ? FS_modeStringToFlags(flags) : flags;
+    if ((flags & 64)) {
+      mode = typeof mode == "undefined" ? 438 : /* 0666 */ mode;
+      mode = (mode & 4095) | 32768;
+    } else {
+      mode = 0;
+    }
+    var node;
+    if (typeof path == "object") {
+      node = path;
+    } else {
+      path = PATH.normalize(path);
+      try {
+        var lookup = FS.lookupPath(path, {
+          follow: !(flags & 131072)
+        });
+        node = lookup.node;
+      } catch (e) {}
+    }
+    // perhaps we need to create the node
+    var created = false;
+    if ((flags & 64)) {
+      if (node) {
+        // if O_CREAT and O_EXCL are set, error out if the node already exists
+        if ((flags & 128)) {
+          throw new FS.ErrnoError(20);
+        }
+      } else {
+        // node doesn't exist, try to create it
+        node = FS.mknod(path, mode, 0);
+        created = true;
+      }
+    }
+    if (!node) {
+      throw new FS.ErrnoError(44);
+    }
+    // can't truncate a device
+    if (FS.isChrdev(node.mode)) {
+      flags &= ~512;
+    }
+    // if asked only for a directory, then this must be one
+    if ((flags & 65536) && !FS.isDir(node.mode)) {
+      throw new FS.ErrnoError(54);
+    }
+    // check permissions, if this is not a file we just created now (it is ok to
+    // create and write to a file with read-only permissions; it is read-only
+    // for later use)
+    if (!created) {
+      var errCode = FS.mayOpen(node, flags);
+      if (errCode) {
+        throw new FS.ErrnoError(errCode);
+      }
+    }
+    // do truncation if necessary
+    if ((flags & 512) && !created) {
+      FS.truncate(node, 0);
+    }
+    // we've already handled these, don't pass down to the underlying vfs
+    flags &= ~(128 | 512 | 131072);
+    // register the stream with the filesystem
+    var stream = FS.createStream({
+      node,
+      path: FS.getPath(node),
+      // we want the absolute path to the node
+      flags,
+      seekable: true,
+      position: 0,
+      stream_ops: node.stream_ops,
+      // used by the file family libc calls (fopen, fwrite, ferror, etc.)
+      ungotten: [],
+      error: false
+    });
+    // call the new stream's open function
+    if (stream.stream_ops.open) {
+      stream.stream_ops.open(stream);
+    }
+    if (Module["logReadFiles"] && !(flags & 1)) {
+      if (!FS.readFiles) FS.readFiles = {};
+      if (!(path in FS.readFiles)) {
+        FS.readFiles[path] = 1;
+      }
+    }
+    return stream;
+  },
+  close(stream) {
+    if (FS.isClosed(stream)) {
+      throw new FS.ErrnoError(8);
+    }
+    if (stream.getdents) stream.getdents = null;
+    // free readdir state
+    try {
+      if (stream.stream_ops.close) {
+        stream.stream_ops.close(stream);
+      }
+    } catch (e) {
+      throw e;
+    } finally {
+      FS.closeStream(stream.fd);
+    }
+    stream.fd = null;
+  },
+  isClosed(stream) {
+    return stream.fd === null;
+  },
+  llseek(stream, offset, whence) {
+    if (FS.isClosed(stream)) {
+      throw new FS.ErrnoError(8);
+    }
+    if (!stream.seekable || !stream.stream_ops.llseek) {
+      throw new FS.ErrnoError(70);
+    }
+    if (whence != 0 && whence != 1 && whence != 2) {
+      throw new FS.ErrnoError(28);
+    }
+    stream.position = stream.stream_ops.llseek(stream, offset, whence);
+    stream.ungotten = [];
+    return stream.position;
+  },
+  read(stream, buffer, offset, length, position) {
+    if (length < 0 || position < 0) {
+      throw new FS.ErrnoError(28);
+    }
+    if (FS.isClosed(stream)) {
+      throw new FS.ErrnoError(8);
+    }
+    if ((stream.flags & 2097155) === 1) {
+      throw new FS.ErrnoError(8);
+    }
+    if (FS.isDir(stream.node.mode)) {
+      throw new FS.ErrnoError(31);
+    }
+    if (!stream.stream_ops.read) {
+      throw new FS.ErrnoError(28);
+    }
+    var seeking = typeof position != "undefined";
+    if (!seeking) {
+      position = stream.position;
+    } else if (!stream.seekable) {
+      throw new FS.ErrnoError(70);
+    }
+    var bytesRead = stream.stream_ops.read(stream, buffer, offset, length, position);
+    if (!seeking) stream.position += bytesRead;
+    return bytesRead;
+  },
+  write(stream, buffer, offset, length, position, canOwn) {
+    if (length < 0 || position < 0) {
+      throw new FS.ErrnoError(28);
+    }
+    if (FS.isClosed(stream)) {
+      throw new FS.ErrnoError(8);
+    }
+    if ((stream.flags & 2097155) === 0) {
+      throw new FS.ErrnoError(8);
+    }
+    if (FS.isDir(stream.node.mode)) {
+      throw new FS.ErrnoError(31);
+    }
+    if (!stream.stream_ops.write) {
+      throw new FS.ErrnoError(28);
+    }
+    if (stream.seekable && stream.flags & 1024) {
+      // seek to the end before writing in append mode
+      FS.llseek(stream, 0, 2);
+    }
+    var seeking = typeof position != "undefined";
+    if (!seeking) {
+      position = stream.position;
+    } else if (!stream.seekable) {
+      throw new FS.ErrnoError(70);
+    }
+    var bytesWritten = stream.stream_ops.write(stream, buffer, offset, length, position, canOwn);
+    if (!seeking) stream.position += bytesWritten;
+    return bytesWritten;
+  },
+  allocate(stream, offset, length) {
+    if (FS.isClosed(stream)) {
+      throw new FS.ErrnoError(8);
+    }
+    if (offset < 0 || length <= 0) {
+      throw new FS.ErrnoError(28);
+    }
+    if ((stream.flags & 2097155) === 0) {
+      throw new FS.ErrnoError(8);
+    }
+    if (!FS.isFile(stream.node.mode) && !FS.isDir(stream.node.mode)) {
+      throw new FS.ErrnoError(43);
+    }
+    if (!stream.stream_ops.allocate) {
+      throw new FS.ErrnoError(138);
+    }
+    stream.stream_ops.allocate(stream, offset, length);
+  },
+  mmap(stream, length, position, prot, flags) {
+    // User requests writing to file (prot & PROT_WRITE != 0).
+    // Checking if we have permissions to write to the file unless
+    // MAP_PRIVATE flag is set. According to POSIX spec it is possible
+    // to write to file opened in read-only mode with MAP_PRIVATE flag,
+    // as all modifications will be visible only in the memory of
+    // the current process.
+    if ((prot & 2) !== 0 && (flags & 2) === 0 && (stream.flags & 2097155) !== 2) {
+      throw new FS.ErrnoError(2);
+    }
+    if ((stream.flags & 2097155) === 1) {
+      throw new FS.ErrnoError(2);
+    }
+    if (!stream.stream_ops.mmap) {
+      throw new FS.ErrnoError(43);
+    }
+    if (!length) {
+      throw new FS.ErrnoError(28);
+    }
+    return stream.stream_ops.mmap(stream, length, position, prot, flags);
+  },
+  msync(stream, buffer, offset, length, mmapFlags) {
+    if (!stream.stream_ops.msync) {
+      return 0;
+    }
+    return stream.stream_ops.msync(stream, buffer, offset, length, mmapFlags);
+  },
+  ioctl(stream, cmd, arg) {
+    if (!stream.stream_ops.ioctl) {
+      throw new FS.ErrnoError(59);
+    }
+    return stream.stream_ops.ioctl(stream, cmd, arg);
+  },
+  readFile(path, opts = {}) {
+    opts.flags = opts.flags || 0;
+    opts.encoding = opts.encoding || "binary";
+    if (opts.encoding !== "utf8" && opts.encoding !== "binary") {
+      throw new Error(`Invalid encoding type "${opts.encoding}"`);
+    }
+    var ret;
+    var stream = FS.open(path, opts.flags);
+    var stat = FS.stat(path);
+    var length = stat.size;
+    var buf = new Uint8Array(length);
+    FS.read(stream, buf, 0, length, 0);
+    if (opts.encoding === "utf8") {
+      ret = UTF8ArrayToString(buf, 0);
+    } else if (opts.encoding === "binary") {
+      ret = buf;
+    }
+    FS.close(stream);
+    return ret;
+  },
+  writeFile(path, data, opts = {}) {
+    opts.flags = opts.flags || 577;
+    var stream = FS.open(path, opts.flags, opts.mode);
+    if (typeof data == "string") {
+      var buf = new Uint8Array(lengthBytesUTF8(data) + 1);
+      var actualNumBytes = stringToUTF8Array(data, buf, 0, buf.length);
+      FS.write(stream, buf, 0, actualNumBytes, undefined, opts.canOwn);
+    } else if (ArrayBuffer.isView(data)) {
+      FS.write(stream, data, 0, data.byteLength, undefined, opts.canOwn);
+    } else {
+      throw new Error("Unsupported data type");
+    }
+    FS.close(stream);
+  },
+  cwd: () => FS.currentPath,
+  chdir(path) {
+    var lookup = FS.lookupPath(path, {
+      follow: true
+    });
+    if (lookup.node === null) {
+      throw new FS.ErrnoError(44);
+    }
+    if (!FS.isDir(lookup.node.mode)) {
+      throw new FS.ErrnoError(54);
+    }
+    var errCode = FS.nodePermissions(lookup.node, "x");
+    if (errCode) {
+      throw new FS.ErrnoError(errCode);
+    }
+    FS.currentPath = lookup.path;
+  },
+  createDefaultDirectories() {
+    FS.mkdir("/tmp");
+    FS.mkdir("/home");
+    FS.mkdir("/home/web_user");
+  },
+  createDefaultDevices() {
+    // create /dev
+    FS.mkdir("/dev");
+    // setup /dev/null
+    FS.registerDevice(FS.makedev(1, 3), {
+      read: () => 0,
+      write: (stream, buffer, offset, length, pos) => length
+    });
+    FS.mkdev("/dev/null", FS.makedev(1, 3));
+    // setup /dev/tty and /dev/tty1
+    // stderr needs to print output using err() rather than out()
+    // so we register a second tty just for it.
+    TTY.register(FS.makedev(5, 0), TTY.default_tty_ops);
+    TTY.register(FS.makedev(6, 0), TTY.default_tty1_ops);
+    FS.mkdev("/dev/tty", FS.makedev(5, 0));
+    FS.mkdev("/dev/tty1", FS.makedev(6, 0));
+    // setup /dev/[u]random
+    // use a buffer to avoid overhead of individual crypto calls per byte
+    var randomBuffer = new Uint8Array(1024), randomLeft = 0;
+    var randomByte = () => {
+      if (randomLeft === 0) {
+        randomLeft = randomFill(randomBuffer).byteLength;
+      }
+      return randomBuffer[--randomLeft];
+    };
+    FS.createDevice("/dev", "random", randomByte);
+    FS.createDevice("/dev", "urandom", randomByte);
+    // we're not going to emulate the actual shm device,
+    // just create the tmp dirs that reside in it commonly
+    FS.mkdir("/dev/shm");
+    FS.mkdir("/dev/shm/tmp");
+  },
+  createSpecialDirectories() {
+    // create /proc/self/fd which allows /proc/self/fd/6 => readlink gives the
+    // name of the stream for fd 6 (see test_unistd_ttyname)
+    FS.mkdir("/proc");
+    var proc_self = FS.mkdir("/proc/self");
+    FS.mkdir("/proc/self/fd");
+    FS.mount({
+      mount() {
+        var node = FS.createNode(proc_self, "fd", 16384 | 511, /* 0777 */ 73);
+        node.node_ops = {
+          lookup(parent, name) {
+            var fd = +name;
+            var stream = FS.getStreamChecked(fd);
+            var ret = {
+              parent: null,
+              mount: {
+                mountpoint: "fake"
+              },
+              node_ops: {
+                readlink: () => stream.path
+              }
+            };
+            ret.parent = ret;
+            // make it look like a simple root node
+            return ret;
+          }
+        };
+        return node;
+      }
+    }, {}, "/proc/self/fd");
+  },
+  createStandardStreams(input, output, error) {
+    // TODO deprecate the old functionality of a single
+    // input / output callback and that utilizes FS.createDevice
+    // and instead require a unique set of stream ops
+    // by default, we symlink the standard streams to the
+    // default tty devices. however, if the standard streams
+    // have been overwritten we create a unique device for
+    // them instead.
+    if (input) {
+      FS.createDevice("/dev", "stdin", input);
+    } else {
+      FS.symlink("/dev/tty", "/dev/stdin");
+    }
+    if (output) {
+      FS.createDevice("/dev", "stdout", null, output);
+    } else {
+      FS.symlink("/dev/tty", "/dev/stdout");
+    }
+    if (error) {
+      FS.createDevice("/dev", "stderr", null, error);
+    } else {
+      FS.symlink("/dev/tty1", "/dev/stderr");
+    }
+    // open default streams for the stdin, stdout and stderr devices
+    var stdin = FS.open("/dev/stdin", 0);
+    var stdout = FS.open("/dev/stdout", 1);
+    var stderr = FS.open("/dev/stderr", 1);
+  },
+  staticInit() {
+    // Some errors may happen quite a bit, to avoid overhead we reuse them (and suffer a lack of stack info)
+    [ 44 ].forEach(code => {
+      FS.genericErrors[code] = new FS.ErrnoError(code);
+      FS.genericErrors[code].stack = "<generic error, no stack>";
+    });
+    FS.nameTable = new Array(4096);
+    FS.mount(MEMFS, {}, "/");
+    FS.createDefaultDirectories();
+    FS.createDefaultDevices();
+    FS.createSpecialDirectories();
+    FS.filesystems = {
+      "MEMFS": MEMFS,
+      "WORKERFS": WORKERFS
+    };
+  },
+  init(input, output, error) {
+    FS.initialized = true;
+    // Allow Module.stdin etc. to provide defaults, if none explicitly passed to us here
+    input ??= Module["stdin"];
+    output ??= Module["stdout"];
+    error ??= Module["stderr"];
+    FS.createStandardStreams(input, output, error);
+  },
+  quit() {
+    FS.initialized = false;
+    // force-flush all streams, so we get musl std streams printed out
+    // close all of our streams
+    for (var i = 0; i < FS.streams.length; i++) {
+      var stream = FS.streams[i];
+      if (!stream) {
+        continue;
+      }
+      FS.close(stream);
+    }
+  },
+  findObject(path, dontResolveLastLink) {
+    var ret = FS.analyzePath(path, dontResolveLastLink);
+    if (!ret.exists) {
+      return null;
+    }
+    return ret.object;
+  },
+  analyzePath(path, dontResolveLastLink) {
+    // operate from within the context of the symlink's target
+    try {
+      var lookup = FS.lookupPath(path, {
+        follow: !dontResolveLastLink
+      });
+      path = lookup.path;
+    } catch (e) {}
+    var ret = {
+      isRoot: false,
+      exists: false,
+      error: 0,
+      name: null,
+      path: null,
+      object: null,
+      parentExists: false,
+      parentPath: null,
+      parentObject: null
+    };
+    try {
+      var lookup = FS.lookupPath(path, {
+        parent: true
+      });
+      ret.parentExists = true;
+      ret.parentPath = lookup.path;
+      ret.parentObject = lookup.node;
+      ret.name = PATH.basename(path);
+      lookup = FS.lookupPath(path, {
+        follow: !dontResolveLastLink
+      });
+      ret.exists = true;
+      ret.path = lookup.path;
+      ret.object = lookup.node;
+      ret.name = lookup.node.name;
+      ret.isRoot = lookup.path === "/";
+    } catch (e) {
+      ret.error = e.errno;
+    }
+    return ret;
+  },
+  createPath(parent, path, canRead, canWrite) {
+    parent = typeof parent == "string" ? parent : FS.getPath(parent);
+    var parts = path.split("/").reverse();
+    while (parts.length) {
+      var part = parts.pop();
+      if (!part) continue;
+      var current = PATH.join2(parent, part);
+      try {
+        FS.mkdir(current);
+      } catch (e) {}
+      // ignore EEXIST
+      parent = current;
+    }
+    return current;
+  },
+  createFile(parent, name, properties, canRead, canWrite) {
+    var path = PATH.join2(typeof parent == "string" ? parent : FS.getPath(parent), name);
+    var mode = FS_getMode(canRead, canWrite);
+    return FS.create(path, mode);
+  },
+  createDataFile(parent, name, data, canRead, canWrite, canOwn) {
+    var path = name;
+    if (parent) {
+      parent = typeof parent == "string" ? parent : FS.getPath(parent);
+      path = name ? PATH.join2(parent, name) : parent;
+    }
+    var mode = FS_getMode(canRead, canWrite);
+    var node = FS.create(path, mode);
+    if (data) {
+      if (typeof data == "string") {
+        var arr = new Array(data.length);
+        for (var i = 0, len = data.length; i < len; ++i) arr[i] = data.charCodeAt(i);
+        data = arr;
+      }
+      // make sure we can write to the file
+      FS.chmod(node, mode | 146);
+      var stream = FS.open(node, 577);
+      FS.write(stream, data, 0, data.length, 0, canOwn);
+      FS.close(stream);
+      FS.chmod(node, mode);
+    }
+  },
+  createDevice(parent, name, input, output) {
+    var path = PATH.join2(typeof parent == "string" ? parent : FS.getPath(parent), name);
+    var mode = FS_getMode(!!input, !!output);
+    if (!FS.createDevice.major) FS.createDevice.major = 64;
+    var dev = FS.makedev(FS.createDevice.major++, 0);
+    // Create a fake device that a set of stream ops to emulate
+    // the old behavior.
+    FS.registerDevice(dev, {
+      open(stream) {
+        stream.seekable = false;
+      },
+      close(stream) {
+        // flush any pending line data
+        if (output?.buffer?.length) {
+          output(10);
+        }
+      },
+      read(stream, buffer, offset, length, pos) {
+        /* ignored */ var bytesRead = 0;
+        for (var i = 0; i < length; i++) {
+          var result;
+          try {
+            result = input();
+          } catch (e) {
+            throw new FS.ErrnoError(29);
+          }
+          if (result === undefined && bytesRead === 0) {
+            throw new FS.ErrnoError(6);
+          }
+          if (result === null || result === undefined) break;
+          bytesRead++;
+          buffer[offset + i] = result;
+        }
+        if (bytesRead) {
+          stream.node.timestamp = Date.now();
+        }
+        return bytesRead;
+      },
+      write(stream, buffer, offset, length, pos) {
+        for (var i = 0; i < length; i++) {
+          try {
+            output(buffer[offset + i]);
+          } catch (e) {
+            throw new FS.ErrnoError(29);
+          }
+        }
+        if (length) {
+          stream.node.timestamp = Date.now();
+        }
+        return i;
+      }
+    });
+    return FS.mkdev(path, mode, dev);
+  },
+  forceLoadFile(obj) {
+    if (obj.isDevice || obj.isFolder || obj.link || obj.contents) return true;
+    if (typeof XMLHttpRequest != "undefined") {
+      throw new Error("Lazy loading should have been performed (contents set) in createLazyFile, but it was not. Lazy loading only works in web workers. Use --embed-file or --preload-file in emcc on the main thread.");
+    } else {
+      // Command-line.
+      try {
+        obj.contents = readBinary(obj.url);
+        obj.usedBytes = obj.contents.length;
+      } catch (e) {
+        throw new FS.ErrnoError(29);
+      }
+    }
+  },
+  createLazyFile(parent, name, url, canRead, canWrite) {
+    // Lazy chunked Uint8Array (implements get and length from Uint8Array).
+    // Actual getting is abstracted away for eventual reuse.
+    class LazyUint8Array {
+      constructor() {
+        this.lengthKnown = false;
+        this.chunks = [];
+      }
+      // Loaded chunks. Index is the chunk number
+      get(idx) {
+        if (idx > this.length - 1 || idx < 0) {
+          return undefined;
+        }
+        var chunkOffset = idx % this.chunkSize;
+        var chunkNum = (idx / this.chunkSize) | 0;
+        return this.getter(chunkNum)[chunkOffset];
+      }
+      setDataGetter(getter) {
+        this.getter = getter;
+      }
+      cacheLength() {
+        // Find length
+        var xhr = new XMLHttpRequest;
+        xhr.open("HEAD", url, false);
+        xhr.send(null);
+        if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) throw new Error("Couldn't load " + url + ". Status: " + xhr.status);
+        var datalength = Number(xhr.getResponseHeader("Content-length"));
+        var header;
+        var hasByteServing = (header = xhr.getResponseHeader("Accept-Ranges")) && header === "bytes";
+        var usesGzip = (header = xhr.getResponseHeader("Content-Encoding")) && header === "gzip";
+        var chunkSize = 1024 * 1024;
+        // Chunk size in bytes
+        if (!hasByteServing) chunkSize = datalength;
+        // Function to get a range from the remote URL.
+        var doXHR = (from, to) => {
+          if (from > to) throw new Error("invalid range (" + from + ", " + to + ") or no bytes requested!");
+          if (to > datalength - 1) throw new Error("only " + datalength + " bytes available! programmer error!");
+          // TODO: Use mozResponseArrayBuffer, responseStream, etc. if available.
+          var xhr = new XMLHttpRequest;
+          xhr.open("GET", url, false);
+          if (datalength !== chunkSize) xhr.setRequestHeader("Range", "bytes=" + from + "-" + to);
+          // Some hints to the browser that we want binary data.
+          xhr.responseType = "arraybuffer";
+          if (xhr.overrideMimeType) {
+            xhr.overrideMimeType("text/plain; charset=x-user-defined");
+          }
+          xhr.send(null);
+          if (!(xhr.status >= 200 && xhr.status < 300 || xhr.status === 304)) throw new Error("Couldn't load " + url + ". Status: " + xhr.status);
+          if (xhr.response !== undefined) {
+            return new Uint8Array(/** @type{Array<number>} */ (xhr.response || []));
+          }
+          return intArrayFromString(xhr.responseText || "", true);
+        };
+        var lazyArray = this;
+        lazyArray.setDataGetter(chunkNum => {
+          var start = chunkNum * chunkSize;
+          var end = (chunkNum + 1) * chunkSize - 1;
+          // including this byte
+          end = Math.min(end, datalength - 1);
+          // if datalength-1 is selected, this is the last block
+          if (typeof lazyArray.chunks[chunkNum] == "undefined") {
+            lazyArray.chunks[chunkNum] = doXHR(start, end);
+          }
+          if (typeof lazyArray.chunks[chunkNum] == "undefined") throw new Error("doXHR failed!");
+          return lazyArray.chunks[chunkNum];
+        });
+        if (usesGzip || !datalength) {
+          // if the server uses gzip or doesn't supply the length, we have to download the whole file to get the (uncompressed) length
+          chunkSize = datalength = 1;
+          // this will force getter(0)/doXHR do download the whole file
+          datalength = this.getter(0).length;
+          chunkSize = datalength;
+          out("LazyFiles on gzip forces download of the whole file when length is accessed");
+        }
+        this._length = datalength;
+        this._chunkSize = chunkSize;
+        this.lengthKnown = true;
+      }
+      get length() {
+        if (!this.lengthKnown) {
+          this.cacheLength();
+        }
+        return this._length;
+      }
+      get chunkSize() {
+        if (!this.lengthKnown) {
+          this.cacheLength();
+        }
+        return this._chunkSize;
+      }
+    }
+    if (typeof XMLHttpRequest != "undefined") {
+      if (!ENVIRONMENT_IS_WORKER) throw "Cannot do synchronous binary XHRs outside webworkers in modern browsers. Use --embed-file or --preload-file in emcc";
+      var lazyArray = new LazyUint8Array;
+      var properties = {
+        isDevice: false,
+        contents: lazyArray
+      };
+    } else {
+      var properties = {
+        isDevice: false,
+        url
+      };
+    }
+    var node = FS.createFile(parent, name, properties, canRead, canWrite);
+    // This is a total hack, but I want to get this lazy file code out of the
+    // core of MEMFS. If we want to keep this lazy file concept I feel it should
+    // be its own thin LAZYFS proxying calls to MEMFS.
+    if (properties.contents) {
+      node.contents = properties.contents;
+    } else if (properties.url) {
+      node.contents = null;
+      node.url = properties.url;
+    }
+    // Add a function that defers querying the file size until it is asked the first time.
+    Object.defineProperties(node, {
+      usedBytes: {
+        get: function() {
+          return this.contents.length;
+        }
+      }
+    });
+    // override each stream op with one that tries to force load the lazy file first
+    var stream_ops = {};
+    var keys = Object.keys(node.stream_ops);
+    keys.forEach(key => {
+      var fn = node.stream_ops[key];
+      stream_ops[key] = (...args) => {
+        FS.forceLoadFile(node);
+        return fn(...args);
+      };
+    });
+    function writeChunks(stream, buffer, offset, length, position) {
+      var contents = stream.node.contents;
+      if (position >= contents.length) return 0;
+      var size = Math.min(contents.length - position, length);
+      if (contents.slice) {
+        // normal array
+        for (var i = 0; i < size; i++) {
+          buffer[offset + i] = contents[position + i];
+        }
+      } else {
+        for (var i = 0; i < size; i++) {
+          // LazyUint8Array from sync binary XHR
+          buffer[offset + i] = contents.get(position + i);
+        }
+      }
+      return size;
+    }
+    // use a custom read function
+    stream_ops.read = (stream, buffer, offset, length, position) => {
+      FS.forceLoadFile(node);
+      return writeChunks(stream, buffer, offset, length, position);
+    };
+    // use a custom mmap function
+    stream_ops.mmap = (stream, length, position, prot, flags) => {
+      FS.forceLoadFile(node);
+      var ptr = mmapAlloc(length);
+      if (!ptr) {
+        throw new FS.ErrnoError(48);
+      }
+      writeChunks(stream, HEAP8, ptr, length, position);
+      return {
+        ptr,
+        allocated: true
+      };
+    };
+    node.stream_ops = stream_ops;
+    return node;
+  }
+};
+
+Module["FS"] = FS;
+
+var SYSCALLS = {
+  DEFAULT_POLLMASK: 5,
+  calculateAt(dirfd, path, allowEmpty) {
+    if (PATH.isAbs(path)) {
+      return path;
+    }
+    // relative path
+    var dir;
+    if (dirfd === -100) {
+      dir = FS.cwd();
+    } else {
+      var dirstream = SYSCALLS.getStreamFromFD(dirfd);
+      dir = dirstream.path;
+    }
+    if (path.length == 0) {
+      if (!allowEmpty) {
+        throw new FS.ErrnoError(44);
+      }
+      return dir;
+    }
+    return PATH.join2(dir, path);
+  },
+  doStat(func, path, buf) {
+    var stat = func(path);
+    HEAP32[((buf) >> 2)] = stat.dev;
+    HEAP32[(((buf) + (4)) >> 2)] = stat.mode;
+    HEAPU32[(((buf) + (8)) >> 2)] = stat.nlink;
+    HEAP32[(((buf) + (12)) >> 2)] = stat.uid;
+    HEAP32[(((buf) + (16)) >> 2)] = stat.gid;
+    HEAP32[(((buf) + (20)) >> 2)] = stat.rdev;
+    (tempI64 = [ stat.size >>> 0, (tempDouble = stat.size, (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], 
+    HEAP32[(((buf) + (24)) >> 2)] = tempI64[0], HEAP32[(((buf) + (28)) >> 2)] = tempI64[1]);
+    HEAP32[(((buf) + (32)) >> 2)] = 4096;
+    HEAP32[(((buf) + (36)) >> 2)] = stat.blocks;
+    var atime = stat.atime.getTime();
+    var mtime = stat.mtime.getTime();
+    var ctime = stat.ctime.getTime();
+    (tempI64 = [ Math.floor(atime / 1e3) >>> 0, (tempDouble = Math.floor(atime / 1e3), 
+    (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], 
+    HEAP32[(((buf) + (40)) >> 2)] = tempI64[0], HEAP32[(((buf) + (44)) >> 2)] = tempI64[1]);
+    HEAPU32[(((buf) + (48)) >> 2)] = (atime % 1e3) * 1e3 * 1e3;
+    (tempI64 = [ Math.floor(mtime / 1e3) >>> 0, (tempDouble = Math.floor(mtime / 1e3), 
+    (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], 
+    HEAP32[(((buf) + (56)) >> 2)] = tempI64[0], HEAP32[(((buf) + (60)) >> 2)] = tempI64[1]);
+    HEAPU32[(((buf) + (64)) >> 2)] = (mtime % 1e3) * 1e3 * 1e3;
+    (tempI64 = [ Math.floor(ctime / 1e3) >>> 0, (tempDouble = Math.floor(ctime / 1e3), 
+    (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], 
+    HEAP32[(((buf) + (72)) >> 2)] = tempI64[0], HEAP32[(((buf) + (76)) >> 2)] = tempI64[1]);
+    HEAPU32[(((buf) + (80)) >> 2)] = (ctime % 1e3) * 1e3 * 1e3;
+    (tempI64 = [ stat.ino >>> 0, (tempDouble = stat.ino, (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], 
+    HEAP32[(((buf) + (88)) >> 2)] = tempI64[0], HEAP32[(((buf) + (92)) >> 2)] = tempI64[1]);
+    return 0;
+  },
+  doMsync(addr, stream, len, flags, offset) {
+    if (!FS.isFile(stream.node.mode)) {
+      throw new FS.ErrnoError(43);
+    }
+    if (flags & 2) {
+      // MAP_PRIVATE calls need not to be synced back to underlying fs
+      return 0;
+    }
+    var buffer = HEAPU8.slice(addr, addr + len);
+    FS.msync(stream, buffer, offset, len, flags);
+  },
+  getStreamFromFD(fd) {
+    var stream = FS.getStreamChecked(fd);
+    return stream;
+  },
+  varargs: undefined,
+  getStr(ptr) {
+    var ret = UTF8ToString(ptr);
+    return ret;
+  }
+};
+
+function ___syscall_fcntl64(fd, cmd, varargs) {
+  SYSCALLS.varargs = varargs;
+  try {
+    var stream = SYSCALLS.getStreamFromFD(fd);
+    switch (cmd) {
+     case 0:
+      {
+        var arg = syscallGetVarargI();
+        if (arg < 0) {
+          return -28;
+        }
+        while (FS.streams[arg]) {
+          arg++;
+        }
+        var newStream;
+        newStream = FS.dupStream(stream, arg);
+        return newStream.fd;
+      }
+
+     case 1:
+     case 2:
+      return 0;
+
+     // FD_CLOEXEC makes no sense for a single process.
+      case 3:
+      return stream.flags;
+
+     case 4:
+      {
+        var arg = syscallGetVarargI();
+        stream.flags |= arg;
+        return 0;
+      }
+
+     case 12:
+      {
+        var arg = syscallGetVarargP();
+        var offset = 0;
+        // We're always unlocked.
+        HEAP16[(((arg) + (offset)) >> 1)] = 2;
+        return 0;
+      }
+
+     case 13:
+     case 14:
+      return 0;
+    }
+    // Pretend that the locking is successful.
+    return -28;
+  } catch (e) {
+    if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e;
+    return -e.errno;
+  }
+}
+
+function ___syscall_ioctl(fd, op, varargs) {
+  SYSCALLS.varargs = varargs;
+  try {
+    var stream = SYSCALLS.getStreamFromFD(fd);
+    switch (op) {
+     case 21509:
+      {
+        if (!stream.tty) return -59;
+        return 0;
+      }
+
+     case 21505:
+      {
+        if (!stream.tty) return -59;
+        if (stream.tty.ops.ioctl_tcgets) {
+          var termios = stream.tty.ops.ioctl_tcgets(stream);
+          var argp = syscallGetVarargP();
+          HEAP32[((argp) >> 2)] = termios.c_iflag || 0;
+          HEAP32[(((argp) + (4)) >> 2)] = termios.c_oflag || 0;
+          HEAP32[(((argp) + (8)) >> 2)] = termios.c_cflag || 0;
+          HEAP32[(((argp) + (12)) >> 2)] = termios.c_lflag || 0;
+          for (var i = 0; i < 32; i++) {
+            HEAP8[(argp + i) + (17)] = termios.c_cc[i] || 0;
+          }
+          return 0;
+        }
+        return 0;
+      }
+
+     case 21510:
+     case 21511:
+     case 21512:
+      {
+        if (!stream.tty) return -59;
+        return 0;
+      }
+
+     // no-op, not actually adjusting terminal settings
+      case 21506:
+     case 21507:
+     case 21508:
+      {
+        if (!stream.tty) return -59;
+        if (stream.tty.ops.ioctl_tcsets) {
+          var argp = syscallGetVarargP();
+          var c_iflag = HEAP32[((argp) >> 2)];
+          var c_oflag = HEAP32[(((argp) + (4)) >> 2)];
+          var c_cflag = HEAP32[(((argp) + (8)) >> 2)];
+          var c_lflag = HEAP32[(((argp) + (12)) >> 2)];
+          var c_cc = [];
+          for (var i = 0; i < 32; i++) {
+            c_cc.push(HEAP8[(argp + i) + (17)]);
+          }
+          return stream.tty.ops.ioctl_tcsets(stream.tty, op, {
+            c_iflag,
+            c_oflag,
+            c_cflag,
+            c_lflag,
+            c_cc
+          });
+        }
+        return 0;
+      }
+
+     // no-op, not actually adjusting terminal settings
+      case 21519:
+      {
+        if (!stream.tty) return -59;
+        var argp = syscallGetVarargP();
+        HEAP32[((argp) >> 2)] = 0;
+        return 0;
+      }
+
+     case 21520:
+      {
+        if (!stream.tty) return -59;
+        return -28;
+      }
+
+     // not supported
+      case 21531:
+      {
+        var argp = syscallGetVarargP();
+        return FS.ioctl(stream, op, argp);
+      }
+
+     case 21523:
+      {
+        // TODO: in theory we should write to the winsize struct that gets
+        // passed in, but for now musl doesn't read anything on it
+        if (!stream.tty) return -59;
+        if (stream.tty.ops.ioctl_tiocgwinsz) {
+          var winsize = stream.tty.ops.ioctl_tiocgwinsz(stream.tty);
+          var argp = syscallGetVarargP();
+          HEAP16[((argp) >> 1)] = winsize[0];
+          HEAP16[(((argp) + (2)) >> 1)] = winsize[1];
+        }
+        return 0;
+      }
+
+     case 21524:
+      {
+        // TODO: technically, this ioctl call should change the window size.
+        // but, since emscripten doesn't have any concept of a terminal window
+        // yet, we'll just silently throw it away as we do TIOCGWINSZ
+        if (!stream.tty) return -59;
+        return 0;
+      }
+
+     case 21515:
+      {
+        if (!stream.tty) return -59;
+        return 0;
+      }
+
+     default:
+      return -28;
+    }
+  } // not supported
+  catch (e) {
+    if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e;
+    return -e.errno;
+  }
+}
+
+function ___syscall_openat(dirfd, path, flags, varargs) {
+  SYSCALLS.varargs = varargs;
+  try {
+    path = SYSCALLS.getStr(path);
+    path = SYSCALLS.calculateAt(dirfd, path);
+    var mode = varargs ? syscallGetVarargI() : 0;
+    return FS.open(path, flags, mode).fd;
+  } catch (e) {
+    if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e;
+    return -e.errno;
+  }
+}
+
+var __abort_js = () => {
+  abort("");
+};
+
+var nowIsMonotonic = 1;
+
+var __emscripten_get_now_is_monotonic = () => nowIsMonotonic;
+
+var __emscripten_memcpy_js = (dest, src, num) => HEAPU8.copyWithin(dest, src, src + num);
+
+var stringToUTF8 = (str, outPtr, maxBytesToWrite) => stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite);
+
+var __tzset_js = (timezone, daylight, std_name, dst_name) => {
+  // TODO: Use (malleable) environment variables instead of system settings.
+  var currentYear = (new Date).getFullYear();
+  var winter = new Date(currentYear, 0, 1);
+  var summer = new Date(currentYear, 6, 1);
+  var winterOffset = winter.getTimezoneOffset();
+  var summerOffset = summer.getTimezoneOffset();
+  // Local standard timezone offset. Local standard time is not adjusted for
+  // daylight savings.  This code uses the fact that getTimezoneOffset returns
+  // a greater value during Standard Time versus Daylight Saving Time (DST).
+  // Thus it determines the expected output during Standard Time, and it
+  // compares whether the output of the given date the same (Standard) or less
+  // (DST).
+  var stdTimezoneOffset = Math.max(winterOffset, summerOffset);
+  // timezone is specified as seconds west of UTC ("The external variable
+  // `timezone` shall be set to the difference, in seconds, between
+  // Coordinated Universal Time (UTC) and local standard time."), the same
+  // as returned by stdTimezoneOffset.
+  // See http://pubs.opengroup.org/onlinepubs/009695399/functions/tzset.html
+  HEAPU32[((timezone) >> 2)] = stdTimezoneOffset * 60;
+  HEAP32[((daylight) >> 2)] = Number(winterOffset != summerOffset);
+  var extractZone = timezoneOffset => {
+    // Why inverse sign?
+    // Read here https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/getTimezoneOffset
+    var sign = timezoneOffset >= 0 ? "-" : "+";
+    var absOffset = Math.abs(timezoneOffset);
+    var hours = String(Math.floor(absOffset / 60)).padStart(2, "0");
+    var minutes = String(absOffset % 60).padStart(2, "0");
+    return `UTC${sign}${hours}${minutes}`;
+  };
+  var winterName = extractZone(winterOffset);
+  var summerName = extractZone(summerOffset);
+  if (summerOffset < winterOffset) {
+    // Northern hemisphere
+    stringToUTF8(winterName, std_name, 17);
+    stringToUTF8(summerName, dst_name, 17);
+  } else {
+    stringToUTF8(winterName, dst_name, 17);
+    stringToUTF8(summerName, std_name, 17);
+  }
+};
+
+var _emscripten_date_now = () => Date.now();
+
+var _emscripten_get_now;
+
+// Modern environment where performance.now() is supported:
+// N.B. a shorter form "_emscripten_get_now = performance.now;" is
+// unfortunately not allowed even in current browsers (e.g. FF Nightly 75).
+_emscripten_get_now = () => performance.now();
+
+var getHeapMax = () => // Stay one Wasm page short of 4GB: while e.g. Chrome is able to allocate
+// full 4GB Wasm memories, the size will wrap back to 0 bytes in Wasm side
+// for any code that deals with heap sizes, which would require special
+// casing all heap size related code to treat 0 specially.
+2147483648;
+
+var growMemory = size => {
+  var b = wasmMemory.buffer;
+  var pages = (size - b.byteLength + 65535) / 65536;
+  try {
+    // round size grow request up to wasm page size (fixed 64KB per spec)
+    wasmMemory.grow(pages);
+    // .grow() takes a delta compared to the previous size
+    updateMemoryViews();
+    return 1;
+  } /*success*/ catch (e) {}
+};
+
+// implicit 0 return to save code size (caller will cast "undefined" into 0
+// anyhow)
+var _emscripten_resize_heap = requestedSize => {
+  var oldSize = HEAPU8.length;
+  // With CAN_ADDRESS_2GB or MEMORY64, pointers are already unsigned.
+  requestedSize >>>= 0;
+  // With multithreaded builds, races can happen (another thread might increase the size
+  // in between), so return a failure, and let the caller retry.
+  // Memory resize rules:
+  // 1.  Always increase heap size to at least the requested size, rounded up
+  //     to next page multiple.
+  // 2a. If MEMORY_GROWTH_LINEAR_STEP == -1, excessively resize the heap
+  //     geometrically: increase the heap size according to
+  //     MEMORY_GROWTH_GEOMETRIC_STEP factor (default +20%), At most
+  //     overreserve by MEMORY_GROWTH_GEOMETRIC_CAP bytes (default 96MB).
+  // 2b. If MEMORY_GROWTH_LINEAR_STEP != -1, excessively resize the heap
+  //     linearly: increase the heap size by at least
+  //     MEMORY_GROWTH_LINEAR_STEP bytes.
+  // 3.  Max size for the heap is capped at 2048MB-WASM_PAGE_SIZE, or by
+  //     MAXIMUM_MEMORY, or by ASAN limit, depending on which is smallest
+  // 4.  If we were unable to allocate as much memory, it may be due to
+  //     over-eager decision to excessively reserve due to (3) above.
+  //     Hence if an allocation fails, cut down on the amount of excess
+  //     growth, in an attempt to succeed to perform a smaller allocation.
+  // A limit is set for how much we can grow. We should not exceed that
+  // (the wasm binary specifies it, so if we tried, we'd fail anyhow).
+  var maxHeapSize = getHeapMax();
+  if (requestedSize > maxHeapSize) {
+    return false;
+  }
+  // Loop through potential heap size increases. If we attempt a too eager
+  // reservation that fails, cut down on the attempted size and reserve a
+  // smaller bump instead. (max 3 times, chosen somewhat arbitrarily)
+  for (var cutDown = 1; cutDown <= 4; cutDown *= 2) {
+    var overGrownHeapSize = oldSize * (1 + .2 / cutDown);
+    // ensure geometric growth
+    // but limit overreserving (default to capping at +96MB overgrowth at most)
+    overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296);
+    var newSize = Math.min(maxHeapSize, alignMemory(Math.max(requestedSize, overGrownHeapSize), 65536));
+    var replacement = growMemory(newSize);
+    if (replacement) {
+      return true;
+    }
+  }
+  return false;
+};
+
+var ENV = {};
+
+var getExecutableName = () => thisProgram || "./this.program";
+
+var getEnvStrings = () => {
+  if (!getEnvStrings.strings) {
+    // Default values.
+    // Browser language detection #8751
+    var lang = ((typeof navigator == "object" && navigator.languages && navigator.languages[0]) || "C").replace("-", "_") + ".UTF-8";
+    var env = {
+      "USER": "web_user",
+      "LOGNAME": "web_user",
+      "PATH": "/",
+      "PWD": "/",
+      "HOME": "/home/web_user",
+      "LANG": lang,
+      "_": getExecutableName()
+    };
+    // Apply the user-provided values, if any.
+    for (var x in ENV) {
+      // x is a key in ENV; if ENV[x] is undefined, that means it was
+      // explicitly set to be so. We allow user code to do that to
+      // force variables with default values to remain unset.
+      if (ENV[x] === undefined) delete env[x]; else env[x] = ENV[x];
+    }
+    var strings = [];
+    for (var x in env) {
+      strings.push(`${x}=${env[x]}`);
+    }
+    getEnvStrings.strings = strings;
+  }
+  return getEnvStrings.strings;
+};
+
+var stringToAscii = (str, buffer) => {
+  for (var i = 0; i < str.length; ++i) {
+    HEAP8[buffer++] = str.charCodeAt(i);
+  }
+  // Null-terminate the string
+  HEAP8[buffer] = 0;
+};
+
+var _environ_get = (__environ, environ_buf) => {
+  var bufSize = 0;
+  getEnvStrings().forEach((string, i) => {
+    var ptr = environ_buf + bufSize;
+    HEAPU32[(((__environ) + (i * 4)) >> 2)] = ptr;
+    stringToAscii(string, ptr);
+    bufSize += string.length + 1;
+  });
+  return 0;
+};
+
+var _environ_sizes_get = (penviron_count, penviron_buf_size) => {
+  var strings = getEnvStrings();
+  HEAPU32[((penviron_count) >> 2)] = strings.length;
+  var bufSize = 0;
+  strings.forEach(string => bufSize += string.length + 1);
+  HEAPU32[((penviron_buf_size) >> 2)] = bufSize;
+  return 0;
+};
+
+function _fd_close(fd) {
+  try {
+    var stream = SYSCALLS.getStreamFromFD(fd);
+    FS.close(stream);
+    return 0;
+  } catch (e) {
+    if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e;
+    return e.errno;
+  }
+}
+
+/** @param {number=} offset */ var doReadv = (stream, iov, iovcnt, offset) => {
+  var ret = 0;
+  for (var i = 0; i < iovcnt; i++) {
+    var ptr = HEAPU32[((iov) >> 2)];
+    var len = HEAPU32[(((iov) + (4)) >> 2)];
+    iov += 8;
+    var curr = FS.read(stream, HEAP8, ptr, len, offset);
+    if (curr < 0) return -1;
+    ret += curr;
+    if (curr < len) break;
+    // nothing more to read
+    if (typeof offset != "undefined") {
+      offset += curr;
+    }
+  }
+  return ret;
+};
+
+function _fd_read(fd, iov, iovcnt, pnum) {
+  try {
+    var stream = SYSCALLS.getStreamFromFD(fd);
+    var num = doReadv(stream, iov, iovcnt);
+    HEAPU32[((pnum) >> 2)] = num;
+    return 0;
+  } catch (e) {
+    if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e;
+    return e.errno;
+  }
+}
+
+var convertI32PairToI53Checked = (lo, hi) => ((hi + 2097152) >>> 0 < 4194305 - !!lo) ? (lo >>> 0) + hi * 4294967296 : NaN;
+
+function _fd_seek(fd, offset_low, offset_high, whence, newOffset) {
+  var offset = convertI32PairToI53Checked(offset_low, offset_high);
+  try {
+    if (isNaN(offset)) return 61;
+    var stream = SYSCALLS.getStreamFromFD(fd);
+    FS.llseek(stream, offset, whence);
+    (tempI64 = [ stream.position >>> 0, (tempDouble = stream.position, (+(Math.abs(tempDouble))) >= 1 ? (tempDouble > 0 ? (+(Math.floor((tempDouble) / 4294967296))) >>> 0 : (~~((+(Math.ceil((tempDouble - +(((~~(tempDouble))) >>> 0)) / 4294967296))))) >>> 0) : 0) ], 
+    HEAP32[((newOffset) >> 2)] = tempI64[0], HEAP32[(((newOffset) + (4)) >> 2)] = tempI64[1]);
+    if (stream.getdents && offset === 0 && whence === 0) stream.getdents = null;
+    // reset readdir state
+    return 0;
+  } catch (e) {
+    if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e;
+    return e.errno;
+  }
+}
+
+/** @param {number=} offset */ var doWritev = (stream, iov, iovcnt, offset) => {
+  var ret = 0;
+  for (var i = 0; i < iovcnt; i++) {
+    var ptr = HEAPU32[((iov) >> 2)];
+    var len = HEAPU32[(((iov) + (4)) >> 2)];
+    iov += 8;
+    var curr = FS.write(stream, HEAP8, ptr, len, offset);
+    if (curr < 0) return -1;
+    ret += curr;
+    if (curr < len) {
+      // No more space to write.
+      break;
+    }
+    if (typeof offset != "undefined") {
+      offset += curr;
+    }
+  }
+  return ret;
+};
+
+function _fd_write(fd, iov, iovcnt, pnum) {
+  try {
+    var stream = SYSCALLS.getStreamFromFD(fd);
+    var num = doWritev(stream, iov, iovcnt);
+    HEAPU32[((pnum) >> 2)] = num;
+    return 0;
+  } catch (e) {
+    if (typeof FS == "undefined" || !(e.name === "ErrnoError")) throw e;
+    return e.errno;
+  }
+}
+
+var getCFunc = ident => {
+  var func = Module["_" + ident];
+  // closure exported function
+  return func;
+};
+
+var writeArrayToMemory = (array, buffer) => {
+  HEAP8.set(array, buffer);
+};
+
+var stackAlloc = sz => __emscripten_stack_alloc(sz);
+
+var stringToUTF8OnStack = str => {
+  var size = lengthBytesUTF8(str) + 1;
+  var ret = stackAlloc(size);
+  stringToUTF8(str, ret, size);
+  return ret;
+};
+
+/**
+     * @param {string|null=} returnType
+     * @param {Array=} argTypes
+     * @param {Arguments|Array=} args
+     * @param {Object=} opts
+     */ var ccall = (ident, returnType, argTypes, args, opts) => {
+  // For fast lookup of conversion functions
+  var toC = {
+    "string": str => {
+      var ret = 0;
+      if (str !== null && str !== undefined && str !== 0) {
+        // null string
+        // at most 4 bytes per UTF-8 code point, +1 for the trailing '\0'
+        ret = stringToUTF8OnStack(str);
+      }
+      return ret;
+    },
+    "array": arr => {
+      var ret = stackAlloc(arr.length);
+      writeArrayToMemory(arr, ret);
+      return ret;
+    }
+  };
+  function convertReturnValue(ret) {
+    if (returnType === "string") {
+      return UTF8ToString(ret);
+    }
+    if (returnType === "boolean") return Boolean(ret);
+    return ret;
+  }
+  var func = getCFunc(ident);
+  var cArgs = [];
+  var stack = 0;
+  if (args) {
+    for (var i = 0; i < args.length; i++) {
+      var converter = toC[argTypes[i]];
+      if (converter) {
+        if (stack === 0) stack = stackSave();
+        cArgs[i] = converter(args[i]);
+      } else {
+        cArgs[i] = args[i];
+      }
+    }
+  }
+  var ret = func(...cArgs);
+  function onDone(ret) {
+    if (stack !== 0) stackRestore(stack);
+    return convertReturnValue(ret);
+  }
+  ret = onDone(ret);
+  return ret;
+};
+
+/**
+     * @param {string=} returnType
+     * @param {Array=} argTypes
+     * @param {Object=} opts
+     */ var cwrap = (ident, returnType, argTypes, opts) => {
+  // When the function takes numbers and returns a number, we can just return
+  // the original function
+  var numericArgs = !argTypes || argTypes.every(type => type === "number" || type === "boolean");
+  var numericRet = returnType !== "string";
+  if (numericRet && numericArgs && !opts) {
+    return getCFunc(ident);
+  }
+  return (...args) => ccall(ident, returnType, argTypes, args, opts);
+};
+
+var stringToNewUTF8 = str => {
+  var size = lengthBytesUTF8(str) + 1;
+  var ret = _malloc(size);
+  if (ret) stringToUTF8(str, ret, size);
+  return ret;
+};
+
+FS.createPreloadedFile = FS_createPreloadedFile;
+
+FS.staticInit();
+
+var wasmImports = {
+  /** @export */ __assert_fail: ___assert_fail,
+  /** @export */ __cxa_throw: ___cxa_throw,
+  /** @export */ __syscall_fcntl64: ___syscall_fcntl64,
+  /** @export */ __syscall_ioctl: ___syscall_ioctl,
+  /** @export */ __syscall_openat: ___syscall_openat,
+  /** @export */ _abort_js: __abort_js,
+  /** @export */ _emscripten_get_now_is_monotonic: __emscripten_get_now_is_monotonic,
+  /** @export */ _emscripten_memcpy_js: __emscripten_memcpy_js,
+  /** @export */ _tzset_js: __tzset_js,
+  /** @export */ emscripten_date_now: _emscripten_date_now,
+  /** @export */ emscripten_get_now: _emscripten_get_now,
+  /** @export */ emscripten_resize_heap: _emscripten_resize_heap,
+  /** @export */ environ_get: _environ_get,
+  /** @export */ environ_sizes_get: _environ_sizes_get,
+  /** @export */ fd_close: _fd_close,
+  /** @export */ fd_read: _fd_read,
+  /** @export */ fd_seek: _fd_seek,
+  /** @export */ fd_write: _fd_write
+};
+
+var wasmExports = createWasm();
+
+var ___wasm_call_ctors = () => (___wasm_call_ctors = wasmExports["__wasm_call_ctors"])();
+
+var _Hunspell_create = Module["_Hunspell_create"] = (a0, a1) => (_Hunspell_create = Module["_Hunspell_create"] = wasmExports["Hunspell_create"])(a0, a1);
+
+var _Hunspell_destroy = Module["_Hunspell_destroy"] = a0 => (_Hunspell_destroy = Module["_Hunspell_destroy"] = wasmExports["Hunspell_destroy"])(a0);
+
+var _Hunspell_add_dic = Module["_Hunspell_add_dic"] = (a0, a1) => (_Hunspell_add_dic = Module["_Hunspell_add_dic"] = wasmExports["Hunspell_add_dic"])(a0, a1);
+
+var _Hunspell_spell = Module["_Hunspell_spell"] = (a0, a1) => (_Hunspell_spell = Module["_Hunspell_spell"] = wasmExports["Hunspell_spell"])(a0, a1);
+
+var _Hunspell_suggest = Module["_Hunspell_suggest"] = (a0, a1, a2) => (_Hunspell_suggest = Module["_Hunspell_suggest"] = wasmExports["Hunspell_suggest"])(a0, a1, a2);
+
+var _Hunspell_add = Module["_Hunspell_add"] = (a0, a1) => (_Hunspell_add = Module["_Hunspell_add"] = wasmExports["Hunspell_add"])(a0, a1);
+
+var _Hunspell_remove = Module["_Hunspell_remove"] = (a0, a1) => (_Hunspell_remove = Module["_Hunspell_remove"] = wasmExports["Hunspell_remove"])(a0, a1);
+
+var _Hunspell_free_list = Module["_Hunspell_free_list"] = (a0, a1, a2) => (_Hunspell_free_list = Module["_Hunspell_free_list"] = wasmExports["Hunspell_free_list"])(a0, a1, a2);
+
+var _malloc = Module["_malloc"] = a0 => (_malloc = Module["_malloc"] = wasmExports["malloc"])(a0);
+
+var _free = Module["_free"] = a0 => (_free = Module["_free"] = wasmExports["free"])(a0);
+
+var __emscripten_stack_restore = a0 => (__emscripten_stack_restore = wasmExports["_emscripten_stack_restore"])(a0);
+
+var __emscripten_stack_alloc = a0 => (__emscripten_stack_alloc = wasmExports["_emscripten_stack_alloc"])(a0);
+
+var _emscripten_stack_get_current = () => (_emscripten_stack_get_current = wasmExports["emscripten_stack_get_current"])();
+
+var dynCall_iiiiij = Module["dynCall_iiiiij"] = (a0, a1, a2, a3, a4, a5, a6) => (dynCall_iiiiij = Module["dynCall_iiiiij"] = wasmExports["dynCall_iiiiij"])(a0, a1, a2, a3, a4, a5, a6);
+
+var dynCall_iiiiijj = Module["dynCall_iiiiijj"] = (a0, a1, a2, a3, a4, a5, a6, a7, a8) => (dynCall_iiiiijj = Module["dynCall_iiiiijj"] = wasmExports["dynCall_iiiiijj"])(a0, a1, a2, a3, a4, a5, a6, a7, a8);
+
+var dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = (a0, a1, a2, a3, a4, a5, a6, a7, a8, a9) => (dynCall_iiiiiijj = Module["dynCall_iiiiiijj"] = wasmExports["dynCall_iiiiiijj"])(a0, a1, a2, a3, a4, a5, a6, a7, a8, a9);
+
+var dynCall_jiji = Module["dynCall_jiji"] = (a0, a1, a2, a3, a4) => (dynCall_jiji = Module["dynCall_jiji"] = wasmExports["dynCall_jiji"])(a0, a1, a2, a3, a4);
+
+var dynCall_viijii = Module["dynCall_viijii"] = (a0, a1, a2, a3, a4, a5, a6) => (dynCall_viijii = Module["dynCall_viijii"] = wasmExports["dynCall_viijii"])(a0, a1, a2, a3, a4, a5, a6);
+
+// include: postamble.js
+// === Auto-generated postamble setup entry stuff ===
+Module["ccall"] = ccall;
+
+Module["cwrap"] = cwrap;
+
+Module["getValue"] = getValue;
+
+Module["UTF8ToString"] = UTF8ToString;
+
+Module["stringToNewUTF8"] = stringToNewUTF8;
+
+Module["WORKERFS"] = WORKERFS;
+
+var calledRun;
+
+dependenciesFulfilled = function runCaller() {
+  // If run has never been called, and we should call run (INVOKE_RUN is true, and Module.noInitialRun is not false)
+  if (!calledRun) run();
+  if (!calledRun) dependenciesFulfilled = runCaller;
+};
+
+// try this again later, after new deps are fulfilled
+function run() {
+  if (runDependencies > 0) {
+    return;
+  }
+  preRun();
+  // a preRun added a dependency, run will be called later
+  if (runDependencies > 0) {
+    return;
+  }
+  function doRun() {
+    // run may have just been called through dependencies being fulfilled just in this very frame,
+    // or while the async setStatus time below was happening
+    if (calledRun) return;
+    calledRun = true;
+    Module["calledRun"] = true;
+    if (ABORT) return;
+    initRuntime();
+    readyPromiseResolve(Module);
+    Module["onRuntimeInitialized"]?.();
+    postRun();
+  }
+  if (Module["setStatus"]) {
+    Module["setStatus"]("Running...");
+    setTimeout(() => {
+      setTimeout(() => Module["setStatus"](""), 1);
+      doRun();
+    }, 1);
+  } else {
+    doRun();
+  }
+}
+
+if (Module["preInit"]) {
+  if (typeof Module["preInit"] == "function") Module["preInit"] = [ Module["preInit"] ];
+  while (Module["preInit"].length > 0) {
+    Module["preInit"].pop()();
+  }
+}
+
+run();
+
+// end include: postamble.js
+// include: postamble_modularize.js
+// In MODULARIZE mode we wrap the generated code in a factory function
+// and return either the Module itself, or a promise of the module.
+// We assign to the `moduleRtn` global here and configure closure to see
+// this as and extern so it won't get minified.
+moduleRtn = readyPromise;
+
+
+  return moduleRtn;
+}
+);
+})();
+export default Module;

BIN
services/web/frontend/js/features/source-editor/hunspell/wasm/hunspell.wasm


+ 2 - 0
services/web/frontend/js/utils/meta.ts

@@ -54,6 +54,7 @@ export interface Meta {
   'ol-allowedExperiments': string[]
   'ol-allowedImageNames': AllowedImageName[]
   'ol-anonymous': boolean
+  'ol-baseAssetPath': string
   'ol-bootstrapVersion': 3 | 5
   'ol-brandVariation': Record<string, any>
 
@@ -76,6 +77,7 @@ export interface Meta {
   'ol-currentUrl': string
   'ol-debugPdfDetach': boolean
   'ol-detachRole': 'detached' | 'detacher' | ''
+  'ol-dictionariesRoot': 'string'
   'ol-dropbox': { error: boolean; registered: boolean }
   'ol-editorThemes': string[]
   'ol-email': string

+ 1 - 0
services/web/locales/en.json

@@ -77,6 +77,7 @@
   "add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
   "add_people": "Add people",
   "add_role_and_department": "Add role and department",
+  "add_to_dictionary": "Add to Dictionary",
   "add_to_tag": "Add to tag",
   "add_your_comment_here": "Add your comment here",
   "add_your_first_group_member_now": "Add your first group members now",

+ 1 - 0
services/web/package.json

@@ -76,6 +76,7 @@
     "@node-oauth/oauth2-server": "^5.1.0",
     "@node-saml/passport-saml": "^4.0.4",
     "@overleaf/access-token-encryptor": "*",
+    "@overleaf/dictionaries": "https://github.com/overleaf/dictionaries/archive/refs/tags/v0.0.2.tar.gz",
     "@overleaf/fetch-utils": "*",
     "@overleaf/logger": "*",
     "@overleaf/metrics": "*",

+ 100 - 0
services/web/test/frontend/features/dictionary/components/dictionary-modal-content.spec.jsx

@@ -0,0 +1,100 @@
+import DictionaryModal from '@/features/dictionary/components/dictionary-modal'
+import { EditorProviders } from '../../../helpers/editor-providers'
+
+describe('<DictionaryModalContent />', function () {
+  beforeEach(function () {
+    cy.interceptCompile()
+  })
+
+  afterEach(function () {
+    cy.window().then(win => {
+      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    })
+  })
+
+  it('list words', function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-learnedWords', ['foo', 'bar'])
+      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    })
+
+    cy.mount(
+      <EditorProviders>
+        <DictionaryModal show handleHide={cy.stub()} />
+      </EditorProviders>
+    )
+
+    cy.findByText('foo')
+    cy.findByText('bar')
+  })
+
+  it('shows message when empty', function () {
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-learnedWords', [])
+      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    })
+
+    cy.mount(
+      <EditorProviders>
+        <DictionaryModal show handleHide={cy.stub()} />
+      </EditorProviders>
+    )
+
+    cy.contains('Your custom dictionary is empty.')
+  })
+
+  it('removes words', function () {
+    cy.intercept('/spelling/unlearn', { statusCode: 200 })
+
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-learnedWords', ['Foo', 'bar'])
+      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    })
+
+    cy.mount(
+      <EditorProviders>
+        <DictionaryModal show handleHide={cy.stub()} />
+      </EditorProviders>
+    )
+
+    cy.findByText('Foo')
+    cy.findByText('bar')
+
+    cy.findAllByRole('button', {
+      name: 'Remove from dictionary',
+    })
+      .eq(0)
+      .click()
+
+    cy.findByText('bar').should('not.exist')
+    cy.findByText('Foo')
+  })
+
+  it('handles errors', function () {
+    cy.intercept('/spelling/unlearn', { statusCode: 500 }).as('unlearn')
+
+    cy.window().then(win => {
+      win.metaAttributesCache.set('ol-learnedWords', ['foo'])
+      win.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+    })
+
+    cy.mount(
+      <EditorProviders>
+        <DictionaryModal show handleHide={cy.stub()} />
+      </EditorProviders>
+    )
+
+    cy.findByText('foo')
+
+    cy.findAllByRole('button', {
+      name: 'Remove from dictionary',
+    })
+      .eq(0)
+      .click()
+
+    cy.wait('@unlearn')
+
+    cy.findByText('Sorry, something went wrong')
+    cy.findByText('foo')
+  })
+})

+ 0 - 60
services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.jsx

@@ -1,60 +0,0 @@
-import {
-  screen,
-  fireEvent,
-  waitForElementToBeRemoved,
-} from '@testing-library/react'
-import fetchMock from 'fetch-mock'
-import DictionaryModal from '../../../../../frontend/js/features/dictionary/components/dictionary-modal'
-import { renderWithEditorContext } from '../../../helpers/render-with-context'
-
-function setLearnedWords(words) {
-  window.metaAttributesCache.set('ol-learnedWords', words)
-  window.dispatchEvent(new CustomEvent('learnedWords:doreset'))
-}
-describe('<DictionaryModalContent />', function () {
-  afterEach(function () {
-    fetchMock.reset()
-    setLearnedWords([])
-  })
-
-  it('list words', async function () {
-    setLearnedWords(['foo', 'bar'])
-    renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
-    screen.getByText('foo')
-    screen.getByText('bar')
-  })
-
-  it('shows message when empty', async function () {
-    setLearnedWords([])
-    renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
-    screen.getByText('Your custom dictionary is empty.')
-  })
-
-  it('removes words', async function () {
-    fetchMock.post('/spelling/unlearn', 200)
-    setLearnedWords(['Foo', 'bar'])
-    renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
-    screen.getByText('Foo')
-    screen.getByText('bar')
-    const [firstButton] = screen.getAllByRole('button', {
-      name: 'Remove from dictionary',
-    })
-    fireEvent.click(firstButton)
-    await waitForElementToBeRemoved(() => screen.getByText('bar'))
-    screen.getByText('Foo')
-  })
-
-  it('handles errors', async function () {
-    fetchMock.post('/spelling/unlearn', 500)
-    setLearnedWords(['foo'])
-    renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
-    screen.getByText('foo')
-    const [firstButton] = screen.getAllByRole('button', {
-      name: 'Remove from dictionary',
-    })
-    fireEvent.click(firstButton)
-    await fetchMock.flush()
-    screen.getByText('Sorry, something went wrong')
-    screen.getByText('foo')
-  })
-})

+ 85 - 0
services/web/test/frontend/features/source-editor/components/codemirror-editor-spellchecker-client.spec.tsx

@@ -0,0 +1,85 @@
+import '../../../helpers/bootstrap-3'
+import { mockScope } from '../helpers/mock-scope'
+import { EditorProviders } from '../../../helpers/editor-providers'
+import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
+import { TestContainer } from '../helpers/test-container'
+import forEach from 'mocha-each'
+import PackageVersions from '../../../../../app/src/infrastructure/PackageVersions'
+
+const languages = [
+  { code: 'en_GB', dic: 'en_GB', name: 'English (British)' },
+  { code: 'fr', dic: 'fr', name: 'French' },
+  { code: 'sv', dic: 'sv_SE', name: 'Swedish' },
+]
+
+const suggestions = {
+  en_GB: ['medecine', 'medicine'],
+  fr: ['medecin', 'médecin'],
+  sv: ['medecin', 'medicin'],
+}
+
+forEach(Object.keys(suggestions)).describe(
+  'Spell check in client (%s)',
+  (spellCheckLanguage: keyof typeof suggestions) => {
+    const content = `
+\\documentclass{}
+
+\\title{}
+\\author{}
+
+\\begin{document}
+\\maketitle
+
+\\begin{abstract}
+\\end{abstract}
+
+\\section{}
+
+\\end{document}`
+
+    beforeEach(function () {
+      cy.window().then(win => {
+        win.metaAttributesCache.set('ol-preventCompileOnLoad', true)
+        win.metaAttributesCache.set('ol-splitTestVariants', {
+          'spell-check-client': 'enabled',
+        })
+        win.metaAttributesCache.set('ol-splitTestInfo', {})
+        win.metaAttributesCache.set('ol-learnedWords', ['baz'])
+        win.metaAttributesCache.set(
+          'ol-dictionariesRoot',
+          `js/dictionaries/${PackageVersions.version.dictionaries}/`
+        )
+        win.metaAttributesCache.set('ol-baseAssetPath', '/__cypress/src/')
+        win.metaAttributesCache.set('ol-languages', languages)
+      })
+
+      cy.interceptEvents()
+
+      const scope = mockScope(content)
+      scope.project.spellCheckLanguage = spellCheckLanguage
+
+      cy.mount(
+        <TestContainer>
+          <EditorProviders scope={scope}>
+            <CodeMirrorEditor />
+          </EditorProviders>
+        </TestContainer>
+      )
+
+      cy.get('.cm-line').eq(13).as('line')
+      cy.get('@line').click()
+    })
+
+    it('shows suggestions for misspelled word', function () {
+      const [from, to] = suggestions[spellCheckLanguage]
+
+      cy.get('@line').type(from)
+      cy.get('@line').get('.ol-cm-spelling-error').contains(from)
+
+      cy.get('@line').get('.ol-cm-spelling-error').rightclick()
+      cy.findByText(to).click()
+      cy.get('@line').contains(to)
+      cy.get('@line').find('.ol-cm-spelling-error').should('not.exist')
+    })
+  }
+)

+ 2 - 0
services/web/types/project-settings.ts

@@ -30,4 +30,6 @@ export type PdfViewer = 'pdfjs' | 'native'
 export type SpellCheckLanguage = {
   name: string
   code: string
+  dic?: string
+  server?: false
 }

+ 26 - 0
services/web/webpack.config.js

@@ -66,6 +66,7 @@ function getModuleDirectory(moduleName) {
 }
 
 const mathjaxDir = getModuleDirectory('mathjax')
+const dictionariesDir = getModuleDirectory('@overleaf/dictionaries')
 
 const pdfjsVersions = ['pdfjs-dist213', 'pdfjs-dist401']
 
@@ -78,6 +79,14 @@ if (MATHJAX_VERSION !== PackageVersions.version.mathjax) {
   )
 }
 
+const DICTIONARIES_VERSION =
+  require('@overleaf/dictionaries/package.json').version
+if (DICTIONARIES_VERSION !== PackageVersions.version.dictionaries) {
+  throw new Error(
+    '"@overleaf/dictionaries" version de-synced, update services/web/app/src/infrastructure/PackageVersions.js'
+  )
+}
+
 module.exports = {
   // Defines the "entry point(s)" for the application - i.e. the file which
   // bootstraps the application
@@ -141,6 +150,13 @@ module.exports = {
         ],
         type: 'javascript/auto',
       },
+      {
+        test: /\.wasm$/,
+        type: 'asset/resource',
+        generator: {
+          filename: 'js/[name]-[contenthash][ext]',
+        },
+      },
       {
         // Pass Less files through less-loader/css-loader/mini-css-extract-
         // plugin (note: run in reverse order)
@@ -275,6 +291,10 @@ module.exports = {
     },
   },
 
+  experiments: {
+    asyncWebAssembly: true,
+  },
+
   plugins: [
     new LezerGrammarCompilerPlugin(),
 
@@ -346,6 +366,12 @@ module.exports = {
           toType: 'dir',
           context: mathjaxDir,
         },
+        {
+          from: '*',
+          to: `js/dictionaries/${PackageVersions.version.dictionaries}`,
+          toType: 'dir',
+          context: `${dictionariesDir}/dictionaries`,
+        },
         ...pdfjsVersions.flatMap(version => {
           const dir = getModuleDirectory(version)